fix: handle age restricted channels

refactor! rename ExtractionError::VideoUnavailable to ExtractionError::Unavailable
This commit is contained in:
ThetaDev 2023-11-05 22:43:04 +01:00
parent b145080631
commit 1a22dc835a
7 changed files with 1200 additions and 51 deletions

View file

@ -5,6 +5,7 @@ use time::OffsetDateTime;
use url::Url; use url::Url;
use crate::{ use crate::{
client::response::YouTubeListItem,
error::{Error, ExtractionError}, error::{Error, ExtractionError},
model::{ model::{
paginator::{ContinuationEndpoint, Paginator}, paginator::{ContinuationEndpoint, Paginator},
@ -290,7 +291,7 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
impl MapResponse<ChannelInfo> for response::ChannelAbout { impl MapResponse<ChannelInfo> for response::ChannelAbout {
fn map_response( fn map_response(
self, self,
_id: &str, id: &str,
_lang: Language, _lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>, _deobf: Option<&crate::deobfuscate::DeobfData>,
_visitor_data: Option<&str>, _visitor_data: Option<&str>,
@ -299,11 +300,21 @@ impl MapResponse<ChannelInfo> for response::ChannelAbout {
// and it allows parsing the country name. // and it allows parsing the country name.
let lang = Language::En; let lang = Language::En;
let ep = self let ep = match self {
.on_response_received_endpoints response::ChannelAbout::ReceivedEndpoints {
.into_iter() on_response_received_endpoints,
.next() } => on_response_received_endpoints
.ok_or(ExtractionError::InvalidData("no received endpoint".into()))?; .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 continuations = ep.append_continuation_items_action.continuation_items;
let about = continuations let about = continuations
.c .c
@ -483,13 +494,6 @@ fn map_channel_content(
match contents { match contents {
Some(contents) => { Some(contents) => {
let tabs = contents.two_column_browse_results_renderer.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, let cmp_url_suffix = |endpoint: &response::channel::ChannelTabEndpoint,
expect: &str| { expect: &str| {
endpoint endpoint
@ -504,24 +508,46 @@ fn map_channel_content(
let mut featured_tab = false; let mut featured_tab = false;
for tab in &tabs { for tab in &tabs {
if cmp_url_suffix(&tab.tab_renderer.endpoint, "/featured") if let Some(endpoint) = &tab.tab_renderer.endpoint {
&& (tab.tab_renderer.content.section_list_renderer.is_some() if cmp_url_suffix(endpoint, "/featured")
|| tab.tab_renderer.content.rich_grid_renderer.is_some()) && (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") { featured_tab = true;
has_shorts = true; } else if cmp_url_suffix(endpoint, "/shorts") {
} else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/streams") { has_shorts = true;
has_live = 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| { let channel_content = tabs
tab.tab_renderer .into_iter()
.content .filter(|t| t.tab_renderer.endpoint.is_some())
.rich_grid_renderer .find_map(|tab| {
.or(tab.tab_renderer.content.section_list_renderer) 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 // YouTube may show the "Featured" tab if the requested tab is empty/does not exist
let content = if featured_tab { let content = if featured_tab {
@ -530,9 +556,10 @@ fn map_channel_content(
match channel_content { match channel_content {
Some(list) => list.contents, Some(list) => list.contents,
None => { None => {
return Err(ExtractionError::InvalidData( return Err(ExtractionError::NotFound {
"could not extract content".into(), id: id.to_owned(),
)) msg: "no tabs".into(),
});
} }
} }
}; };
@ -632,6 +659,7 @@ mod tests {
use crate::{ use crate::{
client::{response, MapResponse}, client::{response, MapResponse},
error::{ExtractionError, UnavailabilityReason},
model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem}, model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem},
param::{ChannelOrder, ChannelVideoTab, Language}, param::{ChannelOrder, ChannelVideoTab, Language},
serializer::MapResult, serializer::MapResult,
@ -649,7 +677,7 @@ mod tests {
#[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")] #[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")]
#[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")] #[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")] #[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")]
#[case::richgrid2("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")] #[case::coachella("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")]
#[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")] #[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::livestreams("livestreams", "UC2DjFE7Xf11URZqWBigcVOQ")] #[case::livestreams("livestreams", "UC2DjFE7Xf11URZqWBigcVOQ")]
fn map_channel_videos(#[case] name: &str, #[case] id: &str) { 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<MapResult<Channel<Paginator<VideoItem>>>, 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] #[rstest]
fn map_channel_playlists() { fn map_channel_playlists() {
let json_path = path!(*TESTFILES / "channel" / "channel_playlists.json"); let json_path = path!(*TESTFILES / "channel" / "channel_playlists.json");

View file

@ -77,7 +77,7 @@ impl RustyPipeQuery {
match tv_res { match tv_res {
// Output desktop client error if the tv client is unsupported // Output desktop client error if the tv client is unsupported
Err(Error::Extraction(ExtractionError::VideoUnavailable { Err(Error::Extraction(ExtractionError::Unavailable {
reason: UnavailabilityReason::UnsupportedClient, reason: UnavailabilityReason::UnsupportedClient,
.. ..
})) => Err(Error::Extraction(e)), })) => Err(Error::Extraction(e)),
@ -183,7 +183,7 @@ impl MapResponse<VideoPlayer> for response::Player {
_ => None, _ => None,
}) })
.unwrap_or_default(); .unwrap_or_default();
return Err(ExtractionError::VideoUnavailable { reason, msg }); return Err(ExtractionError::Unavailable { reason, msg });
} }
response::player::PlayabilityStatus::LoginRequired { reason, messages } => { response::player::PlayabilityStatus::LoginRequired { reason, messages } => {
let mut msg = reason; let mut msg = reason;
@ -205,10 +205,10 @@ impl MapResponse<VideoPlayer> for response::Player {
_ => None, _ => None,
}) })
.unwrap_or_default(); .unwrap_or_default();
return Err(ExtractionError::VideoUnavailable { reason, msg }); return Err(ExtractionError::Unavailable { reason, msg });
} }
response::player::PlayabilityStatus::LiveStreamOffline { reason } => { response::player::PlayabilityStatus::LiveStreamOffline { reason } => {
return Err(ExtractionError::VideoUnavailable { return Err(ExtractionError::Unavailable {
reason: UnavailabilityReason::OfflineLivestream, reason: UnavailabilityReason::OfflineLivestream,
msg: reason, msg: reason,
}); });
@ -216,7 +216,7 @@ impl MapResponse<VideoPlayer> for response::Player {
response::player::PlayabilityStatus::Error { reason } => { 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 (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" // reason: "This video is unavailable"
return Err(ExtractionError::VideoUnavailable { return Err(ExtractionError::Unavailable {
reason: UnavailabilityReason::Deleted, reason: UnavailabilityReason::Deleted,
msg: reason, msg: reason,
}); });

View file

@ -36,7 +36,7 @@ pub(crate) struct TabRendererWrap {
pub(crate) struct TabRenderer { pub(crate) struct TabRenderer {
#[serde(default)] #[serde(default)]
pub content: TabContent, pub content: TabContent,
pub endpoint: ChannelTabEndpoint, pub endpoint: Option<ChannelTabEndpoint>,
} }
#[serde_as] #[serde_as]
@ -148,10 +148,16 @@ pub(crate) struct MicroformatDataRenderer {
#[serde_as] #[serde_as]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(untagged)]
pub(crate) struct ChannelAbout { pub(crate) enum ChannelAbout {
#[serde_as(as = "VecSkipError<_>")] #[serde(rename_all = "camelCase")]
pub on_response_received_endpoints: Vec<ContinuationActionWrap<AboutChannelRendererWrap>>, ReceivedEndpoints {
#[serde_as(as = "VecSkipError<_>")]
on_response_received_endpoints: Vec<ContinuationActionWrap<AboutChannelRendererWrap>>,
},
Content {
contents: Option<Contents>,
},
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -69,6 +69,14 @@ pub(crate) enum YouTubeListItem {
contents: MapResult<Vec<YouTubeListItem>>, contents: MapResult<Vec<YouTubeListItem>>,
}, },
/// 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 /// No video list item (e.g. ad) or unimplemented item
/// ///
/// Unimplemented: /// Unimplemented:
@ -704,7 +712,7 @@ impl YouTubeListMapper<YouTubeItem> {
self.warnings.append(&mut contents.warnings); self.warnings.append(&mut contents.warnings);
contents.c.into_iter().for_each(|it| self.map_item(it)); contents.c.into_iter().for_each(|it| self.map_item(it));
} }
YouTubeListItem::None => {} YouTubeListItem::None | YouTubeListItem::ChannelAgeGateRenderer { .. } => {}
} }
} }

View file

@ -24,14 +24,15 @@ pub enum Error {
/// Error extracting content from YouTube /// Error extracting content from YouTube
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum ExtractionError { pub enum ExtractionError {
/// Video cannot be extracted with RustyPipe /// Content cannot be extracted with RustyPipe
/// ///
/// Reasons include: /// Reasons include:
/// - Deletion/Censorship /// - Deletion/Censorship
/// - Private video that requires a Google account /// - Age restriction
/// - Private video
/// - DRM (Movies and TV shows) /// - DRM (Movies and TV shows)
#[error("video cant be played because it is {reason}. Reason (from YT): {msg}")] #[error("content unavailable because it is {reason}. Reason (from YT): {msg}")]
VideoUnavailable { Unavailable {
/// Reason why the video could not be extracted /// Reason why the video could not be extracted
reason: UnavailabilityReason, reason: UnavailabilityReason,
/// The error message as returned from YouTube /// The error message as returned from YouTube
@ -77,9 +78,9 @@ pub enum ExtractionError {
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] #[derive(Default, Debug, Copy, Clone, PartialEq, Eq)]
#[non_exhaustive] #[non_exhaustive]
pub enum UnavailabilityReason { 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. /// [`ClientType::TvHtml5Embed`](crate::client::ClientType::TvHtml5Embed) client.
AgeRestricted, AgeRestricted,
/// Video was deleted or censored /// Video was deleted or censored
@ -208,7 +209,7 @@ impl ExtractionError {
pub(crate) fn switch_client(&self) -> bool { pub(crate) fn switch_client(&self) -> bool {
matches!( matches!(
self, self,
ExtractionError::VideoUnavailable { ExtractionError::Unavailable {
reason: UnavailabilityReason::AgeRestricted reason: UnavailabilityReason::AgeRestricted
| UnavailabilityReason::UnsupportedClient, | UnavailabilityReason::UnsupportedClient,
.. ..

File diff suppressed because it is too large Load diff

View file

@ -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(); let err = tokio_test::block_on(rp.query().player(id)).unwrap_err();
match err { match err {
Error::Extraction(ExtractionError::VideoUnavailable { reason, .. }) => { Error::Extraction(ExtractionError::Unavailable { reason, .. }) => {
assert_eq!(reason, expect, "got {err}") assert_eq!(reason, expect, "got {err}")
} }
_ => panic!("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); 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 //#CHANNEL_RSS
#[cfg(feature = "rss")] #[cfg(feature = "rss")]