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 crate::{
client::response::YouTubeListItem,
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
@ -290,7 +291,7 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
impl MapResponse<ChannelInfo> 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<ChannelInfo> for response::ChannelAbout {
// and it allows parsing the country name.
let lang = Language::En;
let ep = self
.on_response_received_endpoints
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()))?;
.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,19 +508,41 @@ fn map_channel_content(
let mut featured_tab = false;
for tab in &tabs {
if cmp_url_suffix(&tab.tab_renderer.endpoint, "/featured")
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(&tab.tab_renderer.endpoint, "/shorts") {
} else if cmp_url_suffix(endpoint, "/shorts") {
has_shorts = true;
} else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/streams") {
} 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
.into_iter()
.filter(|t| t.tab_renderer.endpoint.is_some())
.find_map(|tab| {
tab.tab_renderer
.content
.rich_grid_renderer
@ -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<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]
fn map_channel_playlists() {
let json_path = path!(*TESTFILES / "channel" / "channel_playlists.json");

View file

@ -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<VideoPlayer> 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<VideoPlayer> 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<VideoPlayer> 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,
});

View file

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

View file

@ -69,6 +69,14 @@ pub(crate) enum 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
///
/// Unimplemented:
@ -704,7 +712,7 @@ impl YouTubeListMapper<YouTubeItem> {
self.warnings.append(&mut contents.warnings);
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
#[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,
..

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();
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")]