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 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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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 { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
15
src/error.rs
15
src/error.rs
|
|
@ -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,
|
||||||
..
|
..
|
||||||
|
|
|
||||||
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();
|
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")]
|
||||||
|
|
|
||||||
Reference in a new issue