diff --git a/src/client/channel.rs b/src/client/channel.rs index 61e2db8..f9f9300 100644 --- a/src/client/channel.rs +++ b/src/client/channel.rs @@ -160,7 +160,7 @@ impl MapResponse>> for response::Channel { lang: Language, _deobf: Option<&crate::deobfuscate::Deobfuscator>, ) -> Result>>, ExtractionError> { - let content = map_channel_content(self.contents, id); + let content = map_channel_content(self.contents, id, self.alerts)?; let mut warnings = content.warnings; let grid = match content.c { response::channel::ChannelContent::GridRenderer { items } => Some(items), @@ -191,7 +191,7 @@ impl MapResponse>> for response::Channel { lang: Language, _deobf: Option<&crate::deobfuscate::Deobfuscator>, ) -> Result>>, ExtractionError> { - let content = map_channel_content(self.contents, id); + let content = map_channel_content(self.contents, id, self.alerts)?; let mut warnings = content.warnings; let grid = match content.c { response::channel::ChannelContent::GridRenderer { items } => Some(items), @@ -222,7 +222,7 @@ impl MapResponse> for response::Channel { lang: Language, _deobf: Option<&crate::deobfuscate::Deobfuscator>, ) -> Result>, ExtractionError> { - let content = map_channel_content(self.contents, id); + let content = map_channel_content(self.contents, id, self.alerts)?; let mut warnings = content.warnings; let meta = match content.c { response::channel::ChannelContent::ChannelAboutFullMetadataRenderer(meta) => Some(meta), @@ -419,14 +419,18 @@ fn map_vanity_url(url: &str, id: &str) -> Option { } fn map_channel( - header: response::channel::Header, - metadata: response::channel::Metadata, - microformat: response::channel::Microformat, + header: Option, + metadata: Option, + microformat: Option, content: T, id: &str, lang: Language, ) -> Result, ExtractionError> { - let metadata = metadata.channel_metadata_renderer; + let header = header.ok_or(ExtractionError::NoData)?; + let metadata = metadata + .ok_or(ExtractionError::NoData)? + .channel_metadata_renderer; + let microformat = microformat.ok_or(ExtractionError::NoData)?; if metadata.external_id != id { return Err(ExtractionError::WrongResult(format!( @@ -494,39 +498,54 @@ fn map_channel( } fn map_channel_content( - contents: response::channel::Contents, + contents: Option, id: &str, -) -> MapResult { - let mut tabs = contents.two_column_browse_results_renderer.tabs; - let mut sectionlist = some_or_bail!( - tabs.try_swap_remove(0), - MapResult::error("no tab".to_owned()) - ) - .tab_renderer - .content - .section_list_renderer; + alerts: Option>, +) -> Result, ExtractionError> { + match contents { + Some(contents) => { + let mut tabs = contents.two_column_browse_results_renderer.tabs; + let mut sectionlist = some_or_bail!( + tabs.try_swap_remove(0), + Ok(MapResult::error("no tab".to_owned())) + ) + .tab_renderer + .content + .section_list_renderer; - if let Some(target_id) = sectionlist.target_id { - // YouTube falls back to the featured page if the channel does not have a "videos" tab. - // This is the case for YouTube Music channels. - if target_id.starts_with(&format!("browse-feed{}featured", id)) { - return MapResult::ok(response::channel::ChannelContent::None); + if let Some(target_id) = sectionlist.target_id { + // YouTube falls back to the featured page if the channel does not have a "videos" tab. + // This is the case for YouTube Music channels. + if target_id.starts_with(&format!("browse-feed{}featured", id)) { + return Ok(MapResult::ok(response::channel::ChannelContent::None)); + } + } + + let mut itemsection = some_or_bail!( + sectionlist.contents.try_swap_remove(0), + Ok(MapResult::error("no sectionlist".to_owned())) + ) + .item_section_renderer + .contents; + + let content = some_or_bail!( + itemsection.try_swap_remove(0), + Ok(MapResult::error("no channel content".to_owned())) + ); + + Ok(MapResult::ok(content)) } + None => match alerts { + Some(alerts) => Err(ExtractionError::ContentUnavailable( + alerts + .into_iter() + .map(|a| a.alert_renderer.text) + .collect::>() + .join(" "), + )), + None => Err(ExtractionError::InvalidData("no contents".into())), + }, } - - let mut itemsection = some_or_bail!( - sectionlist.contents.try_swap_remove(0), - MapResult::error("no sectionlist".to_owned()) - ) - .item_section_renderer - .contents; - - let content = some_or_bail!( - itemsection.try_swap_remove(0), - MapResult::error("no channel content".to_owned()) - ); - - MapResult::ok(content) } #[cfg(test)] diff --git a/src/client/mod.rs b/src/client/mod.rs index 5ec6824..9d842ae 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1009,7 +1009,13 @@ impl RustyPipeQuery { Ok(mapres.c) } Err(e) => { - create_report(Level::ERR, Some(e.to_string()), Vec::new()); + match e { + ExtractionError::VideoUnavailable(_, _) + | ExtractionError::VideoAgeRestricted + | ExtractionError::ContentUnavailable(_) + | ExtractionError::NoData => (), + _ => create_report(Level::ERR, Some(e.to_string()), Vec::new()), + } Err(e.into()) } }, diff --git a/src/client/response/channel.rs b/src/client/response/channel.rs index 0cb6671..9bfbe57 100644 --- a/src/client/response/channel.rs +++ b/src/client/response/channel.rs @@ -1,9 +1,9 @@ use serde::Deserialize; use serde_with::serde_as; -use serde_with::VecSkipError; +use serde_with::{DefaultOnError, VecSkipError}; -use super::ChannelBadge; use super::Thumbnails; +use super::{Alert, ChannelBadge}; use super::{ContentRenderer, ContentsRenderer, VideoListItem}; use crate::serializer::ignore_any; use crate::serializer::{text::Text, MapResult, VecLogError}; @@ -12,10 +12,13 @@ use crate::serializer::{text::Text, MapResult, VecLogError}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Channel { - pub header: Header, - pub contents: Contents, - pub metadata: Metadata, - pub microformat: Microformat, + #[serde_as(as = "DefaultOnError")] + pub header: Option
, + pub contents: Option, + pub metadata: Option, + pub microformat: Option, + #[serde_as(as = "Option")] + pub alerts: Option>, } #[serde_as] diff --git a/src/client/response/mod.rs b/src/client/response/mod.rs index e84fb96..0cb2efe 100644 --- a/src/client/response/mod.rs +++ b/src/client/response/mod.rs @@ -313,6 +313,20 @@ pub enum VideoBadgeStyle { BadgeStyleTypeLiveNow, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Alert { + pub alert_renderer: AlertRenderer, +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AlertRenderer { + #[serde_as(as = "Text")] + pub text: String, +} + // YouTube Music #[serde_as] diff --git a/src/error.rs b/src/error.rs index 572fd9e..e690178 100644 --- a/src/error.rs +++ b/src/error.rs @@ -75,6 +75,10 @@ pub enum ExtractionError { VideoUnavailable(&'static str, String), #[error("Video is age restricted")] VideoAgeRestricted, + #[error("Content is not available. Reason (from YT): {0}")] + ContentUnavailable(String), + #[error("Got no data from YouTube")] + NoData, #[error("deserialization error: {0}")] Deserialization(#[from] serde_json::Error), #[error("got invalid data from YT: {0}")] diff --git a/tests/youtube.rs b/tests/youtube.rs index 90344fa..373399a 100644 --- a/tests/youtube.rs +++ b/tests/youtube.rs @@ -2,6 +2,7 @@ use chrono::{Datelike, Timelike}; use rstest::rstest; use rustypipe::client::{ClientType, RustyPipe}; +use rustypipe::error::{Error, ExtractionError}; use rustypipe::model::richtext::ToPlaintext; use rustypipe::model::{ AudioCodec, AudioFormat, Channel, SearchItem, Verification, VideoCodec, VideoFormat, @@ -857,6 +858,24 @@ async fn channel_more( assert_channel(&channel_info, id, name); } +#[rstest] +#[case::gaming("UCOpNcN46UbXVtpKMrmU4Abg", false)] +#[case::not_found("UCOpNcN46UbXVtpKMrmU4Abx", true)] +#[tokio::test] +async fn channel_error(#[case] id: &str, #[case] not_found: bool) { + let rp = RustyPipe::builder().strict().build(); + let err = rp.query().channel_videos(&id).await.unwrap_err(); + + if not_found { + assert!(matches!( + err, + Error::Extraction(ExtractionError::ContentUnavailable(_)) + )); + } else { + assert!(matches!(err, Error::Extraction(ExtractionError::NoData))); + } +} + //#CHANNEL_RSS #[tokio::test]