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"
regex = "1.6.0"
fancy-regex = "0.11.0"
fancy-regex = "0.12.0"
thiserror = "1.0.36"
url = "2.2.2"
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_json = "1.0.82"
serde_with = { version = "3.0.0", default-features = false, features = [
"alloc",
"macros",
"json",
] }
serde_plain = "1.0.1"
rand = "0.8.5"
@ -59,7 +59,7 @@ ress = "0.11.4"
phf = "0.11.1"
base64 = "0.21.0"
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"] }
[dev-dependencies]

View file

@ -5,6 +5,7 @@ use time::OffsetDateTime;
use url::Url;
use crate::{
client::response::YouTubeListItem,
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
@ -290,7 +291,7 @@ impl MapResponse<Channel<Paginator<PlaylistItem>>> for response::Channel {
impl MapResponse<ChannelInfo> for response::ChannelAbout {
fn map_response(
self,
_id: &str,
id: &str,
_lang: Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
_visitor_data: Option<&str>,
@ -299,11 +300,21 @@ impl MapResponse<ChannelInfo> for response::ChannelAbout {
// and it allows parsing the country name.
let lang = Language::En;
let ep = self
.on_response_received_endpoints
let ep = match self {
response::ChannelAbout::ReceivedEndpoints {
on_response_received_endpoints,
} => on_response_received_endpoints
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData("no received endpoint".into()))?;
.ok_or(ExtractionError::InvalidData("no received endpoint".into()))?,
response::ChannelAbout::Content { contents } => {
// Handle errors (e.g. age restriction) when regular channel content was returned
map_channel_content(id, contents, None)?;
return Err(ExtractionError::InvalidData(
"could not extract aboutData".into(),
));
}
};
let continuations = ep.append_continuation_items_action.continuation_items;
let about = continuations
.c
@ -483,13 +494,6 @@ fn map_channel_content(
match contents {
Some(contents) => {
let tabs = contents.two_column_browse_results_renderer.contents;
if tabs.is_empty() {
return Err(ExtractionError::NotFound {
id: id.to_owned(),
msg: "no tabs".into(),
});
}
let cmp_url_suffix = |endpoint: &response::channel::ChannelTabEndpoint,
expect: &str| {
endpoint
@ -504,19 +508,41 @@ fn map_channel_content(
let mut featured_tab = false;
for tab in &tabs {
if cmp_url_suffix(&tab.tab_renderer.endpoint, "/featured")
if let Some(endpoint) = &tab.tab_renderer.endpoint {
if cmp_url_suffix(endpoint, "/featured")
&& (tab.tab_renderer.content.section_list_renderer.is_some()
|| tab.tab_renderer.content.rich_grid_renderer.is_some())
{
featured_tab = true;
} else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/shorts") {
} else if cmp_url_suffix(endpoint, "/shorts") {
has_shorts = true;
} else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/streams") {
} else if cmp_url_suffix(endpoint, "/streams") {
has_live = true;
}
} else {
// Check for age gate
if let Some(YouTubeListItem::ChannelAgeGateRenderer {
channel_title,
main_text,
}) = &tab
.tab_renderer
.content
.section_list_renderer
.as_ref()
.and_then(|c| c.contents.c.get(0))
{
return Err(ExtractionError::Unavailable {
reason: crate::error::UnavailabilityReason::AgeRestricted,
msg: format!("{channel_title}: {main_text}"),
});
}
}
}
let channel_content = tabs.into_iter().find_map(|tab| {
let channel_content = tabs
.into_iter()
.filter(|t| t.tab_renderer.endpoint.is_some())
.find_map(|tab| {
tab.tab_renderer
.content
.rich_grid_renderer
@ -530,9 +556,10 @@ fn map_channel_content(
match channel_content {
Some(list) => list.contents,
None => {
return Err(ExtractionError::InvalidData(
"could not extract content".into(),
))
return Err(ExtractionError::NotFound {
id: id.to_owned(),
msg: "no tabs".into(),
});
}
}
};
@ -632,6 +659,7 @@ mod tests {
use crate::{
client::{response, MapResponse},
error::{ExtractionError, UnavailabilityReason},
model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem},
param::{ChannelOrder, ChannelVideoTab, Language},
serializer::MapResult,
@ -649,7 +677,7 @@ mod tests {
#[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")]
#[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")]
#[case::richgrid2("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")]
#[case::coachella("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")]
#[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::livestreams("livestreams", "UC2DjFE7Xf11URZqWBigcVOQ")]
fn map_channel_videos(#[case] name: &str, #[case] id: &str) {
@ -678,6 +706,23 @@ mod tests {
}
}
#[test]
fn channel_agegate() {
let json_path = path!(*TESTFILES / "channel" / format!("channel_agegate.json"));
let json_file = File::open(json_path).unwrap();
let channel: response::Channel =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let res: Result<MapResult<Channel<Paginator<VideoItem>>>, ExtractionError> =
channel.map_response("UCbfnHqxXs_K3kvaH-WlNlig", Language::En, None, None);
if let Err(ExtractionError::Unavailable { reason, msg }) = res {
assert_eq!(reason, UnavailabilityReason::AgeRestricted);
assert!(msg.starts_with("Laphroaig Whisky: "));
} else {
panic!("invalid res: {res:?}")
}
}
#[rstest]
fn map_channel_playlists() {
let json_path = path!(*TESTFILES / "channel" / "channel_playlists.json");

View file

@ -112,13 +112,14 @@ mod tests {
#[rstest]
#[case::default("default")]
#[case::default("w_podcasts")]
fn map_music_new_videos(#[case] name: &str) {
let json_path = path!(*TESTFILES / "music_new" / format!("videos_{name}.json"));
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();
let map_res: MapResult<Vec<TrackItem>> = new_albums
let map_res: MapResult<Vec<TrackItem>> = new_videos
.map_response("", Language::En, None, None)
.unwrap();

View file

@ -77,7 +77,7 @@ impl RustyPipeQuery {
match tv_res {
// Output desktop client error if the tv client is unsupported
Err(Error::Extraction(ExtractionError::VideoUnavailable {
Err(Error::Extraction(ExtractionError::Unavailable {
reason: UnavailabilityReason::UnsupportedClient,
..
})) => Err(Error::Extraction(e)),
@ -183,7 +183,7 @@ impl MapResponse<VideoPlayer> for response::Player {
_ => None,
})
.unwrap_or_default();
return Err(ExtractionError::VideoUnavailable { reason, msg });
return Err(ExtractionError::Unavailable { reason, msg });
}
response::player::PlayabilityStatus::LoginRequired { reason, messages } => {
let mut msg = reason;
@ -205,10 +205,10 @@ impl MapResponse<VideoPlayer> for response::Player {
_ => None,
})
.unwrap_or_default();
return Err(ExtractionError::VideoUnavailable { reason, msg });
return Err(ExtractionError::Unavailable { reason, msg });
}
response::player::PlayabilityStatus::LiveStreamOffline { reason } => {
return Err(ExtractionError::VideoUnavailable {
return Err(ExtractionError::Unavailable {
reason: UnavailabilityReason::OfflineLivestream,
msg: reason,
});
@ -216,7 +216,7 @@ impl MapResponse<VideoPlayer> for response::Player {
response::player::PlayabilityStatus::Error { reason } => {
// reason (censored): "This video has been removed for violating YouTube's policy on hate speech. Learn more about combating hate speech in your country."
// reason: "This video is unavailable"
return Err(ExtractionError::VideoUnavailable {
return Err(ExtractionError::Unavailable {
reason: UnavailabilityReason::Deleted,
msg: reason,
});

View file

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

View file

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

View file

@ -11,7 +11,7 @@ use crate::{
text::{Text, TextComponent, TextComponents},
MapResult,
},
util::{self, dictionary},
util::{self, dictionary, timeago},
};
use super::{
@ -780,10 +780,17 @@ impl MusicListMapper {
.map(|st| map_album_type(st.first_str(), self.lang))
.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());
// 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 =
subtitle_p3.and_then(|st| util::parse_numeric(st.first_str()).ok());
@ -855,23 +862,38 @@ impl MusicListMapper {
match item.navigation_endpoint.music_page() {
Some(music_page) => match music_page.typ {
MusicPageType::Track { vtype } => {
let (artists, by_va, view_count, duration) = if vtype == MusicVideoType::Episode
{
let (artists, by_va) = map_artists(subtitle_p2);
let duration = subtitle_p1.and_then(|s| {
timeago::parse_video_duration_or_warn(
self.lang,
s.first_str(),
&mut self.warnings,
)
});
(artists, by_va, None, duration)
} else {
let (artists, by_va) = map_artists(subtitle_p1);
self.items.push(MusicItem::Track(TrackItem {
id: music_page.id,
name: item.title,
duration: None,
cover: item.thumbnail_renderer.into(),
artist_id: artists.first().and_then(|a| a.id.clone()),
artists,
album: None,
view_count: subtitle_p2.and_then(|c| {
let view_count = subtitle_p2.and_then(|c| {
util::parse_large_numstr_or_warn(
c.first_str(),
self.lang,
&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(),
track_nr: None,
by_va,

View file

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

View file

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

View file

@ -2,7 +2,7 @@ use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize;
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;
@ -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:
@ -154,7 +162,7 @@ pub(crate) struct PlaylistVideoRenderer {
pub title: String,
#[serde(rename = "shortBylineText")]
pub channel: TextComponent,
#[serde_as(as = "Option<JsonString>")]
#[serde_as(as = "Option<DisplayFromStr>")]
pub length_seconds: Option<u32>,
/// Regular video: `["29K views", " • ", "13 years ago"]`
/// Livestream: `["66K", " watching"]`
@ -184,7 +192,7 @@ pub(crate) struct PlaylistRenderer {
/// The first item of this list contains the playlist thumbnail,
/// subsequent items contain very small thumbnails of the next playlist videos
pub thumbnails: Option<Vec<Thumbnails>>,
#[serde_as(as = "Option<JsonString>")]
#[serde_as(as = "Option<DisplayFromStr>")]
pub video_count: Option<u64>,
#[serde_as(as = "Option<Text>")]
pub video_count_short_text: Option<String>,
@ -240,7 +248,7 @@ pub(crate) struct YouTubeListRenderer {
#[serde(rename_all = "camelCase")]
pub(crate) struct UpcomingEventData {
/// Unixtime in seconds
#[serde_as(as = "JsonString")]
#[serde_as(as = "DisplayFromStr")]
pub start_time: i64,
}
@ -704,7 +712,7 @@ impl YouTubeListMapper<YouTubeItem> {
self.warnings.append(&mut contents.warnings);
contents.c.into_iter().for_each(|it| self.map_item(it));
}
YouTubeListItem::None => {}
YouTubeListItem::None | YouTubeListItem::ChannelAgeGateRenderer { .. } => {}
}
}

View file

@ -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;
if id != video_id {
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::live("live", "86YLFOog4GM")]
#[case::agegate("agegate", "HRKu0cvrr_o")]
#[case::newdesc("20220924_newdesc", "ZeerrnuLi5E")]
#[case::new_cont("20221011_new_continuation", "ZeerrnuLi5E")]
#[case::no_recommends("20221011_rec_isr", "nFDBxBUfE74")]
#[case::ab_newdesc("20220924_newdesc", "ZeerrnuLi5E")]
#[case::ab_new_cont("20221011_new_continuation", "ZeerrnuLi5E")]
#[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) {
let json_path = path!(*TESTFILES / "video_details" / format!("video_details_{name}.json"));
let json_file = File::open(json_path).unwrap();

View file

@ -24,14 +24,15 @@ pub enum Error {
/// Error extracting content from YouTube
#[derive(thiserror::Error, Debug)]
pub enum ExtractionError {
/// Video cannot be extracted with RustyPipe
/// Content cannot be extracted with RustyPipe
///
/// Reasons include:
/// - Deletion/Censorship
/// - Private video that requires a Google account
/// - Age restriction
/// - Private video
/// - DRM (Movies and TV shows)
#[error("video cant be played because it is {reason}. Reason (from YT): {msg}")]
VideoUnavailable {
#[error("content unavailable because it is {reason}. Reason (from YT): {msg}")]
Unavailable {
/// Reason why the video could not be extracted
reason: UnavailabilityReason,
/// The error message as returned from YouTube
@ -77,9 +78,9 @@ pub enum ExtractionError {
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnavailabilityReason {
/// Video is age restricted.
/// Video/Channel is age restricted.
///
/// Age restriction may be circumvented with the
/// Video age restriction may be circumvented with the
/// [`ClientType::TvHtml5Embed`](crate::client::ClientType::TvHtml5Embed) client.
AgeRestricted,
/// Video was deleted or censored
@ -208,7 +209,7 @@ impl ExtractionError {
pub(crate) fn switch_client(&self) -> bool {
matches!(
self,
ExtractionError::VideoUnavailable {
ExtractionError::Unavailable {
reason: UnavailabilityReason::AgeRestricted
| UnavailabilityReason::UnsupportedClient,
..

View file

@ -1,12 +1,12 @@
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]
#[derive(Deserialize, Serialize)]
pub struct Range {
#[serde_as(as = "JsonString")]
#[serde_as(as = "DisplayFromStr")]
start: u32,
#[serde_as(as = "JsonString")]
#[serde_as(as = "DisplayFromStr")]
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();
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")]
@ -1368,9 +1389,10 @@ fn music_playlist_cont(#[case] id: &str, rp: RustyPipe) {
assert_gte(track_count, 100, "tracks");
assert_eq!(track_count, playlist.tracks.count.unwrap());
assert_eq!(
assert_gte(
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();
assert!(!video.name.is_empty());
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);
}
}