Compare commits

..

5 commits

Author SHA1 Message Date
53a8ec680a fix: support podcast episodes in new videos
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-11-13 13:53:29 +01:00
a5ec111af4 chore: update dependencies 2023-11-13 13:06:07 +01:00
596b9c4d4a fix: remove serde_with json feature 2023-11-13 13:04:46 +01:00
1a22dc835a fix: handle age restricted channels
refactor! rename ExtractionError::VideoUnavailable to ExtractionError::Unavailable
2023-11-05 22:43:04 +01:00
b145080631 add test for a/b11 2023-11-05 16:55:42 +01:00
19 changed files with 70225 additions and 96 deletions

View file

@ -32,7 +32,7 @@ quick-js-dtp = { version = "0.4.1", default-features = false, features = [
] } ] }
once_cell = "1.12.0" once_cell = "1.12.0"
regex = "1.6.0" regex = "1.6.0"
fancy-regex = "0.11.0" fancy-regex = "0.12.0"
thiserror = "1.0.36" thiserror = "1.0.36"
url = "2.2.2" url = "2.2.2"
reqwest = { version = "0.11.11", default-features = false, features = [ reqwest = { version = "0.11.11", default-features = false, features = [
@ -44,8 +44,8 @@ tokio = { version = "1.20.0", features = ["macros", "time"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.82" serde_json = "1.0.82"
serde_with = { version = "3.0.0", default-features = false, features = [ serde_with = { version = "3.0.0", default-features = false, features = [
"alloc",
"macros", "macros",
"json",
] } ] }
serde_plain = "1.0.1" serde_plain = "1.0.1"
rand = "0.8.5" rand = "0.8.5"
@ -59,7 +59,7 @@ ress = "0.11.4"
phf = "0.11.1" phf = "0.11.1"
base64 = "0.21.0" base64 = "0.21.0"
urlencoding = "2.1.2" urlencoding = "2.1.2"
quick-xml = { version = "0.30.0", features = ["serialize"], optional = true } quick-xml = { version = "0.31.0", features = ["serialize"], optional = true }
tracing = { version = "0.1.37", features = ["log"] } tracing = { version = "0.1.37", features = ["log"] }
[dev-dependencies] [dev-dependencies]

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

@ -112,13 +112,14 @@ mod tests {
#[rstest] #[rstest]
#[case::default("default")] #[case::default("default")]
#[case::default("w_podcasts")]
fn map_music_new_videos(#[case] name: &str) { fn map_music_new_videos(#[case] name: &str) {
let json_path = path!(*TESTFILES / "music_new" / format!("videos_{name}.json")); let json_path = path!(*TESTFILES / "music_new" / format!("videos_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();
let new_albums: response::MusicNew = let new_videos: response::MusicNew =
serde_json::from_reader(BufReader::new(json_file)).unwrap(); serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Vec<TrackItem>> = new_albums let map_res: MapResult<Vec<TrackItem>> = new_videos
.map_response("", Language::En, None, None) .map_response("", Language::En, None, None)
.unwrap(); .unwrap();

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

@ -54,7 +54,7 @@ use serde::{
de::{IgnoredAny, Visitor}, de::{IgnoredAny, Visitor},
Deserialize, Deserialize,
}; };
use serde_with::{json::JsonString, serde_as, VecSkipError}; use serde_with::{serde_as, DisplayFromStr, VecSkipError};
use crate::error::ExtractionError; use crate::error::ExtractionError;
use crate::serializer::{text::Text, MapResult, VecSkipErrorWrap}; use crate::serializer::{text::Text, MapResult, VecSkipErrorWrap};
@ -202,7 +202,7 @@ pub(crate) struct ResponseContext {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Continuation { pub(crate) struct Continuation {
/// Number of search results /// Number of search results
#[serde_as(as = "Option<JsonString>")] #[serde_as(as = "Option<DisplayFromStr>")]
pub estimated_results: Option<u64>, pub estimated_results: Option<u64>,
#[serde( #[serde(
alias = "onResponseReceivedCommands", alias = "onResponseReceivedCommands",

View file

@ -11,7 +11,7 @@ use crate::{
text::{Text, TextComponent, TextComponents}, text::{Text, TextComponent, TextComponents},
MapResult, MapResult,
}, },
util::{self, dictionary}, util::{self, dictionary, timeago},
}; };
use super::{ use super::{
@ -780,10 +780,17 @@ impl MusicListMapper {
.map(|st| map_album_type(st.first_str(), self.lang)) .map(|st| map_album_type(st.first_str(), self.lang))
.unwrap_or_default(); .unwrap_or_default();
let (artists, by_va) = map_artists(subtitle_p2); let (mut artists, by_va) = map_artists(subtitle_p2);
let artist_id = map_artist_id_fallback(item.menu, artists.first()); let artist_id = map_artist_id_fallback(item.menu, artists.first());
// Album artist links may be invisible on the search page, so
// fall back to menu data
if let Some(a1) = artists.first_mut() {
if a1.id.is_none() {
a1.id = artist_id.clone();
}
}
let year = let year =
subtitle_p3.and_then(|st| util::parse_numeric(st.first_str()).ok()); subtitle_p3.and_then(|st| util::parse_numeric(st.first_str()).ok());
@ -855,23 +862,38 @@ impl MusicListMapper {
match item.navigation_endpoint.music_page() { match item.navigation_endpoint.music_page() {
Some(music_page) => match music_page.typ { Some(music_page) => match music_page.typ {
MusicPageType::Track { vtype } => { MusicPageType::Track { vtype } => {
let (artists, by_va) = map_artists(subtitle_p1); let (artists, by_va, view_count, duration) = if vtype == MusicVideoType::Episode
{
self.items.push(MusicItem::Track(TrackItem { let (artists, by_va) = map_artists(subtitle_p2);
id: music_page.id, let duration = subtitle_p1.and_then(|s| {
name: item.title, timeago::parse_video_duration_or_warn(
duration: None, self.lang,
cover: item.thumbnail_renderer.into(), s.first_str(),
artist_id: artists.first().and_then(|a| a.id.clone()), &mut self.warnings,
artists, )
album: None, });
view_count: subtitle_p2.and_then(|c| { (artists, by_va, None, duration)
} else {
let (artists, by_va) = map_artists(subtitle_p1);
let view_count = subtitle_p2.and_then(|c| {
util::parse_large_numstr_or_warn( util::parse_large_numstr_or_warn(
c.first_str(), c.first_str(),
self.lang, self.lang,
&mut self.warnings, &mut self.warnings,
) )
}), });
(artists, by_va, view_count, None)
};
self.items.push(MusicItem::Track(TrackItem {
id: music_page.id,
name: item.title,
duration,
cover: item.thumbnail_renderer.into(),
artist_id: artists.first().and_then(|a| a.id.clone()),
artists,
album: None,
view_count,
is_video: vtype.is_video(), is_video: vtype.is_video(),
track_nr: None, track_nr: None,
by_va, by_va,

View file

@ -2,7 +2,7 @@ use std::ops::Range;
use serde::Deserialize; use serde::Deserialize;
use serde_with::serde_as; use serde_with::serde_as;
use serde_with::{json::JsonString, DefaultOnError}; use serde_with::{DefaultOnError, DisplayFromStr};
use super::{ResponseContext, Thumbnails}; use super::{ResponseContext, Thumbnails};
use crate::serializer::{text::Text, MapResult}; use crate::serializer::{text::Text, MapResult};
@ -78,7 +78,7 @@ pub(crate) struct ErrorMessage {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct StreamingData { pub(crate) struct StreamingData {
#[serde_as(as = "JsonString")] #[serde_as(as = "DisplayFromStr")]
pub expires_in_seconds: u32, pub expires_in_seconds: u32,
#[serde(default)] #[serde(default)]
pub formats: MapResult<Vec<Format>>, pub formats: MapResult<Vec<Format>>,
@ -106,7 +106,7 @@ pub(crate) struct Format {
pub width: Option<u32>, pub width: Option<u32>,
pub height: Option<u32>, pub height: Option<u32>,
#[serde_as(as = "Option<JsonString>")] #[serde_as(as = "Option<DisplayFromStr>")]
pub approx_duration_ms: Option<u32>, pub approx_duration_ms: Option<u32>,
#[serde_as(as = "Option<crate::serializer::Range>")] #[serde_as(as = "Option<crate::serializer::Range>")]
@ -114,7 +114,7 @@ pub(crate) struct Format {
#[serde_as(as = "Option<crate::serializer::Range>")] #[serde_as(as = "Option<crate::serializer::Range>")]
pub init_range: Option<Range<u32>>, pub init_range: Option<Range<u32>>,
#[serde_as(as = "Option<JsonString>")] #[serde_as(as = "Option<DisplayFromStr>")]
pub content_length: Option<u64>, pub content_length: Option<u64>,
#[serde(default)] #[serde(default)]
@ -129,7 +129,7 @@ pub(crate) struct Format {
#[serde(default)] #[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")] #[serde_as(deserialize_as = "DefaultOnError")]
pub audio_quality: Option<AudioQuality>, pub audio_quality: Option<AudioQuality>,
#[serde_as(as = "Option<JsonString>")] #[serde_as(as = "Option<DisplayFromStr>")]
pub audio_sample_rate: Option<u32>, pub audio_sample_rate: Option<u32>,
pub audio_channels: Option<u8>, pub audio_channels: Option<u8>,
pub loudness_db: Option<f32>, pub loudness_db: Option<f32>,
@ -237,7 +237,7 @@ pub(crate) struct CaptionTrack {
pub(crate) struct VideoDetails { pub(crate) struct VideoDetails {
pub video_id: String, pub video_id: String,
pub title: String, pub title: String,
#[serde_as(as = "JsonString")] #[serde_as(as = "DisplayFromStr")]
pub length_seconds: u32, pub length_seconds: u32,
#[serde(default)] #[serde(default)]
pub keywords: Vec<String>, pub keywords: Vec<String>,
@ -245,7 +245,7 @@ pub(crate) struct VideoDetails {
pub short_description: Option<String>, pub short_description: Option<String>,
#[serde(default)] #[serde(default)]
pub thumbnail: Thumbnails, pub thumbnail: Thumbnails,
#[serde_as(as = "JsonString")] #[serde_as(as = "DisplayFromStr")]
pub view_count: u64, pub view_count: u64,
pub author: String, pub author: String,
pub is_live_content: bool, pub is_live_content: bool,

View file

@ -2,7 +2,7 @@ use serde::{
de::{IgnoredAny, Visitor}, de::{IgnoredAny, Visitor},
Deserialize, Deserialize,
}; };
use serde_with::{json::JsonString, serde_as}; use serde_with::{serde_as, DisplayFromStr};
use super::{video_item::YouTubeListRendererWrap, ResponseContext}; use super::{video_item::YouTubeListRendererWrap, ResponseContext};
@ -10,7 +10,7 @@ use super::{video_item::YouTubeListRendererWrap, ResponseContext};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct Search { pub(crate) struct Search {
#[serde_as(as = "Option<JsonString>")] #[serde_as(as = "Option<DisplayFromStr>")]
pub estimated_results: Option<u64>, pub estimated_results: Option<u64>,
pub contents: Contents, pub contents: Contents,
pub response_context: ResponseContext, pub response_context: ResponseContext,

View file

@ -2,7 +2,7 @@ use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
use serde_with::{ use serde_with::{
json::JsonString, rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError, rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError,
}; };
use time::OffsetDateTime; use time::OffsetDateTime;
@ -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:
@ -154,7 +162,7 @@ pub(crate) struct PlaylistVideoRenderer {
pub title: String, pub title: String,
#[serde(rename = "shortBylineText")] #[serde(rename = "shortBylineText")]
pub channel: TextComponent, pub channel: TextComponent,
#[serde_as(as = "Option<JsonString>")] #[serde_as(as = "Option<DisplayFromStr>")]
pub length_seconds: Option<u32>, pub length_seconds: Option<u32>,
/// Regular video: `["29K views", " • ", "13 years ago"]` /// Regular video: `["29K views", " • ", "13 years ago"]`
/// Livestream: `["66K", " watching"]` /// Livestream: `["66K", " watching"]`
@ -184,7 +192,7 @@ pub(crate) struct PlaylistRenderer {
/// The first item of this list contains the playlist thumbnail, /// The first item of this list contains the playlist thumbnail,
/// subsequent items contain very small thumbnails of the next playlist videos /// subsequent items contain very small thumbnails of the next playlist videos
pub thumbnails: Option<Vec<Thumbnails>>, pub thumbnails: Option<Vec<Thumbnails>>,
#[serde_as(as = "Option<JsonString>")] #[serde_as(as = "Option<DisplayFromStr>")]
pub video_count: Option<u64>, pub video_count: Option<u64>,
#[serde_as(as = "Option<Text>")] #[serde_as(as = "Option<Text>")]
pub video_count_short_text: Option<String>, pub video_count_short_text: Option<String>,
@ -240,7 +248,7 @@ pub(crate) struct YouTubeListRenderer {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct UpcomingEventData { pub(crate) struct UpcomingEventData {
/// Unixtime in seconds /// Unixtime in seconds
#[serde_as(as = "JsonString")] #[serde_as(as = "DisplayFromStr")]
pub start_time: i64, pub start_time: i64,
} }
@ -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

@ -0,0 +1,906 @@
---
source: src/client/video_details.rs
expression: map_res.c
---
VideoDetails(
id: "ZeerrnuLi5E",
name: "aespa 에스파 \'Black Mamba\' MV",
description: RichText([
Text(
text: "🎧Listen and download aespa\'s debut single \"Black Mamba\": ",
),
Web(
text: "https://smarturl.it/aespa_BlackMamba",
url: "https://smarturl.it/aespa_BlackMamba",
),
Text(
text: "\n🐍The Debut Stage ",
),
YouTube(
text: "aespa 에스파 \'Black Mamba\' The Debut Stage",
target: Video(
id: "Ky5RT5oGg0w",
start_time: 0,
),
),
Text(
text: "\n\n🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: ",
),
Web(
text: "https://www.ticketmaster.com/event/0A...",
url: "https://www.ticketmaster.com/event/0A005CCD9E871F6E",
),
Text(
text: "\n\nSubscribe to aespa Official YouTube Channel!\n",
),
Web(
text: "https://www.youtube.com/aespa?sub_con...",
url: "https://www.youtube.com/aespa?sub_confirmation=1",
),
Text(
text: "\n\naespa official\n",
),
Web(
text: "aespa",
url: "https://www.youtube.com/c/aespa",
),
Text(
text: "\n",
),
Web(
text: "aespa_official",
url: "https://www.instagram.com/aespa_official",
),
Text(
text: "\n",
),
Web(
text: "aespa_official",
url: "https://www.tiktok.com/@aespa_official",
),
Text(
text: "\n",
),
Web(
text: "aespa_official",
url: "https://twitter.com/aespa_Official",
),
Text(
text: "\n",
),
Web(
text: "aespa.official",
url: "https://www.facebook.com/aespa.official",
),
Text(
text: "\n",
),
Web(
text: "https://weibo.com/aespa",
url: "https://weibo.com/aespa",
),
Text(
text: "\n\n",
),
Text(
text: "#aespa",
),
Text(
text: " ",
),
Text(
text: "#æspa",
),
Text(
text: " ",
),
Text(
text: "#BlackMamba",
),
Text(
text: " ",
),
Text(
text: "#블랙맘바",
),
Text(
text: " ",
),
Text(
text: "#에스파",
),
Text(
text: "\naespa 에스파 \'Black Mamba\' MV ℗ SM Entertainment",
),
]),
channel: ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s48-c-k-c0x00ffffff-no-rj",
width: 48,
height: 48,
),
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s88-c-k-c0x00ffffff-no-rj",
width: 88,
height: 88,
),
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s176-c-k-c0x00ffffff-no-rj",
width: 176,
height: 176,
),
],
verification: Verified,
subscriber_count: Some(32100000),
),
view_count: 255522287,
like_count: Some(4209059),
publish_date: "[date]",
publish_date_txt: Some("Nov 17, 2020"),
is_live: false,
is_ccommons: false,
chapters: [],
recommended: Paginator(
count: None,
items: [
VideoItem(
id: "4TWR90KJl84",
name: "aespa 에스파 \'Next Level\' MV",
length: Some(236),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/4TWR90KJl84/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBYCGc-AKsDC6UpJgIZw2_VsqjVWA",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/4TWR90KJl84/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDh-eDxZBmrNsHcb6pYX0Gyx6gJ8Q",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: Some(277189882),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "yQUU29NwNF4",
name: "aespa(에스파) - Black Mamba @인기가요 inkigayo 20201122",
length: Some(213),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/yQUU29NwNF4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLA4pIWwOFmVuVU-jZ-j7S4GvgxjKw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/yQUU29NwNF4/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC4B3H-paMDpjdf_V6NsymGNvVicQ",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCS_hnpJLQTvBkqALgapi_4g",
name: "스브스케이팝 X INKIGAYO",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/Uxpz5J0EcsFJRbqh4Ip7i3TTNsxTh5jVUxfZmV1DTrCQM_ihfzBGMmkfSRGWoFK9M0anhIie=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: Some(10870401),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "mTmm0y73ZtM",
name: "Secret Missions: 7 Thrilling Spy and Secret Agent Stories",
length: Some(6811),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/mTmm0y73ZtM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDz3aKv3IbWrI5GmtWjWl2br6h7jw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mTmm0y73ZtM/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCNnJtMt14Dn6iNSSHZtZL5MsYDtQ",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCSVngVJ_0s0ZX4oiN_4ZkaQ",
name: "My Story Shared",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/APkrFKbfZM35CSjtdsk3QIcEvalm3yoAzCkgZptcgSfNHw=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("9 days ago"),
view_count: Some(888941),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "xBnSq8JKlZw",
name: "16 Eylül 2023",
length: Some(7971),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/xBnSq8JKlZw/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-BIAC6AKKAgwIABABGGUgUChFMA8=&rs=AOn4CLDBvW0PORHHExpND8qbAa0OCr5MMw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/xBnSq8JKlZw/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gSAAugCigIMCAAQARhlIFAoRTAP&rs=AOn4CLASNCymIttQ1GwgbWvtxD_KeG5yGw",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCcyqrKg5jlvVJy1nc-0igRQ",
name: "Buğlem TV",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_kbdhEVJB2pzgy-qLRGQQ5sCPPZnEVeljgVrk0KZxPe8UxT8mm5ZZp7Zn6TGMcBMcpCG-zPi1YI=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("1 month ago"),
view_count: Some(8407),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "fE2h3lGlOsk",
name: "ITZY \"WANNABE\" M/V @ITZY",
length: Some(219),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/fE2h3lGlOsk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC44Q0lpu5a8rltgTMxi0X2QA6jnQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/fE2h3lGlOsk/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC6F85UnQjP3_9U0gehdYbbF6NTxw",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCaO6TYtlC8U5ttz62hTrZgg",
name: "JYP Entertainment",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/kcV7NQkBm-UvvzVTJvrg1Yf1eHSqi-DLXuZPt_ECa3cHEPefujS951Dxj6KUEQ5i9Z7_fyMUjw=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("3 years ago"),
view_count: Some(523737389),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "NU611fxGyPU",
name: "aespa 에스파 \'Black Mamba\' Dance Practice",
length: Some(175),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/NU611fxGyPU/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAgKORzcy6WKosI1_PAVWDgcjJ9jA",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/NU611fxGyPU/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDqWWIfLCdtyqy5aIUA_PGcEW2r2g",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UC9GtSLeksfK4yuJ_g1lgQbg",
name: "aespa",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/rLxODcRttuDvITkAJruNIDlDkVMEsPVuHJyMQDjeYqoFh80JyGwfXXMOZgZXd6-iKuf9rifqYQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Artist,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: Some(40486850),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "dYRITmpFbJ4",
name: "aespa 에스파 \'Girls\' MV",
length: Some(269),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/dYRITmpFbJ4/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBOxN6ukbZNOPwUBhRZYgG9r23lng",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/dYRITmpFbJ4/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBLmRDhzBtNHCuokfKRQufiNKKfZg",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: Some(135870843),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "jiFBY6gk3Lk",
name: "BLACKPINK x AESPA Pink Venom / Black Mamba MASHUP (feat. Next Level)",
length: Some(240),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/jiFBY6gk3Lk/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLARhXJ8KOxiWpj430QpyKF2m3LJFQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/jiFBY6gk3Lk/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDGOkNsfHOpy9GLXoHn1rnIOn0CcA",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UC5XWNylwy4efFufjMYqcglw",
name: "Miggy Smallz",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/APkrFKYR2j9afW3H0lgdKwD8qPiIZvZBfCSLxAZQRiUB=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: Some(4218059),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "CM4CkVFmTds",
name: "TWICE \"I CAN\'T STOP ME\" M/V",
length: Some(221),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/CM4CkVFmTds/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBfd7QADIduQSR2ESLIp1k5gxxNDg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/CM4CkVFmTds/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDRn7hTXV_Ls30E6BQNZQtQjbuEpA",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCaO6TYtlC8U5ttz62hTrZgg",
name: "JYP Entertainment",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/kcV7NQkBm-UvvzVTJvrg1Yf1eHSqi-DLXuZPt_ECa3cHEPefujS951Dxj6KUEQ5i9Z7_fyMUjw=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("3 years ago"),
view_count: Some(509192107),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "WPdWvnAAurg",
name: "aespa 에스파 \'Savage\' MV",
length: Some(259),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/WPdWvnAAurg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDQGxlnDkAdMYRm2cdkDmiDbBDpYw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/WPdWvnAAurg/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAIHFE0eH_r-HP7DRPv1QJJnRDzWw",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCEf_Bc-KVd7onSeifS3py9g",
name: "SMTOWN",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/_1Z4I2qpWaCN9g3BcDd3cVA9MDHOG43lE1YNWDNkKro49haGxkjwuFK-I8faWTKM6Jle9qb4ag=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("2 years ago"),
view_count: Some(245301963),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "OgabtEgG_kg",
name: "[ FULL ALBUM ] IVE (아이브) — IVE The 1st EP \' I\'VE MINE TRACKLIST",
length: Some(1034),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/OgabtEgG_kg/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AH-BIAC6AKKAgwIABABGGkgaShpMA8=&rs=AOn4CLBF1lxbztXMyXmem4owNAWZRqvnBA",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/OgabtEgG_kg/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB_gSAAugCigIMCAAQARhpIGkoaTAP&rs=AOn4CLAqM-WuOQCGTABX_NmzvOUOhx3rLA",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCo7jZg0Q5jBWs0WSq_SdDoA",
name: "x i a o s h i z i",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ErguVbkzrIKe0hezWviLvuCCGLaQYlrSWQ7Dw6beAwNJnR1ed7NV3pokG_z_I7GQvxfUybB4tg=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("7 days ago"),
view_count: Some(1719),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "cSqOY5nktfg",
name: "BLACKPINK THE GAME - THE GIRLS MV",
length: Some(164),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/cSqOY5nktfg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDkx-bmEWvYbs8ju1cETIRE1AczFQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/cSqOY5nktfg/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAYPPC91mec_MPvRxjRBzTpwFDBUQ",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCOmHUn--16B90oW2L6FRR3A",
name: "BLACKPINK",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hZDUwjoeQqigphL4A1tkg9c6hVp5yXmbboBR7PYFUSFj5PIJSA483NB5v7b0XVoTN9GCku3tqQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Artist,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("2 months ago"),
view_count: Some(61284005),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "i8fRCkq5tbw",
name: "aespa 에스파 ep.2 Next Level SM Culture Universe",
length: Some(1040),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/i8fRCkq5tbw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBGcGKSQOqvI_5ZONNturhZZmkysQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/i8fRCkq5tbw/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBQsyuNIuuztFQo2FdweYiROIJY3A",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UC9GtSLeksfK4yuJ_g1lgQbg",
name: "aespa",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/rLxODcRttuDvITkAJruNIDlDkVMEsPVuHJyMQDjeYqoFh80JyGwfXXMOZgZXd6-iKuf9rifqYQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Artist,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: Some(5419659),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "erCzl8x9Zuo",
name: "에스파(AESPA) 2023 lotte family concert Full Ver. (Black Mamba +thirsty + Illusion+next level+ Spicy)",
length: Some(1192),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/erCzl8x9Zuo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBF18qnuz8guk309k2UUh4xnLuazg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/erCzl8x9Zuo/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB36E2CQnz-l8QtgUEYQfGADaZJdA",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UC8yPhlmo-4MY5ZfF-cw3JRg",
name: "Rock Music",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/APkrFKYogJDjV-mjqi-JeTWKU7PwH_gKL2x9Thq04YuANQ=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("4 months ago"),
view_count: Some(392124),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "7HDeem-JaSY",
name: "(여자)아이들((G)I-DLE) - \'퀸카 (Queencard)\' Official Music Video",
length: Some(211),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/7HDeem-JaSY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC_jASE2yooEXAN64rj8-1_AJZl6A",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/7HDeem-JaSY/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDq-8lmFENHZeVvK5bQBvpnqEDFIQ",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCritGVo7pLJLUS8wEu32vow",
name: "(G)I-DLE (여자)아이들 (Official YouTube Channel)",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/APkrFKbGrI182ZniS64zKXUGr2CeJ9tMxoa9w90e6SaZkA=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Artist,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("5 months ago"),
view_count: Some(259400824),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "9JFi7MmjtGA",
name: "VIVIZ (비비지) - \'MANIAC\' MV",
length: Some(197),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/9JFi7MmjtGA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLALaqiHHm-fnm1TQHpD9PG-zGd-hg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/9JFi7MmjtGA/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLD1uM6lf5nCYUj8ZbzAtNYfwb4Z4Q",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UC6YMr57knEIYXOOKMmYAFXQ",
name: "BPM Entertainment",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/jltaW4jLrF76MrBe-HKcIQoop67mOF3QLdGzFiYwB-Pt7qKv4X7VECkCzfvkn037abuy6zV0bg=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: None,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("1 day ago"),
view_count: Some(2807502),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "NoYKBAajoyo",
name: "EVERGLOW (에버글로우) - DUN DUN MV",
length: Some(209),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/NoYKBAajoyo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC3OhCUbjpIclmjfV8W8T98nVI5pA",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/NoYKBAajoyo/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA-CdJunWg1z_pnrT55qagTHnxkdQ",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UC_pwIXKXNm5KGhdEVzmY60A",
name: "Stone Music Entertainment",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/XIxEtKkvUSLHiDBazM8kYyKpyESz5LM--vG0F7aMsiqOC2o_IZaKztsqDMj2mcn4ciQzFbvu=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("3 years ago"),
view_count: Some(286088204),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "pyf8cbqyfPs",
name: "LE SSERAFIM (르세라핌) \'ANTIFRAGILE\' OFFICIAL M/V",
length: Some(232),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/pyf8cbqyfPs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAu-V-1EWwbHjZTNTO-vuP_O_WB3Q",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/pyf8cbqyfPs/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCxVrNDDgEYMZWgOukne2kgOV2Vhg",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UC3IZKseVpdzPSBaWxBxundA",
name: "HYBE LABELS",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/APkrFKaWqx5IfcKbi5z8FgPsM_kA6NQ2zTAx8gr27yQcdQ=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("1 year ago"),
view_count: Some(196956308),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "32si5cfrCNc",
name: "BLACKPINK - \'How You Like That\' DANCE PERFORMANCE VIDEO",
length: Some(181),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/32si5cfrCNc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBjimPvMxDwTmPBlKX8Buo9EjMeOg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/32si5cfrCNc/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDCsJMBcdZaForwAnhjYy3L1JT1hQ",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCOmHUn--16B90oW2L6FRR3A",
name: "BLACKPINK",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hZDUwjoeQqigphL4A1tkg9c6hVp5yXmbboBR7PYFUSFj5PIJSA483NB5v7b0XVoTN9GCku3tqQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Artist,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("3 years ago"),
view_count: Some(1522365959),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "bwmSjveL3Lc",
name: "BLACKPINK - \'붐바야 (BOOMBAYAH)\' M/V",
length: Some(244),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/bwmSjveL3Lc/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDRzdujtL9QM0RZ8elD00oS2fXMhg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/bwmSjveL3Lc/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBOprayVWEKYsgHjpoCw6GFcV3Hng",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCOmHUn--16B90oW2L6FRR3A",
name: "BLACKPINK",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/hZDUwjoeQqigphL4A1tkg9c6hVp5yXmbboBR7PYFUSFj5PIJSA483NB5v7b0XVoTN9GCku3tqQ=s88-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: Artist,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: Some("7 years ago"),
view_count: Some(1646522795),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
],
ctoken: Some("CBQSExILWmVlcnJudUxpNUXAAQHIAQEYACqjDjJzNkw2d3pUQ2dyUUNnb0Q4ajRBQ2c3Q1Bnc0l6cS1tbFBTLTVKcmhBUW9EOGo0QUNnM0NQZ29JM3I3Z2pfdVl2b2QwQ2c3Q1Bnc0lfZUNBXzQyNl9OV1pBUW9Pd2o0TENQWHE1Y3Z0Z3VlZDVRRUtEc0ktQ3dqazRNencwS19abUpJQkNnM0NQZ29JNHItUm91ZVkxTXBRQ2czQ1Bnb0k4OVRQNGNXMzBJd1JDZzNDUGdvSTRxYmZ6TS11b2ZFRENnN0NQZ3NJX2RIeWs5el9tS0t4QVFvT3dqNExDT24yeE1ycDdKS3JpQUVLRGNJLUNnaVpvcUNKbGFDZ2lVNEtBX0ktQUFvT3dqNExDTjdvd0p1OW04V0N5UUVLQV9JLUFBb093ajRMQ05QTjNmZXkydW1jbVFFS0FfSS1BQW9Pd2o0TENKeXJxcEs4MWZTTXhBRUtBX0ktQUFvTndqNEtDTW4xbEkzbHUtaW1mQW9EOGo0QUNnM0NQZ29JOVpHYjR0LTZyYWMxQ2dQeVBnQUtEY0ktQ2dpZTJaWFM1b21Td25VS0FfSS1BQW9Pd2o0TENMbTVrOEc2ck5DUWpnRUtBX0ktQUFvTndqNEtDTnVibVl1VjBvRG5DQW9EOGo0QUNnM0NQZ29JdVBXQ2dPZlgxZnRZQ2dQeVBnQUtEY0ktQ2dqSV9KdkF4UGFtZ3pvS0FfSS1BQW9Od2o0S0NQanJrcy01ektPVmNRb0Q4ajRBQ2c3Q1Bnc0l2T3ZtMWFTaDlPT0xBUW9EOGo0QUNnM0NQZ29JNnMzMTRfenlyTmg2Q2dQeVBnQUtEc0ktQ3dpbTBxWDhwcy0zdU93QkNnUHlQZ0FLRHNJLUN3amc2STdOek4zWXlQUUJDZ1B5UGdBS0RjSS1DZ2lxeG82MXdNQ0N3ellLQV9JLUFBb093ajRMQ1B2NXlkV2Jqdi1UcHdFS0FfSS1BQW9Pd2o0TENOZVJyTF9jM01pMTN3RUtBX0ktQUFvTndqNEtDTGU1cjd6djBlU0Vid29EOGo0QUNnM0NQZ29JLXNXX3I1ZWI4c01XQ2czQ1Bnb0lpLUdvNjlYMnlfc3JDZzNDUGdvSWlObnc4SmowcHZGbENnM0NQZ29Jczl1ODM4Xzdrc0ZGQ2c3Q1Bnc0k5cnZkNFpqNjlNX2VBUW9Pd2o0TENPQ1VrNG5zbXVMTV9nRUtEc0ktQ3dpMHFySEkzc0R0cGJjQkNnN0NQZ3NJazlxdjZNV2FwY2JhQVFvT3dqNExDTVN6djQyOHhxcUstUUVLRHNJLUN3allfNm5FNXAzVjdMc0JDZzdDUGdzSXNQNjV1ZjdpcEtyV0FRb093ajRMQ05hdWlJWDJwX2VyOFFFS0RzSS1Dd2pvcTlEUG11Nmx6ZEFCQ2c3Q1Bnc0lwNnZFaWNuQ3A1ZUxBUW9Pd2o0TENJQ244cG1vejhuLWlnRUtEY0ktQ2dpSG91emdoS18xdlQwS0RzSS1Dd2pXNE9qcV8tUGFpcHdCQ2c3Q1Bnc0k4ZV9Hbi16bTN2U2pBUW9Od2o0S0NLMjh0WTZJaUtETkd3b093ajRMQ0tfdHlOZXF3T3VCMUFFS0RzSS1Dd2pja3BPWnR0S1AtNDhCQ2c3Q1Bnc0kxSldKNjgyU21lN3dBUW9Od2o0S0NMWE52ZUNUN1pfLVpnb093ajRMQ0pMSHM0eTFrX3FJcXdFU0ZnQUNEUThSRXhVWEdSc2RIeUVqSlNjcEt5MHZNVE1hQkFnQUVBRWFCQWdDRUFNYUJBZ0NFQVFhQkFnQ0VBVWFCQWdDRUFZYUJBZ0NFQWNhQkFnQ0VBZ2FCQWdDRUFrYUJBZ0NFQW9hQkFnQ0VBc2FCQWdDRUF3YUJBZ05FQTRhQkFnUEVCQWFCQWdSRUJJYUJBZ1RFQlFhQkFnVkVCWWFCQWdYRUJnYUJBZ1pFQm9hQkFnYkVCd2FCQWdkRUI0YUJBZ2ZFQ0FhQkFnaEVDSWFCQWdqRUNRYUJBZ2xFQ1lhQkFnbkVDZ2FCQWdwRUNvYUJBZ3JFQ3dhQkFndEVDNGFCQWd2RURBYUJBZ3hFRElhQkFnekVBY2FCQWd6RURRYUJBZ3pFRFVhQkFnekVEWWFCQWd6RURjYUJBZ3pFRGdhQkFnekVEa2FCQWd6RURvYUJBZ3pFRHNhQkFnekVEd2FCQWd6RUQwYUJBZ3pFRDRhQkFnekVBd2FCQWd6RUFVYUJBZ3pFRDhhQkFnekVFQWFCQWd6RUVFYUJBZ3pFRUlhQkFnekVFTWFCQWd6RUVRYUJBZ3pFRVVhQkFnekVBTWFCQWd6RUVZYUJBZ3pFRWNhQkFnekVFZ2FCQWd6RUVrYUJBZ3pFQVlhQkFnekVBUWFCQWd6RUVvYUJBZ3pFRXNxRmdBQ0RROFJFeFVYR1JzZEh5RWpKU2NwS3kwdk1UTWoPd2F0Y2gtbmV4dC1mZWVk"),
endpoint: next,
),
top_comments: Paginator(
count: Some(703000),
items: [],
ctoken: Some("Eg0SC1plZXJybnVMaTVFGAYyJSIRIgtaZWVycm51TGk1RTAAeAJCEGNvbW1lbnRzLXNlY3Rpb24%3D"),
endpoint: next,
),
latest_comments: Paginator(
count: Some(703000),
items: [],
ctoken: Some("Eg0SC1plZXJybnVMaTVFGAYyOCIRIgtaZWVycm51TGk1RTABeAIwAUIhZW5nYWdlbWVudC1wYW5lbC1jb21tZW50cy1zZWN0aW9u"),
endpoint: next,
),
visitor_data: None,
)

View file

@ -110,7 +110,7 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
let video_id = current_video_endpoint.watch_endpoint.video_id; let video_id = current_video_endpoint.watch_endpoint.video_id;
if id != video_id { if id != video_id {
return Err(ExtractionError::WrongResult(format!( return Err(ExtractionError::WrongResult(format!(
"got wrong playlist id {video_id}, expected {id}" "got wrong video id {video_id}, expected {id}"
))); )));
} }
@ -572,9 +572,10 @@ mod tests {
#[case::chapters("chapters", "nFDBxBUfE74")] #[case::chapters("chapters", "nFDBxBUfE74")]
#[case::live("live", "86YLFOog4GM")] #[case::live("live", "86YLFOog4GM")]
#[case::agegate("agegate", "HRKu0cvrr_o")] #[case::agegate("agegate", "HRKu0cvrr_o")]
#[case::newdesc("20220924_newdesc", "ZeerrnuLi5E")] #[case::ab_newdesc("20220924_newdesc", "ZeerrnuLi5E")]
#[case::new_cont("20221011_new_continuation", "ZeerrnuLi5E")] #[case::ab_new_cont("20221011_new_continuation", "ZeerrnuLi5E")]
#[case::no_recommends("20221011_rec_isr", "nFDBxBUfE74")] #[case::ab_no_recommends("20221011_rec_isr", "nFDBxBUfE74")]
#[case::ab_new_likes("20231103_likes", "ZeerrnuLi5E")]
fn map_video_details(#[case] name: &str, #[case] id: &str) { fn map_video_details(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "video_details" / format!("video_details_{name}.json")); let json_path = path!(*TESTFILES / "video_details" / format!("video_details_{name}.json"));
let json_file = File::open(json_path).unwrap(); let json_file = File::open(json_path).unwrap();

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,
.. ..

View file

@ -1,12 +1,12 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_with::{json::JsonString, serde_as, DeserializeAs, SerializeAs}; use serde_with::{serde_as, DeserializeAs, DisplayFromStr, SerializeAs};
#[serde_as] #[serde_as]
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct Range { pub struct Range {
#[serde_as(as = "JsonString")] #[serde_as(as = "DisplayFromStr")]
start: u32, start: u32,
#[serde_as(as = "JsonString")] #[serde_as(as = "DisplayFromStr")]
end: u32, end: u32,
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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")]
@ -1368,9 +1389,10 @@ fn music_playlist_cont(#[case] id: &str, rp: RustyPipe) {
assert_gte(track_count, 100, "tracks"); assert_gte(track_count, 100, "tracks");
assert_eq!(track_count, playlist.tracks.count.unwrap()); assert_eq!(track_count, playlist.tracks.count.unwrap());
assert_eq!( assert_gte(
usize::try_from(track_count).unwrap(), usize::try_from(track_count).unwrap(),
playlist.tracks.items.len() playlist.tracks.items.len(),
"tracks",
); );
} }
@ -2242,7 +2264,12 @@ fn music_new_videos(rp: RustyPipe) {
validate::video_id(&video.id).unwrap(); validate::video_id(&video.id).unwrap();
assert!(!video.name.is_empty()); assert!(!video.name.is_empty());
assert!(!video.cover.is_empty(), "got no cover"); assert!(!video.cover.is_empty(), "got no cover");
assert_gte(video.view_count.unwrap(), 1000, "views"); if let Some(view_count) = video.view_count {
assert_gte(view_count, 1000, "views");
} else {
// Podcast episode: shows duration instead of view count
assert!(video.duration.is_some(), "no view count or duration");
}
assert!(video.is_video); assert!(video.is_video);
} }
} }