fix: handle age restricted channels
refactor! rename ExtractionError::VideoUnavailable to ExtractionError::Unavailable
This commit is contained in:
parent
b145080631
commit
1a22dc835a
7 changed files with 1200 additions and 51 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(untagged)]
|
||||
pub(crate) enum ChannelAbout {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ChannelAbout {
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
15
src/error.rs
15
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,
|
||||
..
|
||||
|
|
|
|||
1068
testfiles/channel/channel_agegate.json
Normal file
1068
testfiles/channel/channel_agegate.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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")]
|
||||
|
|
|
|||
Reference in a new issue