diff --git a/src/client/channel.rs b/src/client/channel.rs index fa4d0ac..c02d8fb 100644 --- a/src/client/channel.rs +++ b/src/client/channel.rs @@ -5,6 +5,7 @@ use time::OffsetDateTime; use url::Url; use crate::{ + client::response::YouTubeListItem, error::{Error, ExtractionError}, model::{ paginator::{ContinuationEndpoint, Paginator}, @@ -290,7 +291,7 @@ impl MapResponse>> for response::Channel { impl MapResponse for response::ChannelAbout { fn map_response( self, - _id: &str, + id: &str, _lang: Language, _deobf: Option<&crate::deobfuscate::DeobfData>, _visitor_data: Option<&str>, @@ -299,11 +300,21 @@ impl MapResponse for response::ChannelAbout { // and it allows parsing the country name. let lang = Language::En; - let ep = self - .on_response_received_endpoints - .into_iter() - .next() - .ok_or(ExtractionError::InvalidData("no received endpoint".into()))?; + let ep = match self { + response::ChannelAbout::ReceivedEndpoints { + on_response_received_endpoints, + } => on_response_received_endpoints + .into_iter() + .next() + .ok_or(ExtractionError::InvalidData("no received endpoint".into()))?, + response::ChannelAbout::Content { contents } => { + // Handle errors (e.g. age restriction) when regular channel content was returned + map_channel_content(id, contents, None)?; + return Err(ExtractionError::InvalidData( + "could not extract aboutData".into(), + )); + } + }; let continuations = ep.append_continuation_items_action.continuation_items; let about = continuations .c @@ -483,13 +494,6 @@ fn map_channel_content( match contents { Some(contents) => { let tabs = contents.two_column_browse_results_renderer.contents; - if tabs.is_empty() { - return Err(ExtractionError::NotFound { - id: id.to_owned(), - msg: "no tabs".into(), - }); - } - let cmp_url_suffix = |endpoint: &response::channel::ChannelTabEndpoint, expect: &str| { endpoint @@ -504,24 +508,46 @@ fn map_channel_content( let mut featured_tab = false; for tab in &tabs { - if cmp_url_suffix(&tab.tab_renderer.endpoint, "/featured") - && (tab.tab_renderer.content.section_list_renderer.is_some() - || tab.tab_renderer.content.rich_grid_renderer.is_some()) - { - featured_tab = true; - } else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/shorts") { - has_shorts = true; - } else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/streams") { - has_live = true; + if let Some(endpoint) = &tab.tab_renderer.endpoint { + if cmp_url_suffix(endpoint, "/featured") + && (tab.tab_renderer.content.section_list_renderer.is_some() + || tab.tab_renderer.content.rich_grid_renderer.is_some()) + { + featured_tab = true; + } else if cmp_url_suffix(endpoint, "/shorts") { + has_shorts = true; + } else if cmp_url_suffix(endpoint, "/streams") { + has_live = true; + } + } else { + // Check for age gate + if let Some(YouTubeListItem::ChannelAgeGateRenderer { + channel_title, + main_text, + }) = &tab + .tab_renderer + .content + .section_list_renderer + .as_ref() + .and_then(|c| c.contents.c.get(0)) + { + return Err(ExtractionError::Unavailable { + reason: crate::error::UnavailabilityReason::AgeRestricted, + msg: format!("{channel_title}: {main_text}"), + }); + } } } - let channel_content = tabs.into_iter().find_map(|tab| { - tab.tab_renderer - .content - .rich_grid_renderer - .or(tab.tab_renderer.content.section_list_renderer) - }); + let channel_content = tabs + .into_iter() + .filter(|t| t.tab_renderer.endpoint.is_some()) + .find_map(|tab| { + tab.tab_renderer + .content + .rich_grid_renderer + .or(tab.tab_renderer.content.section_list_renderer) + }); // YouTube may show the "Featured" tab if the requested tab is empty/does not exist let content = if featured_tab { @@ -530,9 +556,10 @@ fn map_channel_content( match channel_content { Some(list) => list.contents, None => { - return Err(ExtractionError::InvalidData( - "could not extract content".into(), - )) + return Err(ExtractionError::NotFound { + id: id.to_owned(), + msg: "no tabs".into(), + }); } } }; @@ -632,6 +659,7 @@ mod tests { use crate::{ client::{response, MapResponse}, + error::{ExtractionError, UnavailabilityReason}, model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem}, param::{ChannelOrder, ChannelVideoTab, Language}, serializer::MapResult, @@ -649,7 +677,7 @@ mod tests { #[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")] #[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")] #[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")] - #[case::richgrid2("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")] + #[case::coachella("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")] #[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")] #[case::livestreams("livestreams", "UC2DjFE7Xf11URZqWBigcVOQ")] fn map_channel_videos(#[case] name: &str, #[case] id: &str) { @@ -678,6 +706,23 @@ mod tests { } } + #[test] + fn channel_agegate() { + let json_path = path!(*TESTFILES / "channel" / format!("channel_agegate.json")); + let json_file = File::open(json_path).unwrap(); + + let channel: response::Channel = + serde_json::from_reader(BufReader::new(json_file)).unwrap(); + let res: Result>>, ExtractionError> = + channel.map_response("UCbfnHqxXs_K3kvaH-WlNlig", Language::En, None, None); + if let Err(ExtractionError::Unavailable { reason, msg }) = res { + assert_eq!(reason, UnavailabilityReason::AgeRestricted); + assert!(msg.starts_with("Laphroaig Whisky: ")); + } else { + panic!("invalid res: {res:?}") + } + } + #[rstest] fn map_channel_playlists() { let json_path = path!(*TESTFILES / "channel" / "channel_playlists.json"); diff --git a/src/client/player.rs b/src/client/player.rs index b948eda..1dceb7d 100644 --- a/src/client/player.rs +++ b/src/client/player.rs @@ -77,7 +77,7 @@ impl RustyPipeQuery { match tv_res { // Output desktop client error if the tv client is unsupported - Err(Error::Extraction(ExtractionError::VideoUnavailable { + Err(Error::Extraction(ExtractionError::Unavailable { reason: UnavailabilityReason::UnsupportedClient, .. })) => Err(Error::Extraction(e)), @@ -183,7 +183,7 @@ impl MapResponse for response::Player { _ => None, }) .unwrap_or_default(); - return Err(ExtractionError::VideoUnavailable { reason, msg }); + return Err(ExtractionError::Unavailable { reason, msg }); } response::player::PlayabilityStatus::LoginRequired { reason, messages } => { let mut msg = reason; @@ -205,10 +205,10 @@ impl MapResponse for response::Player { _ => None, }) .unwrap_or_default(); - return Err(ExtractionError::VideoUnavailable { reason, msg }); + return Err(ExtractionError::Unavailable { reason, msg }); } response::player::PlayabilityStatus::LiveStreamOffline { reason } => { - return Err(ExtractionError::VideoUnavailable { + return Err(ExtractionError::Unavailable { reason: UnavailabilityReason::OfflineLivestream, msg: reason, }); @@ -216,7 +216,7 @@ impl MapResponse for response::Player { response::player::PlayabilityStatus::Error { reason } => { // reason (censored): "This video has been removed for violating YouTube's policy on hate speech. Learn more about combating hate speech in your country." // reason: "This video is unavailable" - return Err(ExtractionError::VideoUnavailable { + return Err(ExtractionError::Unavailable { reason: UnavailabilityReason::Deleted, msg: reason, }); diff --git a/src/client/response/channel.rs b/src/client/response/channel.rs index b6a9259..a7d91b7 100644 --- a/src/client/response/channel.rs +++ b/src/client/response/channel.rs @@ -36,7 +36,7 @@ pub(crate) struct TabRendererWrap { pub(crate) struct TabRenderer { #[serde(default)] pub content: TabContent, - pub endpoint: ChannelTabEndpoint, + pub endpoint: Option, } #[serde_as] @@ -148,10 +148,16 @@ pub(crate) struct MicroformatDataRenderer { #[serde_as] #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ChannelAbout { - #[serde_as(as = "VecSkipError<_>")] - pub on_response_received_endpoints: Vec>, +#[serde(untagged)] +pub(crate) enum ChannelAbout { + #[serde(rename_all = "camelCase")] + ReceivedEndpoints { + #[serde_as(as = "VecSkipError<_>")] + on_response_received_endpoints: Vec>, + }, + Content { + contents: Option, + }, } #[derive(Debug, Deserialize)] diff --git a/src/client/response/video_item.rs b/src/client/response/video_item.rs index d25fe40..60d6141 100644 --- a/src/client/response/video_item.rs +++ b/src/client/response/video_item.rs @@ -69,6 +69,14 @@ pub(crate) enum YouTubeListItem { contents: MapResult>, }, + /// Age-restricted channel + #[serde(rename_all = "camelCase")] + ChannelAgeGateRenderer { + channel_title: String, + #[serde_as(as = "Text")] + main_text: String, + }, + /// No video list item (e.g. ad) or unimplemented item /// /// Unimplemented: @@ -704,7 +712,7 @@ impl YouTubeListMapper { self.warnings.append(&mut contents.warnings); contents.c.into_iter().for_each(|it| self.map_item(it)); } - YouTubeListItem::None => {} + YouTubeListItem::None | YouTubeListItem::ChannelAgeGateRenderer { .. } => {} } } diff --git a/src/error.rs b/src/error.rs index 0b29cc0..104387f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -24,14 +24,15 @@ pub enum Error { /// Error extracting content from YouTube #[derive(thiserror::Error, Debug)] pub enum ExtractionError { - /// Video cannot be extracted with RustyPipe + /// Content cannot be extracted with RustyPipe /// /// Reasons include: /// - Deletion/Censorship - /// - Private video that requires a Google account + /// - Age restriction + /// - Private video /// - DRM (Movies and TV shows) - #[error("video cant be played because it is {reason}. Reason (from YT): {msg}")] - VideoUnavailable { + #[error("content unavailable because it is {reason}. Reason (from YT): {msg}")] + Unavailable { /// Reason why the video could not be extracted reason: UnavailabilityReason, /// The error message as returned from YouTube @@ -77,9 +78,9 @@ pub enum ExtractionError { #[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum UnavailabilityReason { - /// Video is age restricted. + /// Video/Channel is age restricted. /// - /// Age restriction may be circumvented with the + /// Video age restriction may be circumvented with the /// [`ClientType::TvHtml5Embed`](crate::client::ClientType::TvHtml5Embed) client. AgeRestricted, /// Video was deleted or censored @@ -208,7 +209,7 @@ impl ExtractionError { pub(crate) fn switch_client(&self) -> bool { matches!( self, - ExtractionError::VideoUnavailable { + ExtractionError::Unavailable { reason: UnavailabilityReason::AgeRestricted | UnavailabilityReason::UnsupportedClient, .. diff --git a/testfiles/channel/channel_agegate.json b/testfiles/channel/channel_agegate.json new file mode 100644 index 0000000..b3b8495 --- /dev/null +++ b/testfiles/channel/channel_agegate.json @@ -0,0 +1,1068 @@ +{ + "responseContext": { + "visitorData": "CgtrYzRnNm1YS2g5cyiU9J6qBjIICgJERRICEgA%3D", + "serviceTrackingParams": [ + { + "service": "GFEEDBACK", + "params": [ + { + "key": "browse_id", + "value": "UCbfnHqxXs_K3kvaH-WlNlig" + }, + { + "key": "browse_id_prefix", + "value": "" + }, + { + "key": "logged_in", + "value": "0" + }, + { + "key": "e", + "value": "23804281,23858057,23946420,23966208,23983296,23986019,23998056,24004644,24007246,24034168,24036947,24077241,24080738,24120819,24135310,24140247,24166867,24181174,24187377,24241378,24255543,24255545,24288664,24290971,24291857,24299875,24367580,24368942,24371398,24371778,24373396,24377598,24377909,24379352,24382552,24385612,24385727,24387949,24390675,24428788,24439361,24451319,24453989,24457856,24458317,24458324,24458329,24458839,24468724,24485421,24499534,24506515,24506784,24515423,24517093,24518452,24524098,24526515,24526642,24526772,24526783,24526794,24526801,24526804,24526815,24526823,24528461,24528470,24528473,24528484,24528548,24528559,24528575,24528580,24528642,24528649,24528657,24528668,24531222,24531225,24531254,24537200,24539025,24540881,24541326,24541656,24542367,24542452,24543193,24543197,24543201,24546059,24546074,24547316,24548138,24548627,24548629,24548854,24549786,24550285,24550458,24559328,24560416,24561140,24561156,24561206,24561383,24563746,24566293,24566687,24569887,24585907,24586420,24586688,24588590,24589493,24694842,24696752,24697069,24698453,24699899,39324156,39324184,39324567,51000798,51003636,51004018,51006181,51009757,51009781,51009900,51010235,51011488,51012165,51012291,51012659,51013170,51014091,51016856,51017346,51018888,51019442,51019626,51020302,51020570,51021953,51022241,51023274,51025415,51025833,51027535,51027869,51027870,51028271,51030072,51030101,51030311,51030435,51031230,51031412,51032492,51033399,51033577,51033905,51034526,51036440,51036511,51036735,51037344,51037349,51037540,51038213,51038399,51038805,51040336,51040339,51040350,51040388,51040473,51040538,51041331,51041497,51041809,51042415,51043057,51043505,51043950,51044000,51044153,51044159,51044608,51044722,51045016,51045928,51045969,51046900,51047537,51047726,51048254,51048488,51051089,51051661,51052608,51052750,51053018,51055016" + } + ] + }, + { + "service": "GOOGLE_HELP", + "params": [ + { + "key": "browse_id", + "value": "UCbfnHqxXs_K3kvaH-WlNlig" + }, + { + "key": "browse_id_prefix", + "value": "" + } + ] + }, + { + "service": "CSI", + "params": [ + { + "key": "c", + "value": "WEB" + }, + { + "key": "cver", + "value": "2.20231101.05.00" + }, + { + "key": "yt_li", + "value": "0" + }, + { + "key": "GetChannelPage_rid", + "value": "0x8556774d05e2f72e" + } + ] + }, + { + "service": "GUIDED_HELP", + "params": [ + { + "key": "logged_in", + "value": "0" + } + ] + }, + { + "service": "ECATCHER", + "params": [ + { + "key": "client.version", + "value": "2.20231101" + }, + { + "key": "client.name", + "value": "WEB" + }, + { + "key": "client.fexp", + "value": "24560416,24694842,24034168,24698453,51033577,51036511,24526823,51017346,51043057,51041331,51040339,51038805,24371778,24036947,51027869,24528484,51034526,51047537,24499534,24548627,39324567,24506515,24007246,24453989,24528470,24385612,24385727,51041497,24166867,24373396,24439361,24528657,24255543,24585907,24135310,51010235,51032492,24549786,51036440,24561206,24531225,51023274,51028271,24524098,51012291,51036735,51030435,24531254,24187377,24526515,51052750,24468724,51051089,24699899,51009781,24120819,51037540,51019626,24588590,51020302,51025833,51042415,24548138,51000798,24561156,24377909,51045969,51033399,39324184,24517093,24561140,24586420,24451319,24559328,24518452,24542367,24543193,51027870,24528473,24528461,51040388,51048254,24548629,24526642,24531222,23986019,24528548,24458317,51016856,39324156,51040336,24458324,51040473,24526815,24526772,51044608,24542452,24528642,24457856,24526801,24543201,23998056,24241378,24528649,24506784,24485421,51040538,24526794,24377598,24561383,24550285,24428788,24566293,51037349,24539025,24696752,24458839,24367580,24548854,24586688,24541326,51018888,23983296,51040350,24528668,51030101,24299875,24368942,23858057,24255545,51044722,51019442,51009900,24080738,51030072,51031230,51009757,24528575,51021953,51033905,24541656,51046900,51004018,51022241,51045016,51051661,24697069,51012165,24458329,24550458,51041809,24290971,23966208,51038399,24140247,51044153,51044159,24547316,51012659,51047726,24537200,51048488,24566687,24181174,24589493,24528580,51013170,51003636,51045928,24563746,51043505,24288664,51038213,51055016,23946420,24077241,24387949,24382552,24526804,51011488,23804281,51053018,51044000,24004644,24371398,51014091,51052608,51030311,24546059,24543197,51025415,24546074,51006181,51031412,24540881,51043950,24390675,24379352,51020570,24528559,24526783,24569887,24291857,51027535,24515423,51037344" + } + ] + } + ], + "mainAppWebResponseContext": { + "loggedOut": true, + "trackingParam": "kx_fmPxhoPZRXlUAXXL4PLT4_RyVqRucDArHzojND1DMYnwRgkuswmIBwOcCE59TDtslLKPQ-SS" + }, + "webResponseContextExtensionData": { + "hasDecorated": true + } + }, + "contents": { + "twoColumnBrowseResultsRenderer": { + "tabs": [ + { + "tabRenderer": { + "selected": true, + "content": { + "sectionListRenderer": { + "contents": [ + { + "channelAgeGateRenderer": { + "channelTitle": "Laphroaig Whisky", + "avatar": { + "thumbnails": [ + { + "url": "https://yt3.googleusercontent.com/ytc/APkrFKb88ujZ0617ttM3aLjdPYtIw43926XXUoLP0QmQ5Q=s144-c-k-c0x00ffffff-no-rj", + "width": 144, + "height": 144 + } + ] + }, + "header": { + "runs": [ + { + "text": "Sign in to view this channel" + } + ] + }, + "mainText": { + "runs": [ + { + "text": "This channel contains content that may be inappropriate for some users, " + }, + { + "text": "as determined by the YouTube account owner", + "navigationEndpoint": { + "clickTrackingParams": "CBIQ75gBGAAiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "commandMetadata": { + "webCommandMetadata": { + "url": "http://www.google.com/support/youtube/bin/answer.py?answer=186529", + "webPageType": "WEB_PAGE_TYPE_UNKNOWN", + "rootVe": 83769 + } + }, + "urlEndpoint": { + "url": "http://www.google.com/support/youtube/bin/answer.py?answer=186529" + } + } + }, + { + "text": ". To view this channel, please confirm you are old enough by signing in." + } + ] + }, + "signInButton": { + "buttonRenderer": { + "style": "STYLE_BRAND", + "size": "SIZE_DEFAULT", + "text": { + "runs": [ + { + "text": "Sign in" + } + ] + }, + "navigationEndpoint": { + "clickTrackingParams": "CBMQ8FsiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "commandMetadata": { + "webCommandMetadata": { + "url": "https://accounts.google.com/ServiceLogin?service=youtube&uilel=3&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26app%3Ddesktop%26hl%3Den%26next%3Dhttps%253A%252F%252Fwww.youtube.com%252Fyoutubei%252Fv1%252Fbrowse%253Fkey%253DAIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8%2526prettyPrint%253Dfalse&hl=en", + "webPageType": "WEB_PAGE_TYPE_UNKNOWN", + "rootVe": 83769 + } + }, + "signInEndpoint": { + "hack": true + } + }, + "trackingParams": "CBMQ8FsiEwjQuNu4m62CAxXxHgYAHVTVCIE=" + } + }, + "secondaryText": { + "runs": [ + { + "text": "If you would instead prefer to avoid potentially inappropriate content, consider activating YouTube's " + }, + { + "text": "Restricted Mode", + "navigationEndpoint": { + "clickTrackingParams": "CBIQ75gBGAAiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "commandMetadata": { + "webCommandMetadata": { + "url": "http://www.google.com/support/youtube/bin/answer.py?answer=174084", + "webPageType": "WEB_PAGE_TYPE_UNKNOWN", + "rootVe": 83769 + } + }, + "urlEndpoint": { + "url": "http://www.google.com/support/youtube/bin/answer.py?answer=174084" + } + } + }, + { + "text": "." + } + ] + }, + "trackingParams": "CBIQ75gBGAAiEwjQuNu4m62CAxXxHgYAHVTVCIEyEGNoYW5uZWxfYWdlX2dhdGU=" + } + } + ], + "trackingParams": "CBEQui8iEwjQuNu4m62CAxXxHgYAHVTVCIE=" + } + }, + "trackingParams": "CBAQ8JMBGAAiEwjQuNu4m62CAxXxHgYAHVTVCIE=" + } + } + ] + } + }, + "trackingParams": "CAAQhGciEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "topbar": { + "desktopTopbarRenderer": { + "logo": { + "topbarLogoRenderer": { + "iconImage": { + "iconType": "YOUTUBE_LOGO" + }, + "tooltipText": { + "runs": [ + { + "text": "YouTube Home" + } + ] + }, + "endpoint": { + "clickTrackingParams": "CA8QsV4iEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "commandMetadata": { + "webCommandMetadata": { + "url": "/", + "webPageType": "WEB_PAGE_TYPE_BROWSE", + "rootVe": 3854, + "apiUrl": "/youtubei/v1/browse" + } + }, + "browseEndpoint": { + "browseId": "FEwhat_to_watch" + } + }, + "trackingParams": "CA8QsV4iEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "overrideEntityKey": "EgZ0b3BiYXIg9QEoAQ%3D%3D" + } + }, + "searchbox": { + "fusionSearchboxRenderer": { + "icon": { + "iconType": "SEARCH" + }, + "placeholderText": { + "runs": [ + { + "text": "Search" + } + ] + }, + "config": { + "webSearchboxConfig": { + "requestLanguage": "en", + "requestDomain": "us", + "hasOnscreenKeyboard": false, + "focusSearchbox": true + } + }, + "trackingParams": "CA0Q7VAiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "searchEndpoint": { + "clickTrackingParams": "CA0Q7VAiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "commandMetadata": { + "webCommandMetadata": { + "url": "/results?search_query=", + "webPageType": "WEB_PAGE_TYPE_SEARCH", + "rootVe": 4724 + } + }, + "searchEndpoint": { + "query": "" + } + }, + "clearButton": { + "buttonRenderer": { + "style": "STYLE_DEFAULT", + "size": "SIZE_DEFAULT", + "isDisabled": false, + "icon": { + "iconType": "CLOSE" + }, + "trackingParams": "CA4Q8FsiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "accessibilityData": { + "accessibilityData": { + "label": "Clear search query" + } + } + } + } + } + }, + "trackingParams": "CAEQq6wBIhMI0LjbuJutggMV8R4GAB1U1QiB", + "topbarButtons": [ + { + "topbarMenuButtonRenderer": { + "icon": { + "iconType": "MORE_VERT" + }, + "menuRequest": { + "clickTrackingParams": "CAsQ_qsBGAAiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "commandMetadata": { + "webCommandMetadata": { + "sendPost": true, + "apiUrl": "/youtubei/v1/account/account_menu" + } + }, + "signalServiceEndpoint": { + "signal": "GET_ACCOUNT_MENU", + "actions": [ + { + "clickTrackingParams": "CAsQ_qsBGAAiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "openPopupAction": { + "popup": { + "multiPageMenuRenderer": { + "trackingParams": "CAwQ_6sBIhMI0LjbuJutggMV8R4GAB1U1QiB", + "style": "MULTI_PAGE_MENU_STYLE_TYPE_SYSTEM", + "showLoadingSpinner": true + } + }, + "popupType": "DROPDOWN", + "beReused": true + } + } + ] + } + }, + "trackingParams": "CAsQ_qsBGAAiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "accessibility": { + "accessibilityData": { + "label": "Settings" + } + }, + "tooltip": "Settings", + "style": "STYLE_DEFAULT" + } + }, + { + "buttonRenderer": { + "style": "STYLE_SUGGESTIVE", + "size": "SIZE_SMALL", + "text": { + "runs": [ + { + "text": "Sign in" + } + ] + }, + "icon": { + "iconType": "AVATAR_LOGGED_OUT" + }, + "navigationEndpoint": { + "clickTrackingParams": "CAoQ1IAEGAEiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "commandMetadata": { + "webCommandMetadata": { + "url": "https://accounts.google.com/ServiceLogin?service=youtube&uilel=3&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26app%3Ddesktop%26hl%3Den%26next%3Dhttps%253A%252F%252Fwww.youtube.com%252Fyoutubei%252Fv1%252Fbrowse%253Fkey%253DAIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8%2526prettyPrint%253Dfalse&hl=en&ec=65620", + "webPageType": "WEB_PAGE_TYPE_UNKNOWN", + "rootVe": 83769 + } + }, + "signInEndpoint": { + "idamTag": "65620" + } + }, + "trackingParams": "CAoQ1IAEGAEiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "targetId": "topbar-signin" + } + } + ], + "hotkeyDialog": { + "hotkeyDialogRenderer": { + "title": { + "runs": [ + { + "text": "Keyboard shortcuts" + } + ] + }, + "sections": [ + { + "hotkeyDialogSectionRenderer": { + "title": { + "runs": [ + { + "text": "Playback" + } + ] + }, + "options": [ + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Toggle play/pause" + } + ] + }, + "hotkey": "k" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Rewind 10 seconds" + } + ] + }, + "hotkey": "j" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Fast forward 10 seconds" + } + ] + }, + "hotkey": "l" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Previous video" + } + ] + }, + "hotkey": "P (SHIFT+p)" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Next video" + } + ] + }, + "hotkey": "N (SHIFT+n)" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Previous frame (while paused)" + } + ] + }, + "hotkey": ",", + "hotkeyAccessibilityLabel": { + "accessibilityData": { + "label": "Comma" + } + } + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Next frame (while paused)" + } + ] + }, + "hotkey": ".", + "hotkeyAccessibilityLabel": { + "accessibilityData": { + "label": "Period" + } + } + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Decrease playback rate" + } + ] + }, + "hotkey": "< (SHIFT+,)", + "hotkeyAccessibilityLabel": { + "accessibilityData": { + "label": "Less than or SHIFT + comma" + } + } + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Increase playback rate" + } + ] + }, + "hotkey": "> (SHIFT+.)", + "hotkeyAccessibilityLabel": { + "accessibilityData": { + "label": "Greater than or SHIFT + period" + } + } + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Seek to specific point in the video (7 advances to 70% of duration)" + } + ] + }, + "hotkey": "0..9" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Seek to previous chapter" + } + ] + }, + "hotkey": "CONTROL + ←" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Seek to next chapter" + } + ] + }, + "hotkey": "CONTROL + →" + } + } + ] + } + }, + { + "hotkeyDialogSectionRenderer": { + "title": { + "runs": [ + { + "text": "General" + } + ] + }, + "options": [ + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Toggle full screen" + } + ] + }, + "hotkey": "f" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Toggle theater mode" + } + ] + }, + "hotkey": "t" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Toggle miniplayer" + } + ] + }, + "hotkey": "i" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Close miniplayer or current dialog" + } + ] + }, + "hotkey": "ESCAPE" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Toggle mute" + } + ] + }, + "hotkey": "m" + } + } + ] + } + }, + { + "hotkeyDialogSectionRenderer": { + "title": { + "runs": [ + { + "text": "Subtitles and closed captions" + } + ] + }, + "options": [ + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "If the video supports captions, toggle captions ON/OFF" + } + ] + }, + "hotkey": "c" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Rotate through different text opacity levels" + } + ] + }, + "hotkey": "o" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Rotate through different window opacity levels" + } + ] + }, + "hotkey": "w" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Rotate through font sizes (increasing)" + } + ] + }, + "hotkey": "+" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Rotate through font sizes (decreasing)" + } + ] + }, + "hotkey": "-", + "hotkeyAccessibilityLabel": { + "accessibilityData": { + "label": "Minus" + } + } + } + } + ] + } + }, + { + "hotkeyDialogSectionRenderer": { + "title": { + "runs": [ + { + "text": "Spherical Videos" + } + ] + }, + "options": [ + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Pan up" + } + ] + }, + "hotkey": "w" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Pan left" + } + ] + }, + "hotkey": "a" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Pan down" + } + ] + }, + "hotkey": "s" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Pan right" + } + ] + }, + "hotkey": "d" + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Zoom in" + } + ] + }, + "hotkey": "+ on numpad or ]", + "hotkeyAccessibilityLabel": { + "accessibilityData": { + "label": "Plus on number pad or right bracket" + } + } + } + }, + { + "hotkeyDialogSectionOptionRenderer": { + "label": { + "runs": [ + { + "text": "Zoom out" + } + ] + }, + "hotkey": "- on numpad or [", + "hotkeyAccessibilityLabel": { + "accessibilityData": { + "label": "Minus on number pad or left bracket" + } + } + } + } + ] + } + } + ], + "dismissButton": { + "buttonRenderer": { + "style": "STYLE_BLUE_TEXT", + "size": "SIZE_DEFAULT", + "isDisabled": false, + "text": { + "runs": [ + { + "text": "Dismiss" + } + ] + }, + "trackingParams": "CAkQ8FsiEwjQuNu4m62CAxXxHgYAHVTVCIE=" + } + }, + "trackingParams": "CAgQteYDIhMI0LjbuJutggMV8R4GAB1U1QiB" + } + }, + "backButton": { + "buttonRenderer": { + "trackingParams": "CAcQvIYDIhMI0LjbuJutggMV8R4GAB1U1QiB", + "command": { + "clickTrackingParams": "CAcQvIYDIhMI0LjbuJutggMV8R4GAB1U1QiB", + "commandMetadata": { + "webCommandMetadata": { + "sendPost": true + } + }, + "signalServiceEndpoint": { + "signal": "CLIENT_SIGNAL", + "actions": [ + { + "clickTrackingParams": "CAcQvIYDIhMI0LjbuJutggMV8R4GAB1U1QiB", + "signalAction": { + "signal": "HISTORY_BACK" + } + } + ] + } + } + } + }, + "forwardButton": { + "buttonRenderer": { + "trackingParams": "CAYQvYYDIhMI0LjbuJutggMV8R4GAB1U1QiB", + "command": { + "clickTrackingParams": "CAYQvYYDIhMI0LjbuJutggMV8R4GAB1U1QiB", + "commandMetadata": { + "webCommandMetadata": { + "sendPost": true + } + }, + "signalServiceEndpoint": { + "signal": "CLIENT_SIGNAL", + "actions": [ + { + "clickTrackingParams": "CAYQvYYDIhMI0LjbuJutggMV8R4GAB1U1QiB", + "signalAction": { + "signal": "HISTORY_FORWARD" + } + } + ] + } + } + } + }, + "a11ySkipNavigationButton": { + "buttonRenderer": { + "style": "STYLE_DEFAULT", + "size": "SIZE_DEFAULT", + "isDisabled": false, + "text": { + "runs": [ + { + "text": "Skip navigation" + } + ] + }, + "trackingParams": "CAUQ8FsiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "command": { + "clickTrackingParams": "CAUQ8FsiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "commandMetadata": { + "webCommandMetadata": { + "sendPost": true + } + }, + "signalServiceEndpoint": { + "signal": "CLIENT_SIGNAL", + "actions": [ + { + "clickTrackingParams": "CAUQ8FsiEwjQuNu4m62CAxXxHgYAHVTVCIE=", + "signalAction": { + "signal": "SKIP_NAVIGATION" + } + } + ] + } + } + } + }, + "voiceSearchButton": { + "buttonRenderer": { + "style": "STYLE_DEFAULT", + "size": "SIZE_DEFAULT", + "isDisabled": false, + "serviceEndpoint": { + "clickTrackingParams": "CAIQ7a8FIhMI0LjbuJutggMV8R4GAB1U1QiB", + "commandMetadata": { + "webCommandMetadata": { + "sendPost": true + } + }, + "signalServiceEndpoint": { + "signal": "CLIENT_SIGNAL", + "actions": [ + { + "clickTrackingParams": "CAIQ7a8FIhMI0LjbuJutggMV8R4GAB1U1QiB", + "openPopupAction": { + "popup": { + "voiceSearchDialogRenderer": { + "placeholderHeader": { + "runs": [ + { + "text": "Listening..." + } + ] + }, + "promptHeader": { + "runs": [ + { + "text": "Didn't hear that. Try again." + } + ] + }, + "exampleQuery1": { + "runs": [ + { + "text": "\"Play Dua Lipa\"" + } + ] + }, + "exampleQuery2": { + "runs": [ + { + "text": "\"Show me my subscriptions\"" + } + ] + }, + "promptMicrophoneLabel": { + "runs": [ + { + "text": "Tap microphone to try again" + } + ] + }, + "loadingHeader": { + "runs": [ + { + "text": "Working..." + } + ] + }, + "connectionErrorHeader": { + "runs": [ + { + "text": "No connection" + } + ] + }, + "connectionErrorMicrophoneLabel": { + "runs": [ + { + "text": "Check your connection and try again" + } + ] + }, + "permissionsHeader": { + "runs": [ + { + "text": "Waiting for permission" + } + ] + }, + "permissionsSubtext": { + "runs": [ + { + "text": "Allow microphone access to search with voice" + } + ] + }, + "disabledHeader": { + "runs": [ + { + "text": "Search with your voice" + } + ] + }, + "disabledSubtext": { + "runs": [ + { + "text": "To search by voice, go to your browser settings and allow access to microphone" + } + ] + }, + "microphoneButtonAriaLabel": { + "runs": [ + { + "text": "Cancel" + } + ] + }, + "exitButton": { + "buttonRenderer": { + "style": "STYLE_DEFAULT", + "size": "SIZE_DEFAULT", + "isDisabled": false, + "icon": { + "iconType": "CLOSE" + }, + "trackingParams": "CAQQ0LEFIhMI0LjbuJutggMV8R4GAB1U1QiB", + "accessibilityData": { + "accessibilityData": { + "label": "Cancel" + } + } + } + }, + "trackingParams": "CAMQ7q8FIhMI0LjbuJutggMV8R4GAB1U1QiB", + "microphoneOffPromptHeader": { + "runs": [ + { + "text": "Microphone off. Try again." + } + ] + } + } + }, + "popupType": "TOP_ALIGNED_DIALOG" + } + } + ] + } + }, + "icon": { + "iconType": "MICROPHONE_ON" + }, + "tooltip": "Search with your voice", + "trackingParams": "CAIQ7a8FIhMI0LjbuJutggMV8R4GAB1U1QiB", + "accessibilityData": { + "accessibilityData": { + "label": "Search with your voice" + } + } + } + } + } + } +} diff --git a/tests/youtube.rs b/tests/youtube.rs index 6a34b15..2af69b7 100644 --- a/tests/youtube.rs +++ b/tests/youtube.rs @@ -312,7 +312,7 @@ fn get_player_error(#[case] id: &str, #[case] expect: UnavailabilityReason, rp: let err = tokio_test::block_on(rp.query().player(id)).unwrap_err(); match err { - Error::Extraction(ExtractionError::VideoUnavailable { reason, .. }) => { + Error::Extraction(ExtractionError::Unavailable { reason, .. }) => { assert_eq!(reason, expect, "got {err}") } _ => panic!("got {err}"), @@ -1094,6 +1094,27 @@ fn channel_tab_not_found(#[case] tab: ChannelVideoTab, rp: RustyPipe) { assert!(channel.content.is_empty(), "got: {:?}", channel.content); } +#[rstest] +fn channel_age_restriction(rp: RustyPipe) { + let id = "UCbfnHqxXs_K3kvaH-WlNlig"; + + let res = tokio_test::block_on(rp.query().channel_videos(&id)); + if let Err(Error::Extraction(ExtractionError::Unavailable { reason, msg })) = res { + assert_eq!(reason, UnavailabilityReason::AgeRestricted); + assert!(msg.starts_with("Laphroaig Whisky: ")); + } else { + panic!("invalid res: {res:?}") + } + + let res = tokio_test::block_on(rp.query().channel_info(&id)); + if let Err(Error::Extraction(ExtractionError::Unavailable { reason, msg })) = res { + assert_eq!(reason, UnavailabilityReason::AgeRestricted); + assert!(msg.starts_with("Laphroaig Whisky: ")); + } else { + panic!("invalid res: {res:?}") + } +} + //#CHANNEL_RSS #[cfg(feature = "rss")]