Compare commits

..

No commits in common. "53a8ec680a3965a71b50ccbc7786fdf426c5fcdc" and "4d124c6d981ca3bd68a78361cf728ec41d637400" have entirely different histories.

19 changed files with 96 additions and 70225 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.12.0"
fancy-regex = "0.11.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.31.0", features = ["serialize"], optional = true }
quick-xml = { version = "0.30.0", features = ["serialize"], optional = true }
tracing = { version = "0.1.37", features = ["log"] }
[dev-dependencies]

View file

@ -5,7 +5,6 @@ use time::OffsetDateTime;
use url::Url;
use crate::{
client::response::YouTubeListItem,
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
@ -291,7 +290,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>,
@ -300,21 +299,11 @@ impl MapResponse<ChannelInfo> for response::ChannelAbout {
// and it allows parsing the country name.
let lang = Language::En;
let ep = match self {
response::ChannelAbout::ReceivedEndpoints {
on_response_received_endpoints,
} => on_response_received_endpoints
let ep = self
.on_response_received_endpoints
.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(),
));
}
};
.ok_or(ExtractionError::InvalidData("no received endpoint".into()))?;
let continuations = ep.append_continuation_items_action.continuation_items;
let about = continuations
.c
@ -494,6 +483,13 @@ 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
@ -508,41 +504,19 @@ fn map_channel_content(
let mut featured_tab = false;
for tab in &tabs {
if let Some(endpoint) = &tab.tab_renderer.endpoint {
if cmp_url_suffix(endpoint, "/featured")
if cmp_url_suffix(&tab.tab_renderer.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(endpoint, "/shorts") {
} else if cmp_url_suffix(&tab.tab_renderer.endpoint, "/shorts") {
has_shorts = true;
} else if cmp_url_suffix(endpoint, "/streams") {
} else if cmp_url_suffix(&tab.tab_renderer.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()
.filter(|t| t.tab_renderer.endpoint.is_some())
.find_map(|tab| {
let channel_content = tabs.into_iter().find_map(|tab| {
tab.tab_renderer
.content
.rich_grid_renderer
@ -556,10 +530,9 @@ fn map_channel_content(
match channel_content {
Some(list) => list.contents,
None => {
return Err(ExtractionError::NotFound {
id: id.to_owned(),
msg: "no tabs".into(),
});
return Err(ExtractionError::InvalidData(
"could not extract content".into(),
))
}
}
};
@ -659,7 +632,6 @@ mod tests {
use crate::{
client::{response, MapResponse},
error::{ExtractionError, UnavailabilityReason},
model::{paginator::Paginator, Channel, ChannelInfo, PlaylistItem, VideoItem},
param::{ChannelOrder, ChannelVideoTab, Language},
serializer::MapResult,
@ -677,7 +649,7 @@ mod tests {
#[case::upcoming("videos_upcoming", "UCcvfHa-GHSOHFAjU0-Ie57A")]
#[case::richgrid("videos_20221011_richgrid", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::richgrid2("videos_20221011_richgrid2", "UC2DjFE7Xf11URZqWBigcVOQ")]
#[case::coachella("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")]
#[case::richgrid2("videos_20230415_coachella", "UCHF66aWLOxBW4l6VkSrS3cQ")]
#[case::shorts("shorts", "UCh8gHdtzO2tXd593_bjErWg")]
#[case::livestreams("livestreams", "UC2DjFE7Xf11URZqWBigcVOQ")]
fn map_channel_videos(#[case] name: &str, #[case] id: &str) {
@ -706,23 +678,6 @@ 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,14 +112,13 @@ 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_videos: response::MusicNew =
let new_albums: response::MusicNew =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Vec<TrackItem>> = new_videos
let map_res: MapResult<Vec<TrackItem>> = new_albums
.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::Unavailable {
Err(Error::Extraction(ExtractionError::VideoUnavailable {
reason: UnavailabilityReason::UnsupportedClient,
..
})) => Err(Error::Extraction(e)),
@ -183,7 +183,7 @@ impl MapResponse<VideoPlayer> for response::Player {
_ => None,
})
.unwrap_or_default();
return Err(ExtractionError::Unavailable { reason, msg });
return Err(ExtractionError::VideoUnavailable { 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::Unavailable { reason, msg });
return Err(ExtractionError::VideoUnavailable { reason, msg });
}
response::player::PlayabilityStatus::LiveStreamOffline { reason } => {
return Err(ExtractionError::Unavailable {
return Err(ExtractionError::VideoUnavailable {
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::Unavailable {
return Err(ExtractionError::VideoUnavailable {
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: Option<ChannelTabEndpoint>,
pub endpoint: ChannelTabEndpoint,
}
#[serde_as]
@ -148,16 +148,10 @@ pub(crate) struct MicroformatDataRenderer {
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum ChannelAbout {
#[serde(rename_all = "camelCase")]
ReceivedEndpoints {
pub(crate) struct ChannelAbout {
#[serde_as(as = "VecSkipError<_>")]
on_response_received_endpoints: Vec<ContinuationActionWrap<AboutChannelRendererWrap>>,
},
Content {
contents: Option<Contents>,
},
pub on_response_received_endpoints: Vec<ContinuationActionWrap<AboutChannelRendererWrap>>,
}
#[derive(Debug, Deserialize)]

View file

@ -54,7 +54,7 @@ use serde::{
de::{IgnoredAny, Visitor},
Deserialize,
};
use serde_with::{serde_as, DisplayFromStr, VecSkipError};
use serde_with::{json::JsonString, serde_as, 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<DisplayFromStr>")]
#[serde_as(as = "Option<JsonString>")]
pub estimated_results: Option<u64>,
#[serde(
alias = "onResponseReceivedCommands",

View file

@ -11,7 +11,7 @@ use crate::{
text::{Text, TextComponent, TextComponents},
MapResult,
},
util::{self, dictionary, timeago},
util::{self, dictionary},
};
use super::{
@ -780,16 +780,9 @@ impl MusicListMapper {
.map(|st| map_album_type(st.first_str(), self.lang))
.unwrap_or_default();
let (mut artists, by_va) = map_artists(subtitle_p2);
let artist_id = map_artist_id_fallback(item.menu, artists.first());
let (artists, by_va) = map_artists(subtitle_p2);
// 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 artist_id = map_artist_id_fallback(item.menu, artists.first());
let year =
subtitle_p3.and_then(|st| util::parse_numeric(st.first_str()).ok());
@ -862,38 +855,23 @@ 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);
let view_count = subtitle_p2.and_then(|c| {
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| {
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::{DefaultOnError, DisplayFromStr};
use serde_with::{json::JsonString, DefaultOnError};
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 = "DisplayFromStr")]
#[serde_as(as = "JsonString")]
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<DisplayFromStr>")]
#[serde_as(as = "Option<JsonString>")]
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<DisplayFromStr>")]
#[serde_as(as = "Option<JsonString>")]
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<DisplayFromStr>")]
#[serde_as(as = "Option<JsonString>")]
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 = "DisplayFromStr")]
#[serde_as(as = "JsonString")]
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 = "DisplayFromStr")]
#[serde_as(as = "JsonString")]
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::{serde_as, DisplayFromStr};
use serde_with::{json::JsonString, serde_as};
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<DisplayFromStr>")]
#[serde_as(as = "Option<JsonString>")]
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::{
rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError,
json::JsonString, rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError,
};
use time::OffsetDateTime;
@ -69,14 +69,6 @@ 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:
@ -162,7 +154,7 @@ pub(crate) struct PlaylistVideoRenderer {
pub title: String,
#[serde(rename = "shortBylineText")]
pub channel: TextComponent,
#[serde_as(as = "Option<DisplayFromStr>")]
#[serde_as(as = "Option<JsonString>")]
pub length_seconds: Option<u32>,
/// Regular video: `["29K views", " • ", "13 years ago"]`
/// Livestream: `["66K", " watching"]`
@ -192,7 +184,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<DisplayFromStr>")]
#[serde_as(as = "Option<JsonString>")]
pub video_count: Option<u64>,
#[serde_as(as = "Option<Text>")]
pub video_count_short_text: Option<String>,
@ -248,7 +240,7 @@ pub(crate) struct YouTubeListRenderer {
#[serde(rename_all = "camelCase")]
pub(crate) struct UpcomingEventData {
/// Unixtime in seconds
#[serde_as(as = "DisplayFromStr")]
#[serde_as(as = "JsonString")]
pub start_time: i64,
}
@ -712,7 +704,7 @@ impl YouTubeListMapper<YouTubeItem> {
self.warnings.append(&mut contents.warnings);
contents.c.into_iter().for_each(|it| self.map_item(it));
}
YouTubeListItem::None | YouTubeListItem::ChannelAgeGateRenderer { .. } => {}
YouTubeListItem::None => {}
}
}

View file

@ -1,906 +0,0 @@
---
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 video id {video_id}, expected {id}"
"got wrong playlist id {video_id}, expected {id}"
)));
}
@ -572,10 +572,9 @@ mod tests {
#[case::chapters("chapters", "nFDBxBUfE74")]
#[case::live("live", "86YLFOog4GM")]
#[case::agegate("agegate", "HRKu0cvrr_o")]
#[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")]
#[case::newdesc("20220924_newdesc", "ZeerrnuLi5E")]
#[case::new_cont("20221011_new_continuation", "ZeerrnuLi5E")]
#[case::no_recommends("20221011_rec_isr", "nFDBxBUfE74")]
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,15 +24,14 @@ pub enum Error {
/// Error extracting content from YouTube
#[derive(thiserror::Error, Debug)]
pub enum ExtractionError {
/// Content cannot be extracted with RustyPipe
/// Video cannot be extracted with RustyPipe
///
/// Reasons include:
/// - Deletion/Censorship
/// - Age restriction
/// - Private video
/// - Private video that requires a Google account
/// - DRM (Movies and TV shows)
#[error("content unavailable because it is {reason}. Reason (from YT): {msg}")]
Unavailable {
#[error("video cant be played because it is {reason}. Reason (from YT): {msg}")]
VideoUnavailable {
/// Reason why the video could not be extracted
reason: UnavailabilityReason,
/// The error message as returned from YouTube
@ -78,9 +77,9 @@ pub enum ExtractionError {
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnavailabilityReason {
/// Video/Channel is age restricted.
/// Video is age restricted.
///
/// Video age restriction may be circumvented with the
/// Age restriction may be circumvented with the
/// [`ClientType::TvHtml5Embed`](crate::client::ClientType::TvHtml5Embed) client.
AgeRestricted,
/// Video was deleted or censored
@ -209,7 +208,7 @@ impl ExtractionError {
pub(crate) fn switch_client(&self) -> bool {
matches!(
self,
ExtractionError::Unavailable {
ExtractionError::VideoUnavailable {
reason: UnavailabilityReason::AgeRestricted
| UnavailabilityReason::UnsupportedClient,
..

View file

@ -1,12 +1,12 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_with::{serde_as, DeserializeAs, DisplayFromStr, SerializeAs};
use serde_with::{json::JsonString, serde_as, DeserializeAs, SerializeAs};
#[serde_as]
#[derive(Deserialize, Serialize)]
pub struct Range {
#[serde_as(as = "DisplayFromStr")]
#[serde_as(as = "JsonString")]
start: u32,
#[serde_as(as = "DisplayFromStr")]
#[serde_as(as = "JsonString")]
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::Unavailable { reason, .. }) => {
Error::Extraction(ExtractionError::VideoUnavailable { reason, .. }) => {
assert_eq!(reason, expect, "got {err}")
}
_ => panic!("got {err}"),
@ -1094,27 +1094,6 @@ 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")]
@ -1389,10 +1368,9 @@ fn music_playlist_cont(#[case] id: &str, rp: RustyPipe) {
assert_gte(track_count, 100, "tracks");
assert_eq!(track_count, playlist.tracks.count.unwrap());
assert_gte(
assert_eq!(
usize::try_from(track_count).unwrap(),
playlist.tracks.items.len(),
"tracks",
playlist.tracks.items.len()
);
}
@ -2264,12 +2242,7 @@ 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");
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_gte(video.view_count.unwrap(), 1000, "views");
assert!(video.is_video);
}
}