Compare commits
5 commits
012cde8b51
...
79b7fcf92c
Author | SHA1 | Date | |
---|---|---|---|
79b7fcf92c | |||
de9e3c6ed9 | |||
78bf29453f | |||
562ac2df7e | |||
ef35c48890 |
16 changed files with 12527 additions and 169 deletions
|
@ -160,7 +160,7 @@ impl MapResponse<Channel<Paginator<ChannelVideo>>> for response::Channel {
|
||||||
lang: Language,
|
lang: Language,
|
||||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
) -> Result<MapResult<Channel<Paginator<ChannelVideo>>>, ExtractionError> {
|
) -> Result<MapResult<Channel<Paginator<ChannelVideo>>>, ExtractionError> {
|
||||||
let content = map_channel_content(self.contents, id);
|
let content = map_channel_content(self.contents, id, self.alerts)?;
|
||||||
let mut warnings = content.warnings;
|
let mut warnings = content.warnings;
|
||||||
let grid = match content.c {
|
let grid = match content.c {
|
||||||
response::channel::ChannelContent::GridRenderer { items } => Some(items),
|
response::channel::ChannelContent::GridRenderer { items } => Some(items),
|
||||||
|
@ -191,7 +191,7 @@ impl MapResponse<Channel<Paginator<ChannelPlaylist>>> for response::Channel {
|
||||||
lang: Language,
|
lang: Language,
|
||||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
) -> Result<MapResult<Channel<Paginator<ChannelPlaylist>>>, ExtractionError> {
|
) -> Result<MapResult<Channel<Paginator<ChannelPlaylist>>>, ExtractionError> {
|
||||||
let content = map_channel_content(self.contents, id);
|
let content = map_channel_content(self.contents, id, self.alerts)?;
|
||||||
let mut warnings = content.warnings;
|
let mut warnings = content.warnings;
|
||||||
let grid = match content.c {
|
let grid = match content.c {
|
||||||
response::channel::ChannelContent::GridRenderer { items } => Some(items),
|
response::channel::ChannelContent::GridRenderer { items } => Some(items),
|
||||||
|
@ -222,7 +222,7 @@ impl MapResponse<Channel<ChannelInfo>> for response::Channel {
|
||||||
lang: Language,
|
lang: Language,
|
||||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
) -> Result<MapResult<Channel<ChannelInfo>>, ExtractionError> {
|
) -> Result<MapResult<Channel<ChannelInfo>>, ExtractionError> {
|
||||||
let content = map_channel_content(self.contents, id);
|
let content = map_channel_content(self.contents, id, self.alerts)?;
|
||||||
let mut warnings = content.warnings;
|
let mut warnings = content.warnings;
|
||||||
let meta = match content.c {
|
let meta = match content.c {
|
||||||
response::channel::ChannelContent::ChannelAboutFullMetadataRenderer(meta) => Some(meta),
|
response::channel::ChannelContent::ChannelAboutFullMetadataRenderer(meta) => Some(meta),
|
||||||
|
@ -281,12 +281,11 @@ impl MapResponse<Paginator<ChannelVideo>> for response::ChannelCont {
|
||||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
) -> Result<MapResult<Paginator<ChannelVideo>>, ExtractionError> {
|
) -> Result<MapResult<Paginator<ChannelVideo>>, ExtractionError> {
|
||||||
let mut actions = self.on_response_received_actions;
|
let mut actions = self.on_response_received_actions;
|
||||||
let res = some_or_bail!(
|
let res = actions
|
||||||
actions.try_swap_remove(0),
|
.try_swap_remove(0)
|
||||||
Err(ExtractionError::InvalidData("no received action".into()))
|
.ok_or(ExtractionError::Retry)?
|
||||||
)
|
.append_continuation_items_action
|
||||||
.append_continuation_items_action
|
.continuation_items;
|
||||||
.continuation_items;
|
|
||||||
|
|
||||||
Ok(map_videos(res, lang))
|
Ok(map_videos(res, lang))
|
||||||
}
|
}
|
||||||
|
@ -300,12 +299,11 @@ impl MapResponse<Paginator<ChannelPlaylist>> for response::ChannelCont {
|
||||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
) -> Result<MapResult<Paginator<ChannelPlaylist>>, ExtractionError> {
|
) -> Result<MapResult<Paginator<ChannelPlaylist>>, ExtractionError> {
|
||||||
let mut actions = self.on_response_received_actions;
|
let mut actions = self.on_response_received_actions;
|
||||||
let res = some_or_bail!(
|
let res = actions
|
||||||
actions.try_swap_remove(0),
|
.try_swap_remove(0)
|
||||||
Err(ExtractionError::InvalidData("no received action".into()))
|
.ok_or(ExtractionError::Retry)?
|
||||||
)
|
.append_continuation_items_action
|
||||||
.append_continuation_items_action
|
.continuation_items;
|
||||||
.continuation_items;
|
|
||||||
|
|
||||||
Ok(map_playlists(res))
|
Ok(map_playlists(res))
|
||||||
}
|
}
|
||||||
|
@ -419,14 +417,18 @@ fn map_vanity_url(url: &str, id: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_channel<T>(
|
fn map_channel<T>(
|
||||||
header: response::channel::Header,
|
header: Option<response::channel::Header>,
|
||||||
metadata: response::channel::Metadata,
|
metadata: Option<response::channel::Metadata>,
|
||||||
microformat: response::channel::Microformat,
|
microformat: Option<response::channel::Microformat>,
|
||||||
content: T,
|
content: T,
|
||||||
id: &str,
|
id: &str,
|
||||||
lang: Language,
|
lang: Language,
|
||||||
) -> Result<Channel<T>, ExtractionError> {
|
) -> Result<Channel<T>, ExtractionError> {
|
||||||
let metadata = metadata.channel_metadata_renderer;
|
let header = header.ok_or(ExtractionError::NoData)?;
|
||||||
|
let metadata = metadata
|
||||||
|
.ok_or(ExtractionError::NoData)?
|
||||||
|
.channel_metadata_renderer;
|
||||||
|
let microformat = microformat.ok_or(ExtractionError::NoData)?;
|
||||||
|
|
||||||
if metadata.external_id != id {
|
if metadata.external_id != id {
|
||||||
return Err(ExtractionError::WrongResult(format!(
|
return Err(ExtractionError::WrongResult(format!(
|
||||||
|
@ -494,39 +496,45 @@ fn map_channel<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_channel_content(
|
fn map_channel_content(
|
||||||
contents: response::channel::Contents,
|
contents: Option<response::channel::Contents>,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> MapResult<response::channel::ChannelContent> {
|
alerts: Option<Vec<response::Alert>>,
|
||||||
let mut tabs = contents.two_column_browse_results_renderer.tabs;
|
) -> Result<MapResult<response::channel::ChannelContent>, ExtractionError> {
|
||||||
let mut sectionlist = some_or_bail!(
|
match contents {
|
||||||
tabs.try_swap_remove(0),
|
Some(contents) => {
|
||||||
MapResult::error("no tab".to_owned())
|
let mut tabs = contents.two_column_browse_results_renderer.tabs;
|
||||||
)
|
let mut sectionlist = some_or_bail!(
|
||||||
.tab_renderer
|
tabs.try_swap_remove(0),
|
||||||
.content
|
Ok(MapResult::error("no tab".to_owned()))
|
||||||
.section_list_renderer;
|
)
|
||||||
|
.tab_renderer
|
||||||
|
.content
|
||||||
|
.section_list_renderer;
|
||||||
|
|
||||||
if let Some(target_id) = sectionlist.target_id {
|
if let Some(target_id) = sectionlist.target_id {
|
||||||
// YouTube falls back to the featured page if the channel does not have a "videos" tab.
|
// YouTube falls back to the featured page if the channel does not have a "videos" tab.
|
||||||
// This is the case for YouTube Music channels.
|
// This is the case for YouTube Music channels.
|
||||||
if target_id.starts_with(&format!("browse-feed{}featured", id)) {
|
if target_id.starts_with(&format!("browse-feed{}featured", id)) {
|
||||||
return MapResult::ok(response::channel::ChannelContent::None);
|
return Ok(MapResult::ok(response::channel::ChannelContent::None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut itemsection = some_or_bail!(
|
||||||
|
sectionlist.contents.try_swap_remove(0),
|
||||||
|
Ok(MapResult::error("no sectionlist".to_owned()))
|
||||||
|
)
|
||||||
|
.item_section_renderer
|
||||||
|
.contents;
|
||||||
|
|
||||||
|
let content = some_or_bail!(
|
||||||
|
itemsection.try_swap_remove(0),
|
||||||
|
Ok(MapResult::error("no channel content".to_owned()))
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(MapResult::ok(content))
|
||||||
}
|
}
|
||||||
|
None => Err(response::alerts_to_err(alerts)),
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut itemsection = some_or_bail!(
|
|
||||||
sectionlist.contents.try_swap_remove(0),
|
|
||||||
MapResult::error("no sectionlist".to_owned())
|
|
||||||
)
|
|
||||||
.item_section_renderer
|
|
||||||
.contents;
|
|
||||||
|
|
||||||
let content = some_or_bail!(
|
|
||||||
itemsection.try_swap_remove(0),
|
|
||||||
MapResult::error("no channel content".to_owned())
|
|
||||||
);
|
|
||||||
|
|
||||||
MapResult::ok(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
@ -169,7 +169,8 @@ struct RustyPipeRef {
|
||||||
http: Client,
|
http: Client,
|
||||||
storage: Option<Box<dyn CacheStorage>>,
|
storage: Option<Box<dyn CacheStorage>>,
|
||||||
reporter: Option<Box<dyn Reporter>>,
|
reporter: Option<Box<dyn Reporter>>,
|
||||||
n_retries: u32,
|
n_http_retries: u32,
|
||||||
|
n_query_retries: u32,
|
||||||
consent_cookie: String,
|
consent_cookie: String,
|
||||||
cache: CacheHolder,
|
cache: CacheHolder,
|
||||||
default_opts: RustyPipeOpts,
|
default_opts: RustyPipeOpts,
|
||||||
|
@ -186,7 +187,8 @@ struct RustyPipeOpts {
|
||||||
pub struct RustyPipeBuilder {
|
pub struct RustyPipeBuilder {
|
||||||
storage: Option<Box<dyn CacheStorage>>,
|
storage: Option<Box<dyn CacheStorage>>,
|
||||||
reporter: Option<Box<dyn Reporter>>,
|
reporter: Option<Box<dyn Reporter>>,
|
||||||
n_retries: u32,
|
n_http_retries: u32,
|
||||||
|
n_query_retries: u32,
|
||||||
user_agent: String,
|
user_agent: String,
|
||||||
default_opts: RustyPipeOpts,
|
default_opts: RustyPipeOpts,
|
||||||
}
|
}
|
||||||
|
@ -277,7 +279,8 @@ impl RustyPipeBuilder {
|
||||||
default_opts: RustyPipeOpts::default(),
|
default_opts: RustyPipeOpts::default(),
|
||||||
storage: Some(Box::new(FileStorage::default())),
|
storage: Some(Box::new(FileStorage::default())),
|
||||||
reporter: Some(Box::new(FileReporter::default())),
|
reporter: Some(Box::new(FileReporter::default())),
|
||||||
n_retries: 3,
|
n_http_retries: 3,
|
||||||
|
n_query_retries: 2,
|
||||||
user_agent: DEFAULT_UA.to_owned(),
|
user_agent: DEFAULT_UA.to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -312,7 +315,8 @@ impl RustyPipeBuilder {
|
||||||
http,
|
http,
|
||||||
storage: self.storage,
|
storage: self.storage,
|
||||||
reporter: self.reporter,
|
reporter: self.reporter,
|
||||||
n_retries: self.n_retries,
|
n_http_retries: self.n_http_retries,
|
||||||
|
n_query_retries: self.n_query_retries,
|
||||||
consent_cookie: format!(
|
consent_cookie: format!(
|
||||||
"{}={}{}",
|
"{}={}{}",
|
||||||
CONSENT_COOKIE,
|
CONSENT_COOKIE,
|
||||||
|
@ -367,8 +371,18 @@ impl RustyPipeBuilder {
|
||||||
/// random jitter to be less predictable).
|
/// random jitter to be less predictable).
|
||||||
///
|
///
|
||||||
/// **Default value**: 3
|
/// **Default value**: 3
|
||||||
pub fn n_retries(mut self, n_retries: u32) -> Self {
|
pub fn n_http_retries(mut self, n_retries: u32) -> Self {
|
||||||
self.n_retries = n_retries;
|
self.n_http_retries = n_retries;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the number of retries for YouTube API queries.
|
||||||
|
///
|
||||||
|
/// If a YouTube API requests returns invalid data, the request is repeated.
|
||||||
|
///
|
||||||
|
/// **Default value**: 2
|
||||||
|
pub fn n_query_retries(mut self, n_retries: u32) -> Self {
|
||||||
|
self.n_http_retries = n_retries;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -458,7 +472,7 @@ impl RustyPipe {
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> core::result::Result<Response, reqwest::Error> {
|
) -> core::result::Result<Response, reqwest::Error> {
|
||||||
let mut last_res = None;
|
let mut last_res = None;
|
||||||
for n in 0..self.inner.n_retries {
|
for n in 0..self.inner.n_http_retries {
|
||||||
let res = self.inner.http.execute(request.try_clone().unwrap()).await;
|
let res = self.inner.http.execute(request.try_clone().unwrap()).await;
|
||||||
let emsg = match &res {
|
let emsg = match &res {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
|
@ -939,6 +953,56 @@ impl RustyPipeQuery {
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
body: &B,
|
body: &B,
|
||||||
deobf: Option<&Deobfuscator>,
|
deobf: Option<&Deobfuscator>,
|
||||||
|
) -> Result<M> {
|
||||||
|
for n in 0..self.client.inner.n_query_retries.saturating_sub(1) {
|
||||||
|
let res = self
|
||||||
|
._try_execute_request_deobf::<R, M, B>(
|
||||||
|
ctype,
|
||||||
|
operation,
|
||||||
|
id,
|
||||||
|
endpoint,
|
||||||
|
body,
|
||||||
|
deobf,
|
||||||
|
n == 0,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let emsg = match res {
|
||||||
|
Ok(res) => return Ok(res),
|
||||||
|
Err(error) => match &error {
|
||||||
|
Error::Extraction(e) => match e {
|
||||||
|
ExtractionError::Deserialization(_)
|
||||||
|
| ExtractionError::InvalidData(_)
|
||||||
|
| ExtractionError::WrongResult(_)
|
||||||
|
| ExtractionError::Retry => e.to_string(),
|
||||||
|
_ => return Err(error),
|
||||||
|
},
|
||||||
|
_ => return Err(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
warn!("{} retry attempt #{}. Error: {}.", operation, n, emsg);
|
||||||
|
}
|
||||||
|
self._try_execute_request_deobf::<R, M, B>(
|
||||||
|
ctype, operation, id, endpoint, body, deobf, false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single try of `execute_request_deobf`
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn _try_execute_request_deobf<
|
||||||
|
R: DeserializeOwned + MapResponse<M> + Debug,
|
||||||
|
M,
|
||||||
|
B: Serialize + ?Sized,
|
||||||
|
>(
|
||||||
|
&self,
|
||||||
|
ctype: ClientType,
|
||||||
|
operation: &str,
|
||||||
|
id: &str,
|
||||||
|
endpoint: &str,
|
||||||
|
body: &B,
|
||||||
|
deobf: Option<&Deobfuscator>,
|
||||||
|
report: bool,
|
||||||
) -> Result<M> {
|
) -> Result<M> {
|
||||||
let request = self
|
let request = self
|
||||||
.request_builder(ctype, endpoint)
|
.request_builder(ctype, endpoint)
|
||||||
|
@ -949,36 +1013,38 @@ impl RustyPipeQuery {
|
||||||
let request_url = request.url().to_string();
|
let request_url = request.url().to_string();
|
||||||
let request_headers = request.headers().to_owned();
|
let request_headers = request.headers().to_owned();
|
||||||
|
|
||||||
let response = self.client.inner.http.execute(request).await?;
|
let response = self.client.http_request(request).await?;
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let resp_str = response.text().await?;
|
let resp_str = response.text().await?;
|
||||||
|
|
||||||
let create_report = |level: Level, error: Option<String>, msgs: Vec<String>| {
|
let create_report = |level: Level, error: Option<String>, msgs: Vec<String>| {
|
||||||
if let Some(reporter) = &self.client.inner.reporter {
|
if report {
|
||||||
let report = Report {
|
if let Some(reporter) = &self.client.inner.reporter {
|
||||||
info: Default::default(),
|
let report = Report {
|
||||||
level,
|
info: Default::default(),
|
||||||
operation: format!("{}({})", operation, id),
|
level,
|
||||||
error,
|
operation: format!("{}({})", operation, id),
|
||||||
msgs,
|
error,
|
||||||
deobf_data: deobf.map(Deobfuscator::get_data),
|
msgs,
|
||||||
http_request: crate::report::HTTPRequest {
|
deobf_data: deobf.map(Deobfuscator::get_data),
|
||||||
url: request_url,
|
http_request: crate::report::HTTPRequest {
|
||||||
method: "POST".to_string(),
|
url: request_url,
|
||||||
req_header: request_headers
|
method: "POST".to_string(),
|
||||||
.iter()
|
req_header: request_headers
|
||||||
.map(|(k, v)| {
|
.iter()
|
||||||
(k.to_string(), v.to_str().unwrap_or_default().to_owned())
|
.map(|(k, v)| {
|
||||||
})
|
(k.to_string(), v.to_str().unwrap_or_default().to_owned())
|
||||||
.collect(),
|
})
|
||||||
req_body: serde_json::to_string(body).unwrap_or_default(),
|
.collect(),
|
||||||
status: status.into(),
|
req_body: serde_json::to_string(body).unwrap_or_default(),
|
||||||
resp_body: resp_str.to_owned(),
|
status: status.into(),
|
||||||
},
|
resp_body: resp_str.to_owned(),
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
|
||||||
reporter.report(&report);
|
reporter.report(&report);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1009,7 +1075,14 @@ impl RustyPipeQuery {
|
||||||
Ok(mapres.c)
|
Ok(mapres.c)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
create_report(Level::ERR, Some(e.to_string()), Vec::new());
|
match e {
|
||||||
|
ExtractionError::VideoUnavailable(_, _)
|
||||||
|
| ExtractionError::VideoAgeRestricted
|
||||||
|
| ExtractionError::ContentUnavailable(_)
|
||||||
|
| ExtractionError::NoData
|
||||||
|
| ExtractionError::Retry => (),
|
||||||
|
_ => create_report(Level::ERR, Some(e.to_string()), Vec::new()),
|
||||||
|
}
|
||||||
Err(e.into())
|
Err(e.into())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -68,8 +68,12 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
lang: Language,
|
lang: Language,
|
||||||
_deobf: Option<&Deobfuscator>,
|
_deobf: Option<&Deobfuscator>,
|
||||||
) -> Result<MapResult<Playlist>, ExtractionError> {
|
) -> Result<MapResult<Playlist>, ExtractionError> {
|
||||||
// TODO: think about a deserializer that deserializes only first list item
|
let (contents, header) = match (self.contents, self.header) {
|
||||||
let mut tcbr_contents = self.contents.two_column_browse_results_renderer.contents;
|
(Some(contents), Some(header)) => (contents, header),
|
||||||
|
_ => return Err(response::alerts_to_err(self.alerts)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tcbr_contents = contents.two_column_browse_results_renderer.contents;
|
||||||
let video_items = some_or_bail!(
|
let video_items = some_or_bail!(
|
||||||
some_or_bail!(
|
some_or_bail!(
|
||||||
some_or_bail!(
|
some_or_bail!(
|
||||||
|
@ -121,11 +125,11 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let header_banner = some_or_bail!(
|
let header_banner = some_or_bail!(
|
||||||
self.header.playlist_header_renderer.playlist_header_banner,
|
header.playlist_header_renderer.playlist_header_banner,
|
||||||
Err(ExtractionError::InvalidData("no thumbnail found".into()))
|
Err(ExtractionError::InvalidData("no thumbnail found".into()))
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut byline = self.header.playlist_header_renderer.byline;
|
let mut byline = header.playlist_header_renderer.byline;
|
||||||
let last_update_txt = byline
|
let last_update_txt = byline
|
||||||
.try_swap_remove(1)
|
.try_swap_remove(1)
|
||||||
.map(|b| b.playlist_byline_renderer.text);
|
.map(|b| b.playlist_byline_renderer.text);
|
||||||
|
@ -140,14 +144,14 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
let n_videos = match ctoken {
|
let n_videos = match ctoken {
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
ok_or_bail!(
|
ok_or_bail!(
|
||||||
util::parse_numeric(&self.header.playlist_header_renderer.num_videos_text),
|
util::parse_numeric(&header.playlist_header_renderer.num_videos_text),
|
||||||
Err(ExtractionError::InvalidData("no video count".into()))
|
Err(ExtractionError::InvalidData("no video count".into()))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
None => videos.len() as u64,
|
None => videos.len() as u64,
|
||||||
};
|
};
|
||||||
|
|
||||||
let playlist_id = self.header.playlist_header_renderer.playlist_id;
|
let playlist_id = header.playlist_header_renderer.playlist_id;
|
||||||
if playlist_id != id {
|
if playlist_id != id {
|
||||||
return Err(ExtractionError::WrongResult(format!(
|
return Err(ExtractionError::WrongResult(format!(
|
||||||
"got wrong playlist id {}, expected {}",
|
"got wrong playlist id {}, expected {}",
|
||||||
|
@ -155,10 +159,9 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = self.header.playlist_header_renderer.title;
|
let name = header.playlist_header_renderer.title;
|
||||||
let description = self.header.playlist_header_renderer.description_text;
|
let description = header.playlist_header_renderer.description_text;
|
||||||
let channel = self
|
let channel = header
|
||||||
.header
|
|
||||||
.playlist_header_renderer
|
.playlist_header_renderer
|
||||||
.owner_text
|
.owner_text
|
||||||
.and_then(|link| ChannelId::try_from(link).ok());
|
.and_then(|link| ChannelId::try_from(link).ok());
|
||||||
|
@ -193,12 +196,7 @@ impl MapResponse<Paginator<PlaylistVideo>> for response::PlaylistCont {
|
||||||
_deobf: Option<&Deobfuscator>,
|
_deobf: Option<&Deobfuscator>,
|
||||||
) -> Result<MapResult<Paginator<PlaylistVideo>>, ExtractionError> {
|
) -> Result<MapResult<Paginator<PlaylistVideo>>, ExtractionError> {
|
||||||
let mut actions = self.on_response_received_actions;
|
let mut actions = self.on_response_received_actions;
|
||||||
let action = some_or_bail!(
|
let action = actions.try_swap_remove(0).ok_or(ExtractionError::Retry)?;
|
||||||
actions.try_swap_remove(0),
|
|
||||||
Err(ExtractionError::InvalidData(
|
|
||||||
"no continuation action".into()
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
let (items, ctoken) =
|
let (items, ctoken) =
|
||||||
map_playlist_items(action.append_continuation_items_action.continuation_items.c);
|
map_playlist_items(action.append_continuation_items_action.continuation_items.c);
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::serde_as;
|
use serde_with::serde_as;
|
||||||
use serde_with::VecSkipError;
|
use serde_with::{DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
use super::ChannelBadge;
|
|
||||||
use super::Thumbnails;
|
use super::Thumbnails;
|
||||||
|
use super::{Alert, ChannelBadge};
|
||||||
use super::{ContentRenderer, ContentsRenderer, VideoListItem};
|
use super::{ContentRenderer, ContentsRenderer, VideoListItem};
|
||||||
use crate::serializer::ignore_any;
|
use crate::serializer::ignore_any;
|
||||||
use crate::serializer::{text::Text, MapResult, VecLogError};
|
use crate::serializer::{text::Text, MapResult, VecLogError};
|
||||||
|
@ -12,16 +12,21 @@ use crate::serializer::{text::Text, MapResult, VecLogError};
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
pub header: Header,
|
#[serde_as(as = "DefaultOnError")]
|
||||||
pub contents: Contents,
|
pub header: Option<Header>,
|
||||||
pub metadata: Metadata,
|
pub contents: Option<Contents>,
|
||||||
pub microformat: Microformat,
|
pub metadata: Option<Metadata>,
|
||||||
|
pub microformat: Option<Microformat>,
|
||||||
|
#[serde_as(as = "Option<DefaultOnError>")]
|
||||||
|
pub alerts: Option<Vec<Alert>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ChannelCont {
|
pub struct ChannelCont {
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub on_response_received_actions: Vec<OnResponseReceivedAction>,
|
pub on_response_received_actions: Vec<OnResponseReceivedAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ pub use channel_rss::ChannelRss;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::{json::JsonString, serde_as, DefaultOnError, VecSkipError};
|
use serde_with::{json::JsonString, serde_as, DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
|
use crate::error::ExtractionError;
|
||||||
use crate::serializer::{
|
use crate::serializer::{
|
||||||
ignore_any,
|
ignore_any,
|
||||||
text::{Text, TextComponent},
|
text::{Text, TextComponent},
|
||||||
|
@ -313,6 +314,20 @@ pub enum VideoBadgeStyle {
|
||||||
BadgeStyleTypeLiveNow,
|
BadgeStyleTypeLiveNow,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Alert {
|
||||||
|
pub alert_renderer: AlertRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AlertRenderer {
|
||||||
|
#[serde_as(as = "Text")]
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
// YouTube Music
|
// YouTube Music
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
|
@ -457,3 +472,16 @@ impl IsShort for Vec<TimeOverlay> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn alerts_to_err(alerts: Option<Vec<Alert>>) -> ExtractionError {
|
||||||
|
match alerts {
|
||||||
|
Some(alerts) => ExtractionError::ContentUnavailable(
|
||||||
|
alerts
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| a.alert_renderer.text)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
),
|
||||||
|
None => ExtractionError::InvalidData("no contents".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,20 +5,24 @@ use serde_with::{DefaultOnError, VecSkipError};
|
||||||
use crate::serializer::text::{Text, TextComponent};
|
use crate::serializer::text::{Text, TextComponent};
|
||||||
use crate::serializer::{MapResult, VecLogError};
|
use crate::serializer::{MapResult, VecLogError};
|
||||||
|
|
||||||
use super::{ContentRenderer, ContentsRenderer, ThumbnailsWrap, VideoListItem};
|
use super::{Alert, ContentRenderer, ContentsRenderer, ThumbnailsWrap, VideoListItem};
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Playlist {
|
pub struct Playlist {
|
||||||
pub contents: Contents,
|
pub contents: Option<Contents>,
|
||||||
pub header: Header,
|
pub header: Option<Header>,
|
||||||
pub sidebar: Option<Sidebar>,
|
pub sidebar: Option<Sidebar>,
|
||||||
|
#[serde_as(as = "Option<DefaultOnError>")]
|
||||||
|
pub alerts: Option<Vec<Alert>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlaylistCont {
|
pub struct PlaylistCont {
|
||||||
|
#[serde(default)]
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub on_response_received_actions: Vec<OnResponseReceivedAction>,
|
pub on_response_received_actions: Vec<OnResponseReceivedAction>,
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ pub struct Search {
|
||||||
pub struct SearchCont {
|
pub struct SearchCont {
|
||||||
#[serde_as(as = "Option<JsonString>")]
|
#[serde_as(as = "Option<JsonString>")]
|
||||||
pub estimated_results: Option<u64>,
|
pub estimated_results: Option<u64>,
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub on_response_received_commands: Vec<SearchContCommand>,
|
pub on_response_received_commands: Vec<SearchContCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,8 @@ use crate::serializer::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ContinuationEndpoint, ContinuationItemRenderer, Icon, Thumbnails, VideoListItem, VideoOwner,
|
ContinuationEndpoint, ContinuationItemRenderer, Icon, MusicContinuation, Thumbnails,
|
||||||
|
VideoListItem, VideoOwner,
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -282,6 +283,8 @@ pub struct RecommendationResults {
|
||||||
/// Can be `None` for age-restricted videos
|
/// Can be `None` for age-restricted videos
|
||||||
#[serde_as(as = "Option<VecLogError<_>>")]
|
#[serde_as(as = "Option<VecLogError<_>>")]
|
||||||
pub results: Option<MapResult<Vec<VideoListItem>>>,
|
pub results: Option<MapResult<Vec<VideoListItem>>>,
|
||||||
|
#[serde_as(as = "Option<VecSkipError<_>>")]
|
||||||
|
pub continuations: Option<Vec<MusicContinuation>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The engagement panels are displayed below the video and contain chapter markers
|
/// The engagement panels are displayed below the video and contain chapter markers
|
||||||
|
@ -418,9 +421,12 @@ pub struct CommentItemSectionHeaderMenuItem {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/// Video recommendations continuation response
|
/// Video recommendations continuation response
|
||||||
|
#[serde_as]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct VideoRecommendations {
|
pub struct VideoRecommendations {
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub on_response_received_endpoints: Vec<RecommendationsContItem>,
|
pub on_response_received_endpoints: Vec<RecommendationsContItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -459,8 +465,8 @@ pub struct VideoComments {
|
||||||
/// - Comment replies: appendContinuationItemsAction
|
/// - Comment replies: appendContinuationItemsAction
|
||||||
/// - n*commentRenderer, continuationItemRenderer:
|
/// - n*commentRenderer, continuationItemRenderer:
|
||||||
/// replies + continuation
|
/// replies + continuation
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
#[serde_as(as = "Option<VecLogError<_>>")]
|
||||||
pub on_response_received_endpoints: MapResult<Vec<CommentsContItem>>,
|
pub on_response_received_endpoints: Option<MapResult<Vec<CommentsContItem>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Video comments continuation
|
/// Video comments continuation
|
||||||
|
|
|
@ -0,0 +1,783 @@
|
||||||
|
---
|
||||||
|
source: src/client/video_details.rs
|
||||||
|
expression: map_res.c
|
||||||
|
---
|
||||||
|
VideoDetails(
|
||||||
|
id: "ZeerrnuLi5E",
|
||||||
|
title: "aespa 에스파 \'Black Mamba\' MV",
|
||||||
|
description: RichText([
|
||||||
|
Text("🎧Listen and download aespa\'s debut single \"Black Mamba\": "),
|
||||||
|
Web(
|
||||||
|
text: "https://smarturl.it/aespa_BlackMamba",
|
||||||
|
url: "https://smarturl.it/aespa_BlackMamba",
|
||||||
|
),
|
||||||
|
Text("\n🐍The Debut Stage "),
|
||||||
|
Video(
|
||||||
|
text: "https://youtu.be/Ky5RT5oGg0w",
|
||||||
|
id: "Ky5RT5oGg0w",
|
||||||
|
start_time: 0,
|
||||||
|
),
|
||||||
|
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("\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("\n\naespa official\n"),
|
||||||
|
Web(
|
||||||
|
text: "https://www.youtube.com/c/aespa",
|
||||||
|
url: "https://www.youtube.com/c/aespa",
|
||||||
|
),
|
||||||
|
Text("\n"),
|
||||||
|
Web(
|
||||||
|
text: "https://www.instagram.com/aespa_official",
|
||||||
|
url: "https://www.instagram.com/aespa_official",
|
||||||
|
),
|
||||||
|
Text("\n"),
|
||||||
|
Web(
|
||||||
|
text: "https://www.tiktok.com/@aespa_official",
|
||||||
|
url: "https://www.tiktok.com/@aespa_official",
|
||||||
|
),
|
||||||
|
Text("\n"),
|
||||||
|
Web(
|
||||||
|
text: "https://twitter.com/aespa_Official",
|
||||||
|
url: "https://twitter.com/aespa_Official",
|
||||||
|
),
|
||||||
|
Text("\n"),
|
||||||
|
Web(
|
||||||
|
text: "https://www.facebook.com/aespa.official",
|
||||||
|
url: "https://www.facebook.com/aespa.official",
|
||||||
|
),
|
||||||
|
Text("\n"),
|
||||||
|
Web(
|
||||||
|
text: "https://weibo.com/aespa",
|
||||||
|
url: "https://weibo.com/aespa",
|
||||||
|
),
|
||||||
|
Text("\n\n"),
|
||||||
|
Text("#aespa"),
|
||||||
|
Text(" "),
|
||||||
|
Text("#æspa"),
|
||||||
|
Text(" "),
|
||||||
|
Text("#BlackMamba"),
|
||||||
|
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(31000000),
|
||||||
|
),
|
||||||
|
view_count: 234258725,
|
||||||
|
like_count: Some(4027586),
|
||||||
|
publish_date: "[date]",
|
||||||
|
publish_date_txt: "Nov 17, 2020",
|
||||||
|
is_live: false,
|
||||||
|
is_ccommons: false,
|
||||||
|
chapters: [],
|
||||||
|
recommended: Paginator(
|
||||||
|
count: None,
|
||||||
|
items: [
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "WPdWvnAAurg",
|
||||||
|
title: "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: 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: 218055265,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "4TWR90KJl84",
|
||||||
|
title: "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: 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: 248023999,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "uR8Mrt1IpXg",
|
||||||
|
title: "Red Velvet 레드벨벳 \'Psycho\' MV",
|
||||||
|
length: Some(216),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/uR8Mrt1IpXg/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAnAsLcZaI1uWDB4nag1KnNotAUWw",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/uR8Mrt1IpXg/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBY8Von40LZlH0BIduElAOd7YQ3KQ",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: 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: 347102621,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "UUUWIGx3hDE",
|
||||||
|
title: "ITZY \"WANNABE\" Performance Video",
|
||||||
|
length: Some(198),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/UUUWIGx3hDE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAe05b8SVKrrSU0MSOcxluyp1R_aA",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/UUUWIGx3hDE/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC6B8WyE4aYQfJrjBMKxz0H-G23Og",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: ChannelTag(
|
||||||
|
id: "UCDhM2k2Cua-JdobAh5moMFg",
|
||||||
|
name: "ITZY",
|
||||||
|
avatar: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://yt3.ggpht.com/jQq2YC6CxI82cb54SCLCVKgrL7AHhaccGr8JQcFMBagJ64URg5UNpYNmlIqQ7i7ODdSOUENjSg=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: 97453393,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "NoYKBAajoyo",
|
||||||
|
title: "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: ChannelTag(
|
||||||
|
id: "UC_pwIXKXNm5KGhdEVzmY60A",
|
||||||
|
name: "Stone Music Entertainment",
|
||||||
|
avatar: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://yt3.ggpht.com/jv3r-jNHhG2jktdZcbxgdOUqdX6Yu-AbrpS6kYpYAeoAc0nZyMB5x7jjdjoDzxmHo2Q0LZQC=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: 266364690,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "32si5cfrCNc",
|
||||||
|
title: "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: 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 years ago"),
|
||||||
|
view_count: 1254749733,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "CM4CkVFmTds",
|
||||||
|
title: "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: ChannelTag(
|
||||||
|
id: "UCaO6TYtlC8U5ttz62hTrZgg",
|
||||||
|
name: "JYP Entertainment",
|
||||||
|
avatar: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://yt3.ggpht.com/fxlLUAZQPfYiK_6B-8ZQDbT1C_o-LkTTT75RO_JZ_78SbTSrNrRHB-X7nJkUJYKUb2XOos_Tnw=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: 459831562,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "UZPZyd5vE1c",
|
||||||
|
title: "Shut Down",
|
||||||
|
length: Some(176),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/UZPZyd5vE1c/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD0elXCfbeIuNyk1C4xJkfSUZrJPg",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/UZPZyd5vE1c/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDnA-7uKZLgLXvc4DbgvpRyODNPrg",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: 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 weeks ago"),
|
||||||
|
view_count: 7118730,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "CKZvWhCqx1s",
|
||||||
|
title: "ROSÉ - \'On The Ground\' M/V",
|
||||||
|
length: Some(189),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/CKZvWhCqx1s/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC4uq8-ViYtFE0-2feawfW_IEADxg",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/CKZvWhCqx1s/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC4j67LyXvM7yBQrqhAQPrdOIExHg",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: 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("1 year ago"),
|
||||||
|
view_count: 300492226,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "fE2h3lGlOsk",
|
||||||
|
title: "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: ChannelTag(
|
||||||
|
id: "UCaO6TYtlC8U5ttz62hTrZgg",
|
||||||
|
name: "JYP Entertainment",
|
||||||
|
avatar: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://yt3.ggpht.com/fxlLUAZQPfYiK_6B-8ZQDbT1C_o-LkTTT75RO_JZ_78SbTSrNrRHB-X7nJkUJYKUb2XOos_Tnw=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: 469178299,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "Y8JFxS1HlDo",
|
||||||
|
title: "IVE 아이브 \'LOVE DIVE\' MV",
|
||||||
|
length: Some(179),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/Y8JFxS1HlDo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDf8U7fRH0R-qXbbGwKwpKBCeOa4A",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/Y8JFxS1HlDo/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDOopxOvyhTYJ-zF5yqFpEl5_W_EQ",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: ChannelTag(
|
||||||
|
id: "UCYDmx2Sfpnaxg488yBpZIGg",
|
||||||
|
name: "starshipTV",
|
||||||
|
avatar: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://yt3.ggpht.com/ytc/AMLnZu_09DwCM_6aPAyhOP_HYK1v1Jm9YdYwW1zLtBkP3w=s68-c-k-c0x00ffffff-no-rj",
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
verification: verified,
|
||||||
|
subscriber_count: None,
|
||||||
|
),
|
||||||
|
publish_date: "[date]",
|
||||||
|
publish_date_txt: Some("6 months ago"),
|
||||||
|
view_count: 161053206,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "dNCWe_6HAM8",
|
||||||
|
title: "LISA - \'MONEY\' EXCLUSIVE PERFORMANCE VIDEO",
|
||||||
|
length: Some(171),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/dNCWe_6HAM8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDdT1JD7bbEJ3z7fsQQ59tWeQUwkw",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/dNCWe_6HAM8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBTecIbmrlrTBt4sMGNPVJkHpOGtA",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: 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("1 year ago"),
|
||||||
|
view_count: 694135299,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "tyrVtwE8Gv0",
|
||||||
|
title: "NCT U 엔시티 유 \'Make A Wish (Birthday Song)\' MV",
|
||||||
|
length: Some(249),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/tyrVtwE8Gv0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDjumgWjrKFVPhKG0HyX9aEdP203g",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/tyrVtwE8Gv0/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAJDjnvc6ilNrdXRkFjThG28Dph3A",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: 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: 256797155,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "gU2HqP4NxUs",
|
||||||
|
title: "BLACKPINK - ‘Pretty Savage’ 1011 SBS Inkigayo",
|
||||||
|
length: Some(208),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/gU2HqP4NxUs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD_x0P5jlgH-Xg013D6_0HCVjmpEQ",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/gU2HqP4NxUs/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDriklJAXGJ8a0wuSkNQI3gm_JzCQ",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: 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 years ago"),
|
||||||
|
view_count: 285625201,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "Ujb-gvqsoi0",
|
||||||
|
title: "Red Velvet - IRENE & SEULGI \'Monster\' MV",
|
||||||
|
length: Some(182),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/Ujb-gvqsoi0/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBrGO-Gkm-UqCln07oFNKfFgioXYQ",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/Ujb-gvqsoi0/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDeaEGoH8CCM5osz_jfzbKzkPKHuA",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: 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: 127297352,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "KhTeiaCezwM",
|
||||||
|
title: "[MV] MAMAMOO (마마무) - HIP",
|
||||||
|
length: Some(211),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/KhTeiaCezwM/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCMGgSAC2vrBvhW5_JvAG6-DmNv_Q",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/KhTeiaCezwM/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA_AtcABVzc3_EHCbI_4rX0p5TdPg",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: ChannelTag(
|
||||||
|
id: "UCuhAUMLzJxlP1W7mEk0_6lA",
|
||||||
|
name: "MAMAMOO",
|
||||||
|
avatar: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://yt3.ggpht.com/FuZPj7lIW-I90PfZ3nij90uQCHy-KNdWr7BnDYE3F5Oh3d-2-fFeQYYzY2C3JQKSPUZNlLaTFGQ=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: 357346135,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "XJDPzNzQ3RE",
|
||||||
|
title: "Run BTS! 2022 Special Episode - Fly BTS Fly Part 1",
|
||||||
|
length: Some(2070),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/XJDPzNzQ3RE/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDLdeTJMU0EXsKD20_m1oPEHNfJig",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/XJDPzNzQ3RE/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAZE_GkmGdfjdwu47uUcLusBwNuMA",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: ChannelTag(
|
||||||
|
id: "UCLkAepWjdylmXSltofFvsYQ",
|
||||||
|
name: "BANGTANTV",
|
||||||
|
avatar: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://yt3.ggpht.com/NDWZM_aZQZJ81KRMyctZ5WYJbMIeDXLXBbAYfudK9idNpn7jIiamnj4-_3XIvCvKr1fEU7551A=s88-c-k-c0x00ffffff-no-rj",
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
verification: artist,
|
||||||
|
subscriber_count: None,
|
||||||
|
),
|
||||||
|
publish_date: "[date]",
|
||||||
|
publish_date_txt: Some("2 hours ago"),
|
||||||
|
view_count: 748983,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "0lXwMdnpoFQ",
|
||||||
|
title: "aespa 에스파 \'도깨비불 (Illusion)\' Dance Practice",
|
||||||
|
length: Some(210),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/0lXwMdnpoFQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDvTjZu5GC9ZxiNY88whzTOHX-g1Q",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/0lXwMdnpoFQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAjumVxAE37gEGnP4ch7VW_V4lyeQ",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: ChannelTag(
|
||||||
|
id: "UC473RoZQE2gtgZJ61ZW0ZDQ",
|
||||||
|
name: "SMP FLOOR",
|
||||||
|
avatar: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://yt3.ggpht.com/wzxewsUVqXfk0SxKgC-opgrfigqvCXASyD1n_dj59GjYUPa5mgvgml3-dg8JXOfoI1ZZv7OO=s68-c-k-c0x00ffffff-no-rj",
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
verification: verified,
|
||||||
|
subscriber_count: None,
|
||||||
|
),
|
||||||
|
publish_date: "[date]",
|
||||||
|
publish_date_txt: Some("3 months ago"),
|
||||||
|
view_count: 12347702,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
RecommendedVideo(
|
||||||
|
id: "IHNzOHi8sJs",
|
||||||
|
title: "BLACKPINK - ‘뚜두뚜두 (DDU-DU DDU-DU)’ M/V",
|
||||||
|
length: Some(216),
|
||||||
|
thumbnail: [
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/IHNzOHi8sJs/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCzBqBp42z958fkbmx3yCOebx3aaA",
|
||||||
|
width: 168,
|
||||||
|
height: 94,
|
||||||
|
),
|
||||||
|
Thumbnail(
|
||||||
|
url: "https://i.ytimg.com/vi/IHNzOHi8sJs/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAP9l6y4EXVpwHC4vfYvI7hVJW9DQ",
|
||||||
|
width: 336,
|
||||||
|
height: 188,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
channel: 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("4 years ago"),
|
||||||
|
view_count: 1964840790,
|
||||||
|
is_live: false,
|
||||||
|
is_short: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ctoken: Some("CBQSExILWmVlcnJudUxpNUXAAQHIAQEYACqiDDJzNkw2d3lTQ1FxUENRb0Q4ajRBQ2czQ1Bnb0l1UFdDZ09mWDFmdFlDZ1B5UGdBS0RzSS1Dd2pPcjZhVTlMN2ttdUVCQ2dQeVBnQUtFdEktRHdvTlVrUmFaV1Z5Y201MVRHazFSUW9EOGo0QUNnN0NQZ3NJLU1xaTZ1MlZ3NC01QVFvRDhqNEFDZzNDUGdvSXNZamU0NGJFeGFKUkNnUHlQZ0FLRGNJLUNnaXF4bzYxd01DQ3d6WUtBX0ktQUFvT3dqNExDTmVSckxfYzNNaTEzd0VLQV9JLUFBb053ajRLQ051Ym1ZdVYwb0RuQ0FvRDhqNEFDZzNDUGdvSTE2YTg4NTI1OXNsUkNnUHlQZ0FLRGNJLUNnamJqcXVGb2V1YjB3Z0tBX0ktQUFvTndqNEtDTW4xbEkzbHUtaW1mQW9EOGo0QUNnM0NQZ29JdXFpZTZ0SzRrZUZqQ2dQeVBnQUtEY0ktQ2dqUGdaejB2OC1sNkhRS0FfSS1BQW9Pd2o0TENQMjE4SW53dHJXVnR3RUtBX0ktQUFvT3dqNExDTXVLdF9DUDllR21nUUVLQV9JLUFBb053ajRLQ0szRXN0V3YwTC1iVWdvRDhqNEFDZzNDUGdvSWc1NzdoSnJSdDRvcUNnUHlQZ0FLRGNJLUNnaVJ1c1BtemZtenlGd0tBX0ktQUFvT3dqNExDTlRBcHMtZGh2eXEwZ0VLQV9JLUFBb053ajRLQ0p2aDhzV0g1OXk1SUFvRDhqNEFDaF9TUGh3S0dsSkVRVTk2ZFZaM1JWbDNZMUZFTkhWMmNHZEJUbU5JU0ZWM0NoX1NQaHdLR2xKRVFVOWZjQzFWYmpCSGVUbHVXRlZ0Wm1kaE0xTlNYMXAzQ2hfU1Bod0tHbEpFUVU5cFEwaENhR3R1VTBSd09HcFZWekJPUzFoWU9FMVJDaF9TUGh3S0dsSkVRVTlYWjBsd1lVbDZha1p6UVhkeE5GOXhWR2hyTlROQkNoX1NQaHdLR2xKRVFVOXFNVkpuZEhkZmVtZHJibWxSWkdkTU5XTnlVRmxCQ2hfU1Bod0tHbEpFUVU4NWExbHRhMU5KVG5CVGFYVldTalEzUjNkT1RWSm5DaF9TUGh3S0dsSkVRVTlGYjNOTlVtbHlhM1ZKTjNvNE1tSmZia0oyUjNoQkNoX1NQaHdLR2xKRVFVOW1PRlExTURaUVZGcFVWRmxDWm01RVRVNURiR0ZSQ2hfU1Bod0tHbEpFUVU5UllqSlhRWGxLYTBwMlRURmhaMGRYZEhkRkxVOUJDaF9TUGh3S0dsSkVRVTlNWDFGNk1scFJRbUZNUkROTFExTnFWalpYZG5wM0NoX1NQaHdLR2xKRVFVOXpjeTFGWVdSRFpHZzBUVmxYV0hsMGFtWkpabFYzQ2hfU1Bod0tHbEpFUVU4MVRraFVXblJGV0ROSGJIWlhRMjgyYTJOdGFrdDNDaF9TUGh3S0dsSkVRVTlMWDBjMVRVZzFaM0ZJUTNRd1VXdENZVlZJTjJwUkNoX1NQaHdLR2xKRVFVOVpUWEZhWlV4U1RXMXhaRW8zZGs5b09UQXRhME5CQ2hfU1Bod0tHbEpFUVU4eVNGbEJhMFpIYzBGSmFWVmthRE5NVUhGRE5UZG5FaFVBQWdRR0NBb01EaEFTRkJZWUdod2VJQ0lrSmlnYUJBZ0FFQUVhQkFnQ0VBTWFCQWdFRUFVYUJBZ0dFQWNhQkFnSUVBa2FCQWdLRUFzYUJBZ01FQTBhQkFnT0VBOGFCQWdRRUJFYUJBZ1NFQk1hQkFnVUVCVWFCQWdXRUJjYUJBZ1lFQmthQkFnYUVCc2FCQWdjRUIwYUJBZ2VFQjhhQkFnZ0VDRWFCQWdpRUNNYUJBZ2tFQ1VhQkFnbUVDY2FCQWdvRUNrYUJBZ29FQ29hQkFnb0VDc2FCQWdvRUN3YUJBZ29FQzBhQkFnb0VDNGFCQWdvRUM4YUJBZ29FREFhQkFnb0VERWFCQWdvRURJYUJBZ29FRE1hQkFnb0VEUWFCQWdvRURVYUJBZ29FRFlhQkFnb0VEY3FGUUFDQkFZSUNnd09FQklVRmhnYUhCNGdJaVFtS0FqD3dhdGNoLW5leHQtZmVlZA%3D%3D"),
|
||||||
|
),
|
||||||
|
top_comments: Paginator(
|
||||||
|
count: Some(705000),
|
||||||
|
items: [],
|
||||||
|
ctoken: Some("Eg0SC1plZXJybnVMaTVFGAYyJSIRIgtaZWVycm51TGk1RTAAeAJCEGNvbW1lbnRzLXNlY3Rpb24%3D"),
|
||||||
|
),
|
||||||
|
latest_comments: Paginator(
|
||||||
|
count: Some(705000),
|
||||||
|
items: [],
|
||||||
|
ctoken: Some("Eg0SC1plZXJybnVMaTVFGAYyOCIRIgtaZWVycm51TGk1RTABeAIwAUIhZW5nYWdlbWVudC1wYW5lbC1jb21tZW50cy1zZWN0aW9u"),
|
||||||
|
),
|
||||||
|
)
|
|
@ -251,7 +251,7 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
|
||||||
.secondary_results
|
.secondary_results
|
||||||
.and_then(|sr| {
|
.and_then(|sr| {
|
||||||
sr.secondary_results.results.map(|r| {
|
sr.secondary_results.results.map(|r| {
|
||||||
let mut res = map_recommendations(r, lang);
|
let mut res = map_recommendations(r, sr.secondary_results.continuations, lang);
|
||||||
warnings.append(&mut res.warnings);
|
warnings.append(&mut res.warnings);
|
||||||
res.c
|
res.c
|
||||||
})
|
})
|
||||||
|
@ -342,15 +342,11 @@ impl MapResponse<Paginator<RecommendedVideo>> for response::VideoRecommendations
|
||||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
) -> Result<MapResult<Paginator<RecommendedVideo>>, ExtractionError> {
|
) -> Result<MapResult<Paginator<RecommendedVideo>>, ExtractionError> {
|
||||||
let mut endpoints = self.on_response_received_endpoints;
|
let mut endpoints = self.on_response_received_endpoints;
|
||||||
let cont = some_or_bail!(
|
let cont = endpoints.try_swap_remove(0).ok_or(ExtractionError::Retry)?;
|
||||||
endpoints.try_swap_remove(0),
|
|
||||||
Err(ExtractionError::InvalidData(
|
|
||||||
"no continuation endpoint".into()
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(map_recommendations(
|
Ok(map_recommendations(
|
||||||
cont.append_continuation_items_action.continuation_items,
|
cont.append_continuation_items_action.continuation_items,
|
||||||
|
None,
|
||||||
lang,
|
lang,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
@ -363,57 +359,54 @@ impl MapResponse<Paginator<Comment>> for response::VideoComments {
|
||||||
lang: Language,
|
lang: Language,
|
||||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
) -> Result<MapResult<Paginator<Comment>>, ExtractionError> {
|
) -> Result<MapResult<Paginator<Comment>>, ExtractionError> {
|
||||||
let mut warnings = self.on_response_received_endpoints.warnings;
|
let received_endpoints = self
|
||||||
|
.on_response_received_endpoints
|
||||||
|
.ok_or(ExtractionError::Retry)?;
|
||||||
|
let mut warnings = received_endpoints.warnings;
|
||||||
|
|
||||||
let mut comments = Vec::new();
|
let mut comments = Vec::new();
|
||||||
let mut comment_count = None;
|
let mut comment_count = None;
|
||||||
let mut ctoken = None;
|
let mut ctoken = None;
|
||||||
|
|
||||||
self.on_response_received_endpoints
|
received_endpoints.c.into_iter().for_each(|citem| {
|
||||||
.c
|
let mut items = citem.append_continuation_items_action.continuation_items;
|
||||||
.into_iter()
|
warnings.append(&mut items.warnings);
|
||||||
.for_each(|citem| {
|
items.c.into_iter().for_each(|item| match item {
|
||||||
let mut items = citem.append_continuation_items_action.continuation_items;
|
response::video_details::CommentListItem::CommentThreadRenderer {
|
||||||
warnings.append(&mut items.warnings);
|
comment,
|
||||||
items.c.into_iter().for_each(|item| match item {
|
replies,
|
||||||
response::video_details::CommentListItem::CommentThreadRenderer {
|
rendering_priority,
|
||||||
comment,
|
} => {
|
||||||
replies,
|
let mut res = map_comment(
|
||||||
|
comment.comment_renderer,
|
||||||
|
Some(replies),
|
||||||
rendering_priority,
|
rendering_priority,
|
||||||
} => {
|
lang,
|
||||||
let mut res = map_comment(
|
);
|
||||||
comment.comment_renderer,
|
comments.push(res.c);
|
||||||
Some(replies),
|
warnings.append(&mut res.warnings)
|
||||||
rendering_priority,
|
}
|
||||||
lang,
|
response::video_details::CommentListItem::CommentRenderer(comment) => {
|
||||||
);
|
let mut res = map_comment(
|
||||||
comments.push(res.c);
|
comment,
|
||||||
warnings.append(&mut res.warnings)
|
None,
|
||||||
}
|
response::video_details::CommentPriority::RenderingPriorityUnknown,
|
||||||
response::video_details::CommentListItem::CommentRenderer(comment) => {
|
lang,
|
||||||
let mut res = map_comment(
|
);
|
||||||
comment,
|
comments.push(res.c);
|
||||||
None,
|
warnings.append(&mut res.warnings)
|
||||||
response::video_details::CommentPriority::RenderingPriorityUnknown,
|
}
|
||||||
lang,
|
response::video_details::CommentListItem::ContinuationItemRenderer {
|
||||||
);
|
continuation_endpoint,
|
||||||
comments.push(res.c);
|
} => {
|
||||||
warnings.append(&mut res.warnings)
|
ctoken = Some(continuation_endpoint.continuation_command.token);
|
||||||
}
|
}
|
||||||
response::video_details::CommentListItem::ContinuationItemRenderer {
|
response::video_details::CommentListItem::CommentsHeaderRenderer { count_text } => {
|
||||||
continuation_endpoint,
|
comment_count = count_text
|
||||||
} => {
|
.and_then(|txt| util::parse_numeric_or_warn::<u64>(&txt, &mut warnings));
|
||||||
ctoken = Some(continuation_endpoint.continuation_command.token);
|
}
|
||||||
}
|
|
||||||
response::video_details::CommentListItem::CommentsHeaderRenderer {
|
|
||||||
count_text,
|
|
||||||
} => {
|
|
||||||
comment_count = count_text.and_then(|txt| {
|
|
||||||
util::parse_numeric_or_warn::<u64>(&txt, &mut warnings)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: Paginator::new(comment_count, comments, ctoken),
|
c: Paginator::new(comment_count, comments, ctoken),
|
||||||
|
@ -424,6 +417,7 @@ impl MapResponse<Paginator<Comment>> for response::VideoComments {
|
||||||
|
|
||||||
fn map_recommendations(
|
fn map_recommendations(
|
||||||
r: MapResult<Vec<response::VideoListItem>>,
|
r: MapResult<Vec<response::VideoListItem>>,
|
||||||
|
continuations: Option<Vec<response::MusicContinuation>>,
|
||||||
lang: Language,
|
lang: Language,
|
||||||
) -> MapResult<Paginator<RecommendedVideo>> {
|
) -> MapResult<Paginator<RecommendedVideo>> {
|
||||||
let mut warnings = r.warnings;
|
let mut warnings = r.warnings;
|
||||||
|
@ -475,6 +469,12 @@ fn map_recommendations(
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if let Some(continuations) = continuations {
|
||||||
|
continuations.into_iter().for_each(|c| {
|
||||||
|
ctoken = Some(c.next_continuation_data.continuation);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
MapResult {
|
MapResult {
|
||||||
c: Paginator::new(None, items, ctoken),
|
c: Paginator::new(None, items, ctoken),
|
||||||
warnings,
|
warnings,
|
||||||
|
@ -584,8 +584,9 @@ mod tests {
|
||||||
#[case::chapters("chapters", "nFDBxBUfE74")]
|
#[case::chapters("chapters", "nFDBxBUfE74")]
|
||||||
#[case::live("live", "86YLFOog4GM")]
|
#[case::live("live", "86YLFOog4GM")]
|
||||||
#[case::agegate("agegate", "HRKu0cvrr_o")]
|
#[case::agegate("agegate", "HRKu0cvrr_o")]
|
||||||
#[case::newdesc("newdesc", "ZeerrnuLi5E")]
|
#[case::newdesc("20220924_newdesc", "ZeerrnuLi5E")]
|
||||||
fn t_map_video_details(#[case] name: &str, #[case] id: &str) {
|
#[case::new_cont("20221011_new_continuation", "ZeerrnuLi5E")]
|
||||||
|
fn map_video_details(#[case] name: &str, #[case] id: &str) {
|
||||||
let filename = format!("testfiles/video_details/video_details_{}.json", name);
|
let filename = format!("testfiles/video_details/video_details_{}.json", name);
|
||||||
let json_path = Path::new(&filename);
|
let json_path = Path::new(&filename);
|
||||||
let json_file = File::open(json_path).unwrap();
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
@ -626,10 +627,25 @@ mod tests {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_recommendations_empty() {
|
||||||
|
let filename = format!("testfiles/video_details/recommendations_empty.json");
|
||||||
|
let json_path = Path::new(&filename);
|
||||||
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
|
||||||
|
let recommendations: response::VideoRecommendations =
|
||||||
|
serde_json::from_reader(BufReader::new(json_file)).unwrap();
|
||||||
|
let err = recommendations
|
||||||
|
.map_response("", Language::En, None)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, crate::error::ExtractionError::Retry));
|
||||||
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[case::top("top")]
|
#[case::top("top")]
|
||||||
#[case::latest("latest")]
|
#[case::latest("latest")]
|
||||||
fn t_map_comments(#[case] name: &str) {
|
fn map_comments(#[case] name: &str) {
|
||||||
let filename = format!("testfiles/video_details/comments_{}.json", name);
|
let filename = format!("testfiles/video_details/comments_{}.json", name);
|
||||||
let json_path = Path::new(&filename);
|
let json_path = Path::new(&filename);
|
||||||
let json_file = File::open(json_path).unwrap();
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
|
|
@ -75,6 +75,10 @@ pub enum ExtractionError {
|
||||||
VideoUnavailable(&'static str, String),
|
VideoUnavailable(&'static str, String),
|
||||||
#[error("Video is age restricted")]
|
#[error("Video is age restricted")]
|
||||||
VideoAgeRestricted,
|
VideoAgeRestricted,
|
||||||
|
#[error("Content is not available. Reason (from YT): {0}")]
|
||||||
|
ContentUnavailable(String),
|
||||||
|
#[error("Got no data from YouTube")]
|
||||||
|
NoData,
|
||||||
#[error("deserialization error: {0}")]
|
#[error("deserialization error: {0}")]
|
||||||
Deserialization(#[from] serde_json::Error),
|
Deserialization(#[from] serde_json::Error),
|
||||||
#[error("got invalid data from YT: {0}")]
|
#[error("got invalid data from YT: {0}")]
|
||||||
|
@ -83,6 +87,8 @@ pub enum ExtractionError {
|
||||||
WrongResult(String),
|
WrongResult(String),
|
||||||
#[error("Warnings during deserialization/mapping")]
|
#[error("Warnings during deserialization/mapping")]
|
||||||
DeserializationWarnings,
|
DeserializationWarnings,
|
||||||
|
#[error("Got no data from YouTube, attempt retry")]
|
||||||
|
Retry,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal error
|
/// Internal error
|
||||||
|
|
94
testfiles/video_details/recommendations_empty.json
Normal file
94
testfiles/video_details/recommendations_empty.json
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
{
|
||||||
|
"responseContext": {
|
||||||
|
"visitorData": "CgthSmp5T24zQkRjTSiom5WaBg%3D%3D",
|
||||||
|
"serviceTrackingParams": [
|
||||||
|
{
|
||||||
|
"service": "CSI",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"key": "c",
|
||||||
|
"value": "WEB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cver",
|
||||||
|
"value": "2.20221006.09.00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "yt_li",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "GetWatchNext_rid",
|
||||||
|
"value": "0x8836d1dc393da349"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "GFEEDBACK",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"key": "logged_in",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "e",
|
||||||
|
"value": "1714258,23804281,23882503,23885487,23918597,23934970,23940248,23946420,23966208,23983296,23986022,23998056,24001373,24002022,24002025,24004644,24007246,24034168,24036948,24077241,24080738,24108448,24120820,24135310,24140247,24152443,24161116,24162920,24164186,24166867,24169501,24181174,24185614,24187043,24187377,24191629,24197450,24199724,24199774,24211178,24217535,24219713,24223903,24224266,24225483,24226335,24227844,24228638,24229161,24241378,24243988,24248092,24248385,24254502,24255543,24255545,24256985,24259938,24260783,24262346,24263796,24265820,24267564,24267570,24268142,24268812,24268870,24278546,24278596,24279196,24279628,24279727,24280997,24281835,24282957,24283093,24283280,24286003,24286019,24287326,24287795,24288045,24289478,24289901,24289939,24290131,24290276,24290971,24292296,24295099,24295740,24297099,24298640,24298651,24298795,24299688,24299747,24390674,24391537,24392058,24392269,24394618,24590921,39322278,39322399,39322505"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "GUIDED_HELP",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"key": "logged_in",
|
||||||
|
"value": "0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "ECATCHER",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"key": "client.version",
|
||||||
|
"value": "2.20221006"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "client.name",
|
||||||
|
"value": "WEB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "client.fexp",
|
||||||
|
"value": "24286003,24298795,24256985,23804281,24001373,23946420,24283280,24289478,24223903,24298651,24286019,23885487,24077241,24265820,23918597,24255545,24036948,24259938,24279196,24199774,24282957,24279628,24268812,24169501,24225483,24590921,24197450,24298640,39322399,24290971,24108448,24287326,24219713,24278596,24002022,24181174,24227844,24287795,24229161,24283093,24162920,24248092,24241378,24166867,24002025,24280997,24391537,24278546,24288045,24034168,24290131,24211178,24289901,24226335,24268870,24295099,24135310,24191629,24394618,24007246,24004644,24243988,24281835,24392058,23998056,24185614,24262346,24187043,24224266,23986022,24228638,23934970,39322278,24292296,24260783,23940248,24263796,24267564,24299688,24390674,24152443,23966208,24267570,24080738,24290276,24217535,23882503,24279727,24164186,24289939,24187377,24268142,24120820,24199724,39322505,24392269,24254502,24255543,24299747,24161116,24140247,1714258,24297099,23983296,24295740,24248385"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mainAppWebResponseContext": {
|
||||||
|
"loggedOut": true
|
||||||
|
},
|
||||||
|
"webResponseContextExtensionData": {
|
||||||
|
"hasDecorated": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackingParams": "CAAQg2ciEwjfruPhg9j6AhXW2BEIHd9wAwc=",
|
||||||
|
"engagementPanels": [
|
||||||
|
{
|
||||||
|
"engagementPanelSectionListRenderer": {
|
||||||
|
"content": {
|
||||||
|
"adsEngagementPanelContentRenderer": {
|
||||||
|
"hack": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targetId": "engagement-panel-ads",
|
||||||
|
"visibility": "ENGAGEMENT_PANEL_VISIBILITY_HIDDEN",
|
||||||
|
"loggingDirectives": {
|
||||||
|
"trackingParams": "CAEQ040EGAAiEwjfruPhg9j6AhXW2BEIHd9wAwc=",
|
||||||
|
"visibility": {
|
||||||
|
"types": "12"
|
||||||
|
},
|
||||||
|
"enableDisplayloggerExperiment": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
11306
testfiles/video_details/video_details_20221011_new_continuation.json
Normal file
11306
testfiles/video_details/video_details_20221011_new_continuation.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -2,6 +2,7 @@ use chrono::{Datelike, Timelike};
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
|
|
||||||
use rustypipe::client::{ClientType, RustyPipe};
|
use rustypipe::client::{ClientType, RustyPipe};
|
||||||
|
use rustypipe::error::{Error, ExtractionError};
|
||||||
use rustypipe::model::richtext::ToPlaintext;
|
use rustypipe::model::richtext::ToPlaintext;
|
||||||
use rustypipe::model::{
|
use rustypipe::model::{
|
||||||
AudioCodec, AudioFormat, Channel, SearchItem, Verification, VideoCodec, VideoFormat,
|
AudioCodec, AudioFormat, Channel, SearchItem, Verification, VideoCodec, VideoFormat,
|
||||||
|
@ -18,7 +19,7 @@ use rustypipe::param::{
|
||||||
#[case::tv_html5_embed(ClientType::TvHtml5Embed)]
|
#[case::tv_html5_embed(ClientType::TvHtml5Embed)]
|
||||||
#[case::android(ClientType::Android)]
|
#[case::android(ClientType::Android)]
|
||||||
#[case::ios(ClientType::Ios)]
|
#[case::ios(ClientType::Ios)]
|
||||||
#[test_log::test(tokio::test)]
|
#[tokio::test]
|
||||||
async fn get_player(#[case] client_type: ClientType) {
|
async fn get_player(#[case] client_type: ClientType) {
|
||||||
let rp = RustyPipe::builder().strict().build();
|
let rp = RustyPipe::builder().strict().build();
|
||||||
let player_data = rp.query().player("n4tK7LYFxI0", client_type).await.unwrap();
|
let player_data = rp.query().player("n4tK7LYFxI0", client_type).await.unwrap();
|
||||||
|
@ -178,7 +179,7 @@ async fn get_playlist(
|
||||||
assert!(!playlist.thumbnail.is_empty());
|
assert!(!playlist.thumbnail.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test_log::test(tokio::test)]
|
#[tokio::test]
|
||||||
async fn playlist_cont() {
|
async fn playlist_cont() {
|
||||||
let rp = RustyPipe::builder().strict().build();
|
let rp = RustyPipe::builder().strict().build();
|
||||||
let mut playlist = rp
|
let mut playlist = rp
|
||||||
|
@ -196,7 +197,7 @@ async fn playlist_cont() {
|
||||||
assert!(playlist.videos.count.unwrap() > 100);
|
assert!(playlist.videos.count.unwrap() > 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test_log::test(tokio::test)]
|
#[tokio::test]
|
||||||
async fn playlist_cont2() {
|
async fn playlist_cont2() {
|
||||||
let rp = RustyPipe::builder().strict().build();
|
let rp = RustyPipe::builder().strict().build();
|
||||||
let mut playlist = rp
|
let mut playlist = rp
|
||||||
|
@ -210,6 +211,21 @@ async fn playlist_cont2() {
|
||||||
assert!(playlist.videos.count.unwrap() > 100);
|
assert!(playlist.videos.count.unwrap() > 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn playlist_not_found() {
|
||||||
|
let rp = RustyPipe::builder().strict().build();
|
||||||
|
let err = rp
|
||||||
|
.query()
|
||||||
|
.playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qz")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
Error::Extraction(ExtractionError::ContentUnavailable(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
//#VIDEO DETAILS
|
//#VIDEO DETAILS
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
@ -310,7 +326,6 @@ async fn get_video_details_music() {
|
||||||
assert!(!details.is_live);
|
assert!(!details.is_live);
|
||||||
assert!(!details.is_ccommons);
|
assert!(!details.is_ccommons);
|
||||||
|
|
||||||
assert!(!details.recommended.items.is_empty());
|
|
||||||
assert!(!details.recommended.is_exhausted());
|
assert!(!details.recommended.is_exhausted());
|
||||||
|
|
||||||
// Comments are disabled for this video
|
// Comments are disabled for this video
|
||||||
|
@ -368,7 +383,6 @@ async fn get_video_details_ccommons() {
|
||||||
assert!(!details.is_live);
|
assert!(!details.is_live);
|
||||||
assert!(details.is_ccommons);
|
assert!(details.is_ccommons);
|
||||||
|
|
||||||
assert!(!details.recommended.items.is_empty());
|
|
||||||
assert!(!details.recommended.is_exhausted());
|
assert!(!details.recommended.is_exhausted());
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
@ -505,7 +519,6 @@ async fn get_video_details_chapters() {
|
||||||
]
|
]
|
||||||
"###);
|
"###);
|
||||||
|
|
||||||
assert!(!details.recommended.items.is_empty());
|
|
||||||
assert!(!details.recommended.is_exhausted());
|
assert!(!details.recommended.is_exhausted());
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
@ -565,7 +578,6 @@ async fn get_video_details_live() {
|
||||||
assert!(details.is_live);
|
assert!(details.is_live);
|
||||||
assert!(!details.is_ccommons);
|
assert!(!details.is_ccommons);
|
||||||
|
|
||||||
assert!(!details.recommended.items.is_empty());
|
|
||||||
assert!(!details.recommended.is_exhausted());
|
assert!(!details.recommended.is_exhausted());
|
||||||
|
|
||||||
// No comments because livestream
|
// No comments because livestream
|
||||||
|
@ -857,6 +869,24 @@ async fn channel_more(
|
||||||
assert_channel(&channel_info, id, name);
|
assert_channel(&channel_info, id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case::gaming("UCOpNcN46UbXVtpKMrmU4Abg", false)]
|
||||||
|
#[case::not_found("UCOpNcN46UbXVtpKMrmU4Abx", true)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn channel_error(#[case] id: &str, #[case] not_found: bool) {
|
||||||
|
let rp = RustyPipe::builder().strict().build();
|
||||||
|
let err = rp.query().channel_videos(&id).await.unwrap_err();
|
||||||
|
|
||||||
|
if not_found {
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
Error::Extraction(ExtractionError::ContentUnavailable(_))
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
assert!(matches!(err, Error::Extraction(ExtractionError::NoData)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//#CHANNEL_RSS
|
//#CHANNEL_RSS
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
Loading…
Add table
Reference in a new issue