Compare commits
7 commits
df2952729a
...
3596861b77
Author | SHA1 | Date | |
---|---|---|---|
3596861b77 | |||
584d6aa3f5 | |||
8c1e7bf6ac | |||
e800e16c68 | |||
df6543d62e | |||
94c9a264a4 | |||
230b027b59 |
23 changed files with 66997 additions and 7973 deletions
|
@ -26,7 +26,6 @@ quick-js = { path = "../quickjs-rs" }
|
||||||
once_cell = "1.12.0"
|
once_cell = "1.12.0"
|
||||||
fancy-regex = "0.10.0"
|
fancy-regex = "0.10.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
thiserror = "1.0.31"
|
|
||||||
url = "2.2.2"
|
url = "2.2.2"
|
||||||
log = "0.4.17"
|
log = "0.4.17"
|
||||||
reqwest = {version = "0.11.11", default-features = false, features = ["json", "gzip", "brotli", "stream"]}
|
reqwest = {version = "0.11.11", default-features = false, features = ["json", "gzip", "brotli", "stream"]}
|
||||||
|
|
4
Justfile
4
Justfile
|
@ -1,3 +1,3 @@
|
||||||
report2yaml:
|
report2yaml:
|
||||||
yq e -Pi rustypipe_reports/*.json
|
mkdir -p rustypipe_reports/conv
|
||||||
for f in rustypipe_reports/*.json; do mv $f rustypipe_reports/`basename $f .json`.yaml; done;
|
for f in rustypipe_reports/*.json; do yq '.http_request.resp_body' $f | yq -o json -P > rustypipe_reports/conv/`basename $f .json`_body.json; yq e -Pi $f; mv $f rustypipe_reports/conv/`basename $f .json`.yaml; done;
|
||||||
|
|
|
@ -149,12 +149,17 @@ async fn download_playlist(
|
||||||
.expect("unable to build the HTTP client");
|
.expect("unable to build the HTTP client");
|
||||||
|
|
||||||
let rp = RustyPipe::default();
|
let rp = RustyPipe::default();
|
||||||
let playlist = rp.query().playlist(id).await.unwrap();
|
let mut playlist = rp.query().playlist(id).await.unwrap();
|
||||||
|
playlist
|
||||||
|
.videos
|
||||||
|
.extend_pages(rp.query(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Indicatif setup
|
// Indicatif setup
|
||||||
let multi = MultiProgress::new();
|
let multi = MultiProgress::new();
|
||||||
let main = multi.add(ProgressBar::new(
|
let main = multi.add(ProgressBar::new(
|
||||||
playlist.videos.len().try_into().unwrap_or_default(),
|
playlist.videos.items.len().try_into().unwrap_or_default(),
|
||||||
));
|
));
|
||||||
|
|
||||||
main.set_style(
|
main.set_style(
|
||||||
|
@ -165,11 +170,11 @@ async fn download_playlist(
|
||||||
);
|
);
|
||||||
main.tick();
|
main.tick();
|
||||||
|
|
||||||
stream::iter(playlist.videos)
|
stream::iter(playlist.videos.items)
|
||||||
.map(|video| {
|
.map(|video| {
|
||||||
download_single_video(
|
download_single_video(
|
||||||
video.id.to_owned(),
|
video.id,
|
||||||
video.title.to_owned(),
|
video.title,
|
||||||
output_dir,
|
output_dir,
|
||||||
output_fname.to_owned(),
|
output_fname.to_owned(),
|
||||||
resolution,
|
resolution,
|
||||||
|
|
|
@ -17,6 +17,7 @@ pub async fn download_testfiles(project_root: &Path) {
|
||||||
player_model(&testfiles),
|
player_model(&testfiles),
|
||||||
playlist(&testfiles),
|
playlist(&testfiles),
|
||||||
video_details(&testfiles),
|
video_details(&testfiles),
|
||||||
|
comments_top(&testfiles),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,18 +121,18 @@ async fn playlist(testfiles: &Path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn video_details(testfiles: &Path) {
|
async fn video_details(testfiles: &Path) {
|
||||||
for (name, id) in [
|
for (name, id) in [
|
||||||
("music", "MZOgTu2dMTg"),
|
("music", "XuM2onMGvTI"),
|
||||||
("mv", "ZeerrnuLi5E"),
|
("mv", "ZeerrnuLi5E"),
|
||||||
("ccommons", "0rb9CfOvojk"),
|
("ccommons", "0rb9CfOvojk"),
|
||||||
("chapters", "nFDBxBUfE74"),
|
("chapters", "nFDBxBUfE74"),
|
||||||
|
("agegate", "XuM2onMGvTI"),
|
||||||
|
("live", "86YLFOog4GM"),
|
||||||
] {
|
] {
|
||||||
let mut json_path = testfiles.to_path_buf();
|
let mut json_path = testfiles.to_path_buf();
|
||||||
json_path.push("video_details");
|
json_path.push("video_details");
|
||||||
json_path.push(format!("video_details_{}.json", name));
|
json_path.push(format!("video_details_{}.json", name));
|
||||||
println!("{}", json_path.display());
|
|
||||||
if json_path.exists() {
|
if json_path.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -141,7 +142,20 @@ async fn video_details(testfiles: &Path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
async fn comments_top(testfiles: &Path) {
|
||||||
async fn x() {
|
let mut json_path = testfiles.to_path_buf();
|
||||||
video_details(Path::new("../testfiles")).await;
|
json_path.push("video_details");
|
||||||
|
json_path.push(format!("comments_top.json"));
|
||||||
|
if json_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rp = RustyPipe::new();
|
||||||
|
let details = rp.query().video_details("ZeerrnuLi5E").await.unwrap();
|
||||||
|
|
||||||
|
let rp = rp_testfile(&json_path);
|
||||||
|
rp.query()
|
||||||
|
.video_comments(&details.top_comments.ctoken.unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
11609
notes/next/next_comments_by_artist.json
Normal file
11609
notes/next/next_comments_by_artist.json
Normal file
File diff suppressed because it is too large
Load diff
|
@ -18,7 +18,10 @@ DRM: 1bfOsni7EgI
|
||||||
|
|
||||||
Album with unknown artists: https://music.youtube.com/playlist?list=OLAK5uy_mEX9ljZeeEWgTM1xLL1isyiGaWXoPyoOk
|
Album with unknown artists: https://music.youtube.com/playlist?list=OLAK5uy_mEX9ljZeeEWgTM1xLL1isyiGaWXoPyoOk
|
||||||
|
|
||||||
Throttling issue: Y8JFxS1HlDo
|
Comment by artist: 3pv_rHKnwAs
|
||||||
|
|
||||||
|
Comments disabled: XuM2onMGvTI
|
||||||
|
Likes hidden:
|
||||||
|
|
||||||
# Playlists
|
# Playlists
|
||||||
962 Songs: PL4lEESSgxM_5O81EvKCmBIm_JT5Q7JeaI
|
962 Songs: PL4lEESSgxM_5O81EvKCmBIm_JT5Q7JeaI
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
@ -5,8 +7,8 @@ use serde::Serialize;
|
||||||
use crate::{
|
use crate::{
|
||||||
deobfuscate::Deobfuscator,
|
deobfuscate::Deobfuscator,
|
||||||
model::{ChannelId, Language, Paginator, Playlist, PlaylistVideo},
|
model::{ChannelId, Language, Paginator, Playlist, PlaylistVideo},
|
||||||
serializer::text::{PageType, TextLink},
|
timeago,
|
||||||
timeago, util,
|
util::{self, TryRemove},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{response, ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext};
|
use super::{response, ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext};
|
||||||
|
@ -44,7 +46,7 @@ impl RustyPipeQuery {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_playlist_continuation(self, ctoken: &str) -> Result<Paginator<PlaylistVideo>> {
|
pub async fn playlist_continuation(self, ctoken: &str) -> Result<Paginator<PlaylistVideo>> {
|
||||||
let context = self.get_context(ClientType::Desktop, true).await;
|
let context = self.get_context(ClientType::Desktop, true).await;
|
||||||
let request_body = QPlaylistCont {
|
let request_body = QPlaylistCont {
|
||||||
context,
|
context,
|
||||||
|
@ -73,25 +75,21 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
// TODO: think about a deserializer that deserializes only first list item
|
// TODO: think about a deserializer that deserializes only first list item
|
||||||
let mut tcbr_contents = self.contents.two_column_browse_results_renderer.contents;
|
let mut tcbr_contents = self.contents.two_column_browse_results_renderer.contents;
|
||||||
let video_items = some_or_bail!(
|
let video_items = some_or_bail!(
|
||||||
util::vec_try_swap_remove(
|
some_or_bail!(
|
||||||
&mut some_or_bail!(
|
some_or_bail!(
|
||||||
util::vec_try_swap_remove(
|
tcbr_contents.try_swap_remove(0),
|
||||||
&mut some_or_bail!(
|
|
||||||
util::vec_try_swap_remove(&mut tcbr_contents, 0),
|
|
||||||
Err(anyhow!("twoColumnBrowseResultsRenderer empty"))
|
Err(anyhow!("twoColumnBrowseResultsRenderer empty"))
|
||||||
)
|
)
|
||||||
.tab_renderer
|
.tab_renderer
|
||||||
.content
|
.content
|
||||||
.section_list_renderer
|
.section_list_renderer
|
||||||
.contents,
|
.contents
|
||||||
0,
|
.try_swap_remove(0),
|
||||||
),
|
|
||||||
Err(anyhow!("sectionListRenderer empty"))
|
Err(anyhow!("sectionListRenderer empty"))
|
||||||
)
|
)
|
||||||
.item_section_renderer
|
.item_section_renderer
|
||||||
.contents,
|
.contents
|
||||||
0
|
.try_swap_remove(0),
|
||||||
),
|
|
||||||
Err(anyhow!("itemSectionRenderer empty"))
|
Err(anyhow!("itemSectionRenderer empty"))
|
||||||
)
|
)
|
||||||
.playlist_video_list_renderer
|
.playlist_video_list_renderer
|
||||||
|
@ -103,7 +101,7 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
Some(sidebar) => {
|
Some(sidebar) => {
|
||||||
let mut sidebar_items = sidebar.playlist_sidebar_renderer.items;
|
let mut sidebar_items = sidebar.playlist_sidebar_renderer.items;
|
||||||
let mut primary = some_or_bail!(
|
let mut primary = some_or_bail!(
|
||||||
util::vec_try_swap_remove(&mut sidebar_items, 0),
|
sidebar_items.try_swap_remove(0),
|
||||||
Err(anyhow!("no primary sidebar"))
|
Err(anyhow!("no primary sidebar"))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -113,10 +111,10 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
.thumbnail_renderer
|
.thumbnail_renderer
|
||||||
.playlist_video_thumbnail_renderer
|
.playlist_video_thumbnail_renderer
|
||||||
.thumbnail,
|
.thumbnail,
|
||||||
util::vec_try_swap_remove(
|
primary
|
||||||
&mut primary.playlist_sidebar_primary_info_renderer.stats,
|
.playlist_sidebar_primary_info_renderer
|
||||||
2,
|
.stats
|
||||||
),
|
.try_swap_remove(2),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
@ -126,7 +124,8 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut byline = self.header.playlist_header_renderer.byline;
|
let mut byline = self.header.playlist_header_renderer.byline;
|
||||||
let last_update_txt = util::vec_try_swap_remove(&mut byline, 1)
|
let last_update_txt = byline
|
||||||
|
.try_swap_remove(1)
|
||||||
.map(|b| b.playlist_byline_renderer.text);
|
.map(|b| b.playlist_byline_renderer.text);
|
||||||
|
|
||||||
(
|
(
|
||||||
|
@ -153,41 +152,28 @@ impl MapResponse<Playlist> for response::Playlist {
|
||||||
|
|
||||||
let name = self.header.playlist_header_renderer.title;
|
let name = self.header.playlist_header_renderer.title;
|
||||||
let description = self.header.playlist_header_renderer.description_text;
|
let description = self.header.playlist_header_renderer.description_text;
|
||||||
|
let channel = self
|
||||||
let channel = match self.header.playlist_header_renderer.owner_text {
|
.header
|
||||||
Some(TextLink::Browse {
|
.playlist_header_renderer
|
||||||
text,
|
.owner_text
|
||||||
page_type: PageType::Channel,
|
.and_then(|link| ChannelId::try_from(link).ok());
|
||||||
browse_id,
|
|
||||||
}) => Some(ChannelId {
|
|
||||||
id: browse_id,
|
|
||||||
name: text,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut warnings = video_items.warnings;
|
let mut warnings = video_items.warnings;
|
||||||
let last_update = match &last_update_txt {
|
let last_update = last_update_txt
|
||||||
Some(textual_date) => {
|
.as_ref()
|
||||||
let parsed = timeago::parse_textual_date_to_dt(lang, textual_date);
|
.and_then(|txt| timeago::parse_textual_date_or_warn(lang, txt, &mut warnings));
|
||||||
if parsed.is_none() {
|
|
||||||
warnings.push(format!("could not parse textual date `{}`", textual_date));
|
|
||||||
}
|
|
||||||
parsed
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: Playlist {
|
c: Playlist {
|
||||||
id: playlist_id,
|
id: playlist_id,
|
||||||
name,
|
name,
|
||||||
videos: Paginator {
|
videos: Paginator {
|
||||||
|
count: Some(n_videos),
|
||||||
items: videos,
|
items: videos,
|
||||||
ctoken,
|
ctoken,
|
||||||
},
|
},
|
||||||
n_videos,
|
video_count: n_videos,
|
||||||
thumbnails: thumbnails.into(),
|
thumbnail: thumbnails.into(),
|
||||||
description,
|
description,
|
||||||
channel,
|
channel,
|
||||||
last_update,
|
last_update,
|
||||||
|
@ -207,7 +193,7 @@ impl MapResponse<Paginator<PlaylistVideo>> for response::PlaylistCont {
|
||||||
) -> Result<MapResult<Paginator<PlaylistVideo>>> {
|
) -> Result<MapResult<Paginator<PlaylistVideo>>> {
|
||||||
let mut actions = self.on_response_received_actions;
|
let mut actions = self.on_response_received_actions;
|
||||||
let action = some_or_bail!(
|
let action = some_or_bail!(
|
||||||
util::vec_try_swap_remove(&mut actions, 0),
|
actions.try_swap_remove(0),
|
||||||
Err(anyhow!("no continuation action"))
|
Err(anyhow!("no continuation action"))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -215,7 +201,11 @@ impl MapResponse<Paginator<PlaylistVideo>> for response::PlaylistCont {
|
||||||
map_playlist_items(action.append_continuation_items_action.continuation_items.c);
|
map_playlist_items(action.append_continuation_items_action.continuation_items.c);
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: Paginator { items, ctoken },
|
c: Paginator {
|
||||||
|
count: None,
|
||||||
|
items,
|
||||||
|
ctoken,
|
||||||
|
},
|
||||||
warnings: action
|
warnings: action
|
||||||
.append_continuation_items_action
|
.append_continuation_items_action
|
||||||
.continuation_items
|
.continuation_items
|
||||||
|
@ -231,23 +221,18 @@ fn map_playlist_items(
|
||||||
let videos = items
|
let videos = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|it| match it {
|
.filter_map(|it| match it {
|
||||||
response::VideoListItem::GridVideoRenderer { video } => match video.channel {
|
response::VideoListItem::GridVideoRenderer { video } => {
|
||||||
TextLink::Browse {
|
match ChannelId::try_from(video.channel) {
|
||||||
text,
|
Ok(channel) => Some(PlaylistVideo {
|
||||||
page_type: PageType::Channel,
|
|
||||||
browse_id,
|
|
||||||
} => Some(PlaylistVideo {
|
|
||||||
id: video.video_id,
|
id: video.video_id,
|
||||||
title: video.title,
|
title: video.title,
|
||||||
length: video.length_seconds,
|
length: video.length_seconds,
|
||||||
thumbnails: video.thumbnail.into(),
|
thumbnail: video.thumbnail.into(),
|
||||||
channel: ChannelId {
|
channel,
|
||||||
id: browse_id,
|
|
||||||
name: text,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
_ => None,
|
Err(_) => None,
|
||||||
},
|
}
|
||||||
|
}
|
||||||
response::VideoListItem::ContinuationItemRenderer {
|
response::VideoListItem::ContinuationItemRenderer {
|
||||||
continuation_endpoint,
|
continuation_endpoint,
|
||||||
} => {
|
} => {
|
||||||
|
@ -261,21 +246,22 @@ fn map_playlist_items(
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Paginator<PlaylistVideo> {
|
impl Paginator<PlaylistVideo> {
|
||||||
pub async fn next(&self, query: RustyPipeQuery) -> Result<Self, crate::error::Error> {
|
pub async fn next(&self, query: RustyPipeQuery) -> Result<Option<Self>> {
|
||||||
match &self.ctoken {
|
Ok(match &self.ctoken {
|
||||||
Some(ctoken) => Ok(query.get_playlist_continuation(ctoken).await?),
|
Some(ctoken) => Some(query.playlist_continuation(ctoken).await?),
|
||||||
None => Err(crate::error::Error::PaginatorExhausted),
|
None => None,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn extend(&mut self, query: RustyPipeQuery) -> Result<(), crate::error::Error> {
|
pub async fn extend(&mut self, query: RustyPipeQuery) -> Result<bool> {
|
||||||
match self.next(query).await {
|
match self.next(query).await {
|
||||||
Ok(paginator) => {
|
Ok(Some(paginator)) => {
|
||||||
let mut items = paginator.items;
|
let mut items = paginator.items;
|
||||||
self.items.append(&mut items);
|
self.items.append(&mut items);
|
||||||
self.ctoken = paginator.ctoken;
|
self.ctoken = paginator.ctoken;
|
||||||
Ok(())
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
Ok(None) => Ok(false),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -283,12 +269,8 @@ impl Paginator<PlaylistVideo> {
|
||||||
pub async fn extend_pages(&mut self, query: RustyPipeQuery, n_pages: usize) -> Result<()> {
|
pub async fn extend_pages(&mut self, query: RustyPipeQuery, n_pages: usize) -> Result<()> {
|
||||||
for _ in 0..n_pages {
|
for _ in 0..n_pages {
|
||||||
match self.extend(query.clone()).await {
|
match self.extend(query.clone()).await {
|
||||||
Err(crate::error::Error::PaginatorExhausted) => {
|
Ok(false) => break,
|
||||||
break;
|
Err(e) => return Err(e),
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -298,12 +280,8 @@ impl Paginator<PlaylistVideo> {
|
||||||
pub async fn extend_limit(&mut self, query: RustyPipeQuery, n_items: usize) -> Result<()> {
|
pub async fn extend_limit(&mut self, query: RustyPipeQuery, n_items: usize) -> Result<()> {
|
||||||
while self.items.len() < n_items {
|
while self.items.len() < n_items {
|
||||||
match self.extend(query.clone()).await {
|
match self.extend(query.clone()).await {
|
||||||
Err(crate::error::Error::PaginatorExhausted) => {
|
Ok(false) => break,
|
||||||
break;
|
Err(e) => return Err(e),
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -364,13 +342,13 @@ mod tests {
|
||||||
assert_eq!(playlist.name, name);
|
assert_eq!(playlist.name, name);
|
||||||
assert!(!playlist.videos.is_empty());
|
assert!(!playlist.videos.is_empty());
|
||||||
assert_eq!(!playlist.videos.is_exhausted(), is_long);
|
assert_eq!(!playlist.videos.is_exhausted(), is_long);
|
||||||
assert!(playlist.n_videos > 10);
|
assert!(playlist.video_count > 10);
|
||||||
assert_eq!(playlist.n_videos > 100, is_long);
|
assert_eq!(playlist.video_count > 100, is_long);
|
||||||
assert_eq!(playlist.description, description);
|
assert_eq!(playlist.description, description);
|
||||||
if channel.is_some() {
|
if channel.is_some() {
|
||||||
assert_eq!(playlist.channel, channel);
|
assert_eq!(playlist.channel, channel);
|
||||||
}
|
}
|
||||||
assert!(!playlist.thumbnails.is_empty());
|
assert!(!playlist.thumbnail.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
|
@ -411,6 +389,7 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(playlist.videos.items.len() > 100);
|
assert!(playlist.videos.items.len() > 100);
|
||||||
|
assert!(playlist.videos.count.unwrap() > 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test_log::test(tokio::test)]
|
#[test_log::test(tokio::test)]
|
||||||
|
@ -422,11 +401,8 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
playlist
|
playlist.videos.extend_limit(rp.query(), 101).await.unwrap();
|
||||||
.videos
|
|
||||||
.extend_limit(rp.query(), 101)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(playlist.videos.items.len() > 100);
|
assert!(playlist.videos.items.len() > 100);
|
||||||
|
assert!(playlist.videos.count.unwrap() > 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,10 +68,19 @@ pub enum VideoListItem<T> {
|
||||||
continuation_endpoint: ContinuationEndpoint,
|
continuation_endpoint: ContinuationEndpoint,
|
||||||
},
|
},
|
||||||
/// No video list item (e.g. ad)
|
/// No video list item (e.g. ad)
|
||||||
|
///
|
||||||
|
/// Note that there are sometimes playlists among the recommended
|
||||||
|
/// videos. They are currently ignored.
|
||||||
#[serde(other, deserialize_with = "ignore_any")]
|
#[serde(other, deserialize_with = "ignore_any")]
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ContinuationItemRenderer {
|
||||||
|
pub continuation_endpoint: ContinuationEndpoint,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ContinuationEndpoint {
|
pub struct ContinuationEndpoint {
|
||||||
|
@ -84,10 +93,22 @@ pub struct ContinuationCommand {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Icon {
|
pub struct Icon {
|
||||||
pub icon_type: String,
|
pub icon_type: IconType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum IconType {
|
||||||
|
/// Checkmark for verified channels
|
||||||
|
Check,
|
||||||
|
/// Music note for verified artists
|
||||||
|
OfficialArtistBadge,
|
||||||
|
/// Like button
|
||||||
|
Like,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
@ -107,24 +128,24 @@ pub struct VideoOwnerRenderer {
|
||||||
pub subscriber_count_text: Option<String>,
|
pub subscriber_count_text: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub badges: Vec<UserBadge>,
|
pub badges: Vec<ChannelBadge>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserBadge {
|
pub struct ChannelBadge {
|
||||||
pub metadata_badge_renderer: UserBadgeRenderer,
|
pub metadata_badge_renderer: ChannelBadgeRenderer,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserBadgeRenderer {
|
pub struct ChannelBadgeRenderer {
|
||||||
pub style: UserBadgeStyle,
|
pub style: ChannelBadgeStyle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
|
||||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
pub enum UserBadgeStyle {
|
pub enum ChannelBadgeStyle {
|
||||||
BadgeStyleTypeVerified,
|
BadgeStyleTypeVerified,
|
||||||
BadgeStyleTypeVerifiedArtist,
|
BadgeStyleTypeVerifiedArtist,
|
||||||
}
|
}
|
||||||
|
@ -155,6 +176,29 @@ pub enum TimeOverlayStyle {
|
||||||
Shorts,
|
Shorts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Badges are displayed on the video thumbnail and
|
||||||
|
/// show certain video properties (e.g. active livestream)
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct VideoBadge {
|
||||||
|
pub metadata_badge_renderer: VideoBadgeRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Badges are displayed on the video thumbnail and
|
||||||
|
/// show certain video properties (e.g. active livestream)
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct VideoBadgeRenderer {
|
||||||
|
pub style: VideoBadgeStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum VideoBadgeStyle {
|
||||||
|
/// Active livestream
|
||||||
|
BadgeStyleTypeLiveNow,
|
||||||
|
}
|
||||||
|
|
||||||
// YouTube Music
|
// YouTube Music
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
|
@ -247,3 +291,36 @@ impl From<Thumbnails> for Vec<crate::model::Thumbnail> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<Vec<ChannelBadge>> for crate::model::Verification {
|
||||||
|
fn from(badges: Vec<ChannelBadge>) -> Self {
|
||||||
|
badges.get(0).map_or(crate::model::Verification::None, |b| {
|
||||||
|
match b.metadata_badge_renderer.style {
|
||||||
|
ChannelBadgeStyle::BadgeStyleTypeVerified => Self::Verified,
|
||||||
|
ChannelBadgeStyle::BadgeStyleTypeVerifiedArtist => Self::Artist,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Icon> for crate::model::Verification {
|
||||||
|
fn from(icon: Icon) -> Self {
|
||||||
|
match icon.icon_type {
|
||||||
|
IconType::Check => Self::Verified,
|
||||||
|
IconType::OfficialArtistBadge => Self::Artist,
|
||||||
|
_ => Self::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IsLive {
|
||||||
|
fn is_live(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IsLive for Vec<VideoBadge> {
|
||||||
|
fn is_live(&self) -> bool {
|
||||||
|
self.iter().any(|badge| {
|
||||||
|
badge.metadata_badge_renderer.style == VideoBadgeStyle::BadgeStyleTypeLiveNow
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -11,7 +11,10 @@ use crate::serializer::{
|
||||||
VecLogError,
|
VecLogError,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ContentsRenderer, ContinuationEndpoint, Icon, Thumbnails, VideoListItem, VideoOwner};
|
use super::{
|
||||||
|
ChannelBadge, ContentsRenderer, ContinuationEndpoint, ContinuationItemRenderer, Icon,
|
||||||
|
Thumbnails, VideoBadge, VideoListItem, VideoOwner,
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
#VIDEO DETAILS
|
#VIDEO DETAILS
|
||||||
|
@ -24,6 +27,8 @@ use super::{ContentsRenderer, ContinuationEndpoint, Icon, Thumbnails, VideoListI
|
||||||
pub struct VideoDetails {
|
pub struct VideoDetails {
|
||||||
/// Video metadata + recommended videos
|
/// Video metadata + recommended videos
|
||||||
pub contents: Contents,
|
pub contents: Contents,
|
||||||
|
/// Video ID
|
||||||
|
pub current_video_endpoint: CurrentVideoEndpoint,
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
#[serde_as(as = "VecLogError<_>")]
|
||||||
/// Video chapters + comment section
|
/// Video chapters + comment section
|
||||||
pub engagement_panels: MapResult<Vec<EngagementPanel>>,
|
pub engagement_panels: MapResult<Vec<EngagementPanel>>,
|
||||||
|
@ -42,7 +47,9 @@ pub struct TwoColumnWatchNextResults {
|
||||||
/// Metadata about the video
|
/// Metadata about the video
|
||||||
pub results: VideoResultsWrap,
|
pub results: VideoResultsWrap,
|
||||||
/// Video recommendations
|
/// Video recommendations
|
||||||
pub secondary_results: RecommendationResultsWrap,
|
///
|
||||||
|
/// Can be `None` for age-restricted videos
|
||||||
|
pub secondary_results: Option<RecommendationResultsWrap>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metadata about the video
|
/// Metadata about the video
|
||||||
|
@ -80,6 +87,7 @@ pub enum VideoResultsItem {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
VideoSecondaryInfoRenderer {
|
VideoSecondaryInfoRenderer {
|
||||||
owner: VideoOwner,
|
owner: VideoOwner,
|
||||||
|
#[serde(default)]
|
||||||
#[serde_as(as = "Text")]
|
#[serde_as(as = "Text")]
|
||||||
description: String,
|
description: String,
|
||||||
/// Additional metadata (e.g. Creative Commons License)
|
/// Additional metadata (e.g. Creative Commons License)
|
||||||
|
@ -87,18 +95,11 @@ pub enum VideoResultsItem {
|
||||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||||
metadata_row_container: Option<MetadataRowContainer>,
|
metadata_row_container: Option<MetadataRowContainer>,
|
||||||
},
|
},
|
||||||
/*
|
|
||||||
/// The comment section consists of 2 ItemSectionRenderers:
|
/// The comment section consists of 2 ItemSectionRenderers:
|
||||||
///
|
///
|
||||||
/// 1. sectionIdentifier: "comments-entry-point", contains number of comments
|
/// 1. sectionIdentifier: "comments-entry-point", contains number of comments
|
||||||
/// 2. sectionIdentifier: "comment-item-section", contains continuation token
|
/// 2. sectionIdentifier: "comment-item-section", contains continuation token
|
||||||
#[serde(rename_all = "camelCase")]
|
ItemSectionRenderer(#[serde_as(deserialize_as = "DefaultOnError")] ItemSection),
|
||||||
ItemSectionRenderer {
|
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
|
||||||
contents: Vec<ItemSection>,
|
|
||||||
section_identifier: String,
|
|
||||||
},
|
|
||||||
*/
|
|
||||||
#[serde(other, deserialize_with = "ignore_any")]
|
#[serde(other, deserialize_with = "ignore_any")]
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
@ -113,8 +114,11 @@ pub struct ViewCount {
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ViewCountRenderer {
|
pub struct ViewCountRenderer {
|
||||||
|
/// View count (`232,975,196 views`)
|
||||||
#[serde_as(as = "Text")]
|
#[serde_as(as = "Text")]
|
||||||
pub view_count: String,
|
pub view_count: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_live: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like/Dislike buttons
|
/// Like/Dislike buttons
|
||||||
|
@ -130,7 +134,23 @@ pub struct VideoActions {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct VideoActionsMenu {
|
pub struct VideoActionsMenu {
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub top_level_buttons: Vec<ToggleButtonWrap>,
|
pub top_level_buttons: Vec<TopLevelButton>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The different TopLevelButtons
|
||||||
|
///
|
||||||
|
/// YouTube seems to be A/B testing the SegmentedLikeDislikeButtonRenderer
|
||||||
|
///
|
||||||
|
/// See: https://github.com/TeamNewPipe/NewPipeExtractor/pull/926
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum TopLevelButton {
|
||||||
|
ToggleButtonRenderer(ToggleButton),
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
SegmentedLikeDislikeButtonRenderer {
|
||||||
|
like_button: ToggleButtonWrap,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like/Dislike button
|
/// Like/Dislike button
|
||||||
|
@ -147,9 +167,11 @@ pub struct ToggleButtonWrap {
|
||||||
pub struct ToggleButton {
|
pub struct ToggleButton {
|
||||||
/// Icon type: `LIKE` / `DISLIKE`
|
/// Icon type: `LIKE` / `DISLIKE`
|
||||||
pub default_icon: Icon,
|
pub default_icon: Icon,
|
||||||
/// Number of likes (`4,010,157 likes`)
|
/// Number of likes (`like this video along with 4,010,156 other people`)
|
||||||
|
///
|
||||||
|
/// Contains no digits (e.g. `I like this`) if likes are hidden by the creator.
|
||||||
#[serde_as(as = "AccessibilityText")]
|
#[serde_as(as = "AccessibilityText")]
|
||||||
pub default_text: String,
|
pub accessibility_data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows additional video metadata. Its only known use is for
|
/// Shows additional video metadata. Its only known use is for
|
||||||
|
@ -192,22 +214,61 @@ pub struct MetadataRowRenderer {
|
||||||
pub contents: Vec<Vec<TextLink>>,
|
pub contents: Vec<Vec<TextLink>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/// Contains current video ID
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CurrentVideoEndpoint {
|
||||||
|
pub watch_endpoint: CurrentVideoWatchEndpoint,
|
||||||
|
}
|
||||||
|
/// Contains current video ID
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CurrentVideoWatchEndpoint {
|
||||||
|
pub video_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The comment section consists of 2 ItemSections:
|
||||||
|
///
|
||||||
|
/// 1. CommentsEntryPointHeaderRenderer: contains number of comments
|
||||||
|
/// 2. ContinuationItemRenderer: contains continuation token
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Default, Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case", tag = "sectionIdentifier")]
|
||||||
|
pub enum ItemSection {
|
||||||
|
CommentsEntryPoint {
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
contents: Vec<ItemSectionCommentCount>,
|
||||||
|
},
|
||||||
|
CommentItemSection {
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
contents: Vec<ItemSectionComments>,
|
||||||
|
},
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Item section containing comment count
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ItemSectionCommentCount {
|
||||||
|
pub comments_entry_point_header_renderer: CommentsEntryPointHeaderRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderer of item section containing comment count
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum ItemSection {
|
pub struct CommentsEntryPointHeaderRenderer {
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
CommentsEntryPointHeaderRenderer {
|
|
||||||
#[serde_as(as = "Text")]
|
#[serde_as(as = "Text")]
|
||||||
comment_count: String,
|
pub comment_count: String,
|
||||||
},
|
}
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
ContinuationItemRenderer {
|
/// Item section containing comments ctoken
|
||||||
continuation_endpoint: ContinuationEndpoint,
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
},
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ItemSectionComments {
|
||||||
|
pub continuation_item_renderer: ContinuationItemRenderer,
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
/// Video recommendations
|
/// Video recommendations
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
@ -221,8 +282,9 @@ pub struct RecommendationResultsWrap {
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RecommendationResults {
|
pub struct RecommendationResults {
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
/// Can be `None` for age-restricted videos
|
||||||
pub results: MapResult<Vec<VideoListItem<RecommendedVideo>>>,
|
#[serde_as(as = "Option<VecLogError<_>>")]
|
||||||
|
pub results: Option<MapResult<Vec<VideoListItem<RecommendedVideo>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Video recommendation item
|
/// Video recommendation item
|
||||||
|
@ -237,40 +299,25 @@ pub struct RecommendedVideo {
|
||||||
#[serde(rename = "shortBylineText")]
|
#[serde(rename = "shortBylineText")]
|
||||||
#[serde_as(as = "TextLink")]
|
#[serde_as(as = "TextLink")]
|
||||||
pub channel: TextLink,
|
pub channel: TextLink,
|
||||||
|
pub channel_thumbnail: Thumbnails,
|
||||||
|
/// Channel verification badge
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
pub owner_badges: Vec<ChannelBadge>,
|
||||||
#[serde_as(as = "Option<Text>")]
|
#[serde_as(as = "Option<Text>")]
|
||||||
pub length_text: Option<String>,
|
pub length_text: Option<String>,
|
||||||
|
/// (e.g. `11 months ago`)
|
||||||
#[serde_as(as = "Option<Text>")]
|
#[serde_as(as = "Option<Text>")]
|
||||||
pub published_time_text: Option<String>,
|
pub published_time_text: Option<String>,
|
||||||
#[serde_as(as = "Text")]
|
#[serde_as(as = "Text")]
|
||||||
pub view_count_text: String,
|
pub view_count_text: String,
|
||||||
|
/// Badges are displayed on the video thumbnail and
|
||||||
|
/// show certain video properties (e.g. active livestream)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub badges: Vec<VideoBadge>,
|
pub badges: Vec<VideoBadge>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Badges are displayed on the video thumbnail and
|
|
||||||
/// show certain video properties (e.g. active livestream)
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct VideoBadge {
|
|
||||||
pub metadata_badge_renderer: VideoBadgeRenderer,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Badges are displayed on the video thumbnail and
|
|
||||||
/// show certain video properties (e.g. active livestream)
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct VideoBadgeRenderer {
|
|
||||||
pub style: VideoBadgeStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
|
|
||||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
||||||
pub enum VideoBadgeStyle {
|
|
||||||
/// Active livestream
|
|
||||||
BadgeStyleTypeLiveNow,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The engagement panels are displayed below the video and contain chapter markers
|
/// The engagement panels are displayed below the video and contain chapter markers
|
||||||
/// and the comment section.
|
/// and the comment section.
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
@ -282,17 +329,17 @@ pub struct EngagementPanel {
|
||||||
/// The engagement panels are displayed below the video and contain chapter markers
|
/// The engagement panels are displayed below the video and contain chapter markers
|
||||||
/// and the comment section.
|
/// and the comment section.
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "kebab-case", tag = "panelIdentifier")]
|
#[serde(rename_all = "kebab-case", tag = "targetId")]
|
||||||
pub enum EngagementPanelRenderer {
|
pub enum EngagementPanelRenderer {
|
||||||
/// Chapter markers
|
/// Chapter markers
|
||||||
EngagementPanelMacroMarkersDescriptionChapters { content: ChapterMarkersContent },
|
EngagementPanelMacroMarkersDescriptionChapters { content: ChapterMarkersContent },
|
||||||
/// Comment section (contains no comments, but the
|
/// Comment section (contains no comments, but the
|
||||||
/// continuation tokens for fetching top/latest comments)
|
/// continuation tokens for fetching top/latest comments)
|
||||||
CommentItemSection { header: CommentItemSectionHeader },
|
EngagementPanelCommentsSection { header: CommentItemSectionHeader },
|
||||||
/// Ignored items:
|
/// Ignored items:
|
||||||
/// - `engagement-panel-ads`
|
/// - `engagement-panel-ads`
|
||||||
/// - `engagement-panel-structured-description`
|
/// - `engagement-panel-structured-description`
|
||||||
/// (Desctiption already included in `VideoSecondaryInfoRenderer`)
|
/// (Description already included in `VideoSecondaryInfoRenderer`)
|
||||||
/// - `engagement-panel-searchable-transcript`
|
/// - `engagement-panel-searchable-transcript`
|
||||||
/// (basically video subtitles in a different format)
|
/// (basically video subtitles in a different format)
|
||||||
#[serde(other, deserialize_with = "ignore_any")]
|
#[serde(other, deserialize_with = "ignore_any")]
|
||||||
|
@ -357,11 +404,13 @@ pub struct CommentItemSectionHeader {
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CommentItemSectionHeaderRenderer {
|
pub struct CommentItemSectionHeaderRenderer {
|
||||||
/// Average comment count (e.g. `81`, `2.2K`, `705K`)
|
/// Approximate comment count (e.g. `81`, `2.2K`, `705K`)
|
||||||
///
|
///
|
||||||
/// The accurate count is included in the first comment response.
|
/// The accurate count is included in the first comment response.
|
||||||
#[serde_as(as = "Text")]
|
///
|
||||||
pub contextual_info: String,
|
/// Is `None` if there are no comments.
|
||||||
|
#[serde_as(as = "Option<Text>")]
|
||||||
|
pub contextual_info: Option<String>,
|
||||||
pub menu: CommentItemSectionHeaderMenu,
|
pub menu: CommentItemSectionHeaderMenu,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -474,10 +523,7 @@ pub enum CommentListItem {
|
||||||
rendering_priority: CommentPriority,
|
rendering_priority: CommentPriority,
|
||||||
},
|
},
|
||||||
/// Reply comment
|
/// Reply comment
|
||||||
CommentRenderer {
|
CommentRenderer(CommentRenderer),
|
||||||
#[serde(flatten)]
|
|
||||||
comment: CommentRenderer,
|
|
||||||
},
|
|
||||||
/// Continuation token to fetch more comments
|
/// Continuation token to fetch more comments
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
ContinuationItemRenderer {
|
ContinuationItemRenderer {
|
||||||
|
@ -486,8 +532,9 @@ pub enum CommentListItem {
|
||||||
/// Header of the comment section (contains number of comments)
|
/// Header of the comment section (contains number of comments)
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
CommentsHeaderRenderer {
|
CommentsHeaderRenderer {
|
||||||
#[serde_as(as = "Text")]
|
/// `4,238,993 Comments`
|
||||||
count_text: Vec<String>
|
#[serde_as(as = "Option<Text>")]
|
||||||
|
count_text: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -520,11 +567,12 @@ pub struct CommentRenderer {
|
||||||
pub published_time_text: String,
|
pub published_time_text: String,
|
||||||
pub comment_id: String,
|
pub comment_id: String,
|
||||||
pub author_is_channel_owner: bool,
|
pub author_is_channel_owner: bool,
|
||||||
#[serde_as(as = "Option<Text>")]
|
// #[serde_as(as = "Option<Text>")]
|
||||||
pub vote_count: Option<String>,
|
// pub vote_count: Option<String>,
|
||||||
pub author_comment_badge: Option<AuthorCommentBadge>,
|
pub author_comment_badge: Option<AuthorCommentBadge>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub reply_count: u32,
|
pub reply_count: u32,
|
||||||
|
/// Buttons for comment interaction (Like/Dislike/Reply)
|
||||||
pub action_buttons: CommentActionButtons,
|
pub action_buttons: CommentActionButtons,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -568,17 +616,20 @@ pub struct RepliesRenderer {
|
||||||
pub contents: Vec<CommentListItem>,
|
pub contents: Vec<CommentListItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// These are the buttons for comment interaction. Contains the CreatorHeart.
|
/// These are the buttons for comment interaction (Like/Dislike/Reply).
|
||||||
|
/// Contains the CreatorHeart.
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CommentActionButtons {
|
pub struct CommentActionButtons {
|
||||||
pub comment_action_buttons_renderer: CommentActionButtonsRenderer,
|
pub comment_action_buttons_renderer: CommentActionButtonsRenderer,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// These are the buttons for comment interaction. Contains the CreatorHeart.
|
/// These are the buttons for comment interaction (Like/Dislike/Reply).
|
||||||
|
/// Contains the CreatorHeart.
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CommentActionButtonsRenderer {
|
pub struct CommentActionButtonsRenderer {
|
||||||
|
pub like_button: ToggleButtonWrap,
|
||||||
pub creator_heart: Option<CreatorHeart>,
|
pub creator_heart: Option<CreatorHeart>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -607,5 +658,7 @@ pub struct AuthorCommentBadge {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AuthorCommentBadgeRenderer {
|
pub struct AuthorCommentBadgeRenderer {
|
||||||
/// Verified: `CHECK`
|
/// Verified: `CHECK`
|
||||||
|
///
|
||||||
|
/// Artist: `OFFICIAL_ARTIST_BADGE`
|
||||||
pub icon: Icon,
|
pub icon: Icon,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,20 @@
|
||||||
use anyhow::Result;
|
use std::convert::TryFrom;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Result};
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::{model::VideoDetails, serializer::MapResult};
|
use crate::{
|
||||||
|
model::{Channel, ChannelId, Comment, Language, Paginator, RecommendedVideo, VideoDetails},
|
||||||
|
serializer::MapResult,
|
||||||
|
timeago,
|
||||||
|
util::{self, TryRemove},
|
||||||
|
};
|
||||||
|
|
||||||
use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
|
use super::{
|
||||||
|
response::{self, IconType, IsLive},
|
||||||
|
ClientType, MapResponse, RustyPipeQuery, YTContext,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
struct QVideo {
|
struct QVideo {
|
||||||
|
@ -44,48 +54,41 @@ impl RustyPipeQuery {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
pub async fn video_recommendations(self, ctoken: &str) -> Result<Paginator<RecommendedVideo>> {
|
||||||
async fn get_comments_response(&self, ctoken: &str) -> Result<response::VideoComments> {
|
let context = self.get_context(ClientType::Desktop, true).await;
|
||||||
let client = self.get_ytclient(ClientType::Desktop);
|
|
||||||
let context = client.get_context(true).await;
|
|
||||||
let request_body = QVideoCont {
|
let request_body = QVideoCont {
|
||||||
context,
|
context,
|
||||||
continuation: ctoken.to_owned(),
|
continuation: ctoken.to_owned(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp = client
|
self.execute_request::<response::VideoRecommendations, _, _>(
|
||||||
.request_builder(Method::POST, "next")
|
ClientType::Desktop,
|
||||||
|
"video_recommendations",
|
||||||
|
ctoken,
|
||||||
|
Method::POST,
|
||||||
|
"next",
|
||||||
|
&request_body,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.json(&request_body)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?;
|
|
||||||
|
|
||||||
Ok(resp.json::<response::VideoComments>().await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_recommendations_response(
|
pub async fn video_comments(self, ctoken: &str) -> Result<Paginator<Comment>> {
|
||||||
&self,
|
let context = self.get_context(ClientType::Desktop, true).await;
|
||||||
ctoken: &str,
|
|
||||||
) -> Result<response::VideoRecommendations> {
|
|
||||||
let client = self.get_ytclient(ClientType::Desktop);
|
|
||||||
let context = client.get_context(true).await;
|
|
||||||
let request_body = QVideoCont {
|
let request_body = QVideoCont {
|
||||||
context,
|
context,
|
||||||
continuation: ctoken.to_owned(),
|
continuation: ctoken.to_owned(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp = client
|
self.execute_request::<response::VideoComments, _, _>(
|
||||||
.request_builder(Method::POST, "next")
|
ClientType::Desktop,
|
||||||
|
"video_comments",
|
||||||
|
ctoken,
|
||||||
|
Method::POST,
|
||||||
|
"next",
|
||||||
|
&request_body,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.json(&request_body)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?;
|
|
||||||
|
|
||||||
Ok(resp.json::<response::VideoRecommendations>().await?)
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MapResponse<VideoDetails> for response::VideoDetails {
|
impl MapResponse<VideoDetails> for response::VideoDetails {
|
||||||
|
@ -95,18 +98,491 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
|
||||||
lang: crate::model::Language,
|
lang: crate::model::Language,
|
||||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
) -> Result<MapResult<VideoDetails>> {
|
) -> Result<MapResult<VideoDetails>> {
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
let video_id = self.current_video_endpoint.watch_endpoint.video_id;
|
||||||
|
if id != video_id {
|
||||||
|
bail!("got wrong playlist id {}, expected {}", video_id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut primary_results = self
|
||||||
|
.contents
|
||||||
|
.two_column_watch_next_results
|
||||||
|
.results
|
||||||
|
.results
|
||||||
|
.contents;
|
||||||
|
warnings.append(&mut primary_results.warnings);
|
||||||
|
|
||||||
|
let mut primary_info = None;
|
||||||
|
let mut secondary_info = None;
|
||||||
|
let mut comment_count_section = None;
|
||||||
|
let mut comment_ctoken_section = None;
|
||||||
|
|
||||||
|
primary_results.c.into_iter().for_each(|r| match r {
|
||||||
|
response::video_details::VideoResultsItem::VideoPrimaryInfoRenderer { .. } => {
|
||||||
|
primary_info = Some(r);
|
||||||
|
}
|
||||||
|
response::video_details::VideoResultsItem::VideoSecondaryInfoRenderer { .. } => {
|
||||||
|
secondary_info = Some(r);
|
||||||
|
}
|
||||||
|
response::video_details::VideoResultsItem::ItemSectionRenderer(section) => {
|
||||||
|
match section {
|
||||||
|
response::video_details::ItemSection::CommentsEntryPoint { mut contents } => {
|
||||||
|
comment_count_section = contents.try_swap_remove(0);
|
||||||
|
}
|
||||||
|
response::video_details::ItemSection::CommentItemSection { mut contents } => {
|
||||||
|
comment_ctoken_section = contents.try_swap_remove(0);
|
||||||
|
}
|
||||||
|
response::video_details::ItemSection::None => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response::video_details::VideoResultsItem::None => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
let (title, view_count, like_count, publish_date, publish_date_txt, is_live) =
|
||||||
|
match primary_info {
|
||||||
|
Some(response::video_details::VideoResultsItem::VideoPrimaryInfoRenderer {
|
||||||
|
title,
|
||||||
|
view_count,
|
||||||
|
video_actions,
|
||||||
|
date_text,
|
||||||
|
}) => {
|
||||||
|
let like_btn = video_actions
|
||||||
|
.menu_renderer
|
||||||
|
.top_level_buttons
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|button| {
|
||||||
|
let btn = match button {
|
||||||
|
response::video_details::TopLevelButton::ToggleButtonRenderer(btn) => btn,
|
||||||
|
response::video_details::TopLevelButton::SegmentedLikeDislikeButtonRenderer { like_button } => like_button.toggle_button_renderer,
|
||||||
|
};
|
||||||
|
match btn.default_icon.icon_type {
|
||||||
|
IconType::Like => Some(btn),
|
||||||
|
_ => None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(
|
||||||
|
title,
|
||||||
|
util::parse_numeric(&view_count.video_view_count_renderer.view_count)?,
|
||||||
|
// accessibility_data contains no digits if the like count is hidden,
|
||||||
|
// so we ignore parse errors here for now
|
||||||
|
like_btn.and_then(|btn| util::parse_numeric(&btn.accessibility_data).ok()),
|
||||||
|
timeago::parse_textual_date_or_warn(lang, &date_text, &mut warnings),
|
||||||
|
date_text,
|
||||||
|
view_count.video_view_count_renderer.is_live,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => bail!("could not find primary_info"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO: use large number parser for this
|
||||||
|
let comment_count = comment_count_section.and_then(|s| {
|
||||||
|
util::parse_numeric_or_warn::<u32>(
|
||||||
|
&s.comments_entry_point_header_renderer.comment_count,
|
||||||
|
&mut warnings,
|
||||||
|
)
|
||||||
|
});*/
|
||||||
|
|
||||||
|
let comment_ctoken = comment_ctoken_section.map(|s| {
|
||||||
|
s.continuation_item_renderer
|
||||||
|
.continuation_endpoint
|
||||||
|
.continuation_command
|
||||||
|
.token
|
||||||
|
});
|
||||||
|
|
||||||
|
let (owner, description, is_ccommons) = match secondary_info {
|
||||||
|
Some(response::video_details::VideoResultsItem::VideoSecondaryInfoRenderer {
|
||||||
|
owner,
|
||||||
|
description,
|
||||||
|
metadata_row_container,
|
||||||
|
}) => {
|
||||||
|
let is_ccommons = metadata_row_container
|
||||||
|
.map(|c| {
|
||||||
|
c.metadata_row_container_renderer.rows.iter().any(|cr| {
|
||||||
|
cr.metadata_row_renderer.contents.iter().any(|links| {
|
||||||
|
links.iter().any(|link| match link {
|
||||||
|
crate::serializer::text::TextLink::Web { text: _, url } => {
|
||||||
|
url == "https://www.youtube.com/t/creative_commons"
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
(owner.video_owner_renderer, description, is_ccommons)
|
||||||
|
}
|
||||||
|
_ => bail!("could not find secondary_info"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (channel_id, channel_name) = match owner.title {
|
||||||
|
crate::serializer::text::TextLink::Browse {
|
||||||
|
text,
|
||||||
|
page_type,
|
||||||
|
browse_id,
|
||||||
|
} => match page_type {
|
||||||
|
crate::serializer::text::PageType::Channel => (browse_id, text),
|
||||||
|
_ => bail!("invalid channel link type"),
|
||||||
|
},
|
||||||
|
_ => bail!("invalid channel link"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let recommended = self
|
||||||
|
.contents
|
||||||
|
.two_column_watch_next_results
|
||||||
|
.secondary_results
|
||||||
|
.map(|sr| {
|
||||||
|
sr.secondary_results.results.map(|r| {
|
||||||
|
let mut res = map_recommendations(r, lang);
|
||||||
|
warnings.append(&mut res.warnings);
|
||||||
|
res.c
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut engagement_panels = self.engagement_panels;
|
||||||
|
warnings.append(&mut engagement_panels.warnings);
|
||||||
|
|
||||||
|
let mut chapter_panel = None;
|
||||||
|
let mut comment_panel = None;
|
||||||
|
engagement_panels.c.into_iter().for_each(|panel| match panel.engagement_panel_section_list_renderer {
|
||||||
|
response::video_details::EngagementPanelRenderer::EngagementPanelMacroMarkersDescriptionChapters { content } => {
|
||||||
|
chapter_panel = Some(content);
|
||||||
|
},
|
||||||
|
response::video_details::EngagementPanelRenderer::EngagementPanelCommentsSection { header } => {
|
||||||
|
comment_panel = Some(header);
|
||||||
|
},
|
||||||
|
response::video_details::EngagementPanelRenderer::None => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
let latest_comments_ctoken = comment_panel.and_then(|comments| {
|
||||||
|
let mut items = comments
|
||||||
|
.engagement_panel_title_header_renderer
|
||||||
|
.menu
|
||||||
|
.sort_filter_sub_menu_renderer
|
||||||
|
.sub_menu_items;
|
||||||
|
items
|
||||||
|
.try_swap_remove(1)
|
||||||
|
.map(|c| c.service_endpoint.continuation_command.token)
|
||||||
|
});
|
||||||
|
|
||||||
Ok(MapResult {
|
Ok(MapResult {
|
||||||
c: VideoDetails {
|
c: VideoDetails {
|
||||||
id: id.to_owned(),
|
id: video_id,
|
||||||
title: "".to_owned(),
|
title,
|
||||||
description: "".to_owned(),
|
description,
|
||||||
|
channel: Channel {
|
||||||
|
id: channel_id,
|
||||||
|
name: channel_name,
|
||||||
|
avatar: owner.thumbnail.into(),
|
||||||
|
verification: owner.badges.into(),
|
||||||
|
subscriber_count: None,
|
||||||
|
subscriber_count_txt: owner.subscriber_count_text,
|
||||||
},
|
},
|
||||||
warnings: vec![],
|
view_count,
|
||||||
|
like_count,
|
||||||
|
publish_date,
|
||||||
|
publish_date_txt,
|
||||||
|
is_live,
|
||||||
|
is_ccommons,
|
||||||
|
recommended,
|
||||||
|
top_comments: Paginator {
|
||||||
|
count: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
ctoken: comment_ctoken,
|
||||||
|
},
|
||||||
|
latest_comments: Paginator {
|
||||||
|
count: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
ctoken: latest_comments_ctoken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MapResponse<Paginator<RecommendedVideo>> for response::VideoRecommendations {
|
||||||
|
fn map_response(
|
||||||
|
self,
|
||||||
|
_id: &str,
|
||||||
|
lang: crate::model::Language,
|
||||||
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
|
) -> Result<MapResult<Paginator<RecommendedVideo>>> {
|
||||||
|
let mut endpoints = self.on_response_received_endpoints;
|
||||||
|
let cont = some_or_bail!(
|
||||||
|
endpoints.try_swap_remove(0),
|
||||||
|
Err(anyhow!("no continuation endpoint"))
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(map_recommendations(
|
||||||
|
cont.append_continuation_items_action.continuation_items,
|
||||||
|
lang,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MapResponse<Paginator<Comment>> for response::VideoComments {
|
||||||
|
fn map_response(
|
||||||
|
self,
|
||||||
|
_id: &str,
|
||||||
|
lang: crate::model::Language,
|
||||||
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
|
) -> Result<MapResult<Paginator<Comment>>> {
|
||||||
|
let mut warnings = self.on_response_received_endpoints.warnings;
|
||||||
|
|
||||||
|
let mut comments = Vec::new();
|
||||||
|
let mut comment_count = None;
|
||||||
|
let mut ctoken = None;
|
||||||
|
|
||||||
|
self.on_response_received_endpoints
|
||||||
|
.c
|
||||||
|
.into_iter()
|
||||||
|
.for_each(|citem| {
|
||||||
|
let mut items = citem.append_continuation_items_action.continuation_items;
|
||||||
|
warnings.append(&mut items.warnings);
|
||||||
|
items.c.into_iter().for_each(|item| match item {
|
||||||
|
response::video_details::CommentListItem::CommentThreadRenderer {
|
||||||
|
comment,
|
||||||
|
replies,
|
||||||
|
rendering_priority,
|
||||||
|
} => {
|
||||||
|
let mut res = map_comment(
|
||||||
|
comment.comment_renderer,
|
||||||
|
Some(replies),
|
||||||
|
rendering_priority,
|
||||||
|
lang,
|
||||||
|
);
|
||||||
|
comments.push(res.c);
|
||||||
|
warnings.append(&mut res.warnings)
|
||||||
|
}
|
||||||
|
response::video_details::CommentListItem::CommentRenderer(comment) => {
|
||||||
|
let mut res = map_comment(
|
||||||
|
comment,
|
||||||
|
None,
|
||||||
|
response::video_details::CommentPriority::RenderingPriorityUnknown,
|
||||||
|
lang,
|
||||||
|
);
|
||||||
|
comments.push(res.c);
|
||||||
|
warnings.append(&mut res.warnings)
|
||||||
|
}
|
||||||
|
response::video_details::CommentListItem::ContinuationItemRenderer {
|
||||||
|
continuation_endpoint,
|
||||||
|
} => {
|
||||||
|
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::<u32>(&txt, &mut warnings)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(MapResult {
|
||||||
|
c: Paginator {
|
||||||
|
count: comment_count,
|
||||||
|
items: comments,
|
||||||
|
ctoken,
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_recommendations(
|
||||||
|
r: MapResult<Vec<response::VideoListItem<response::video_details::RecommendedVideo>>>,
|
||||||
|
lang: Language,
|
||||||
|
) -> MapResult<Paginator<RecommendedVideo>> {
|
||||||
|
let mut warnings = r.warnings;
|
||||||
|
let mut ctoken = None;
|
||||||
|
|
||||||
|
let items =
|
||||||
|
r.c.into_iter()
|
||||||
|
.filter_map(|item| match item {
|
||||||
|
response::VideoListItem::GridVideoRenderer { video } => {
|
||||||
|
match ChannelId::try_from(video.channel) {
|
||||||
|
Ok(channel) => Some(RecommendedVideo {
|
||||||
|
id: video.video_id,
|
||||||
|
title: video.title,
|
||||||
|
length: video.length_text.and_then(|txt| {
|
||||||
|
util::parse_video_length_or_warn(&txt, &mut warnings)
|
||||||
|
}),
|
||||||
|
thumbnail: video.thumbnail.into(),
|
||||||
|
channel: Channel {
|
||||||
|
id: channel.id,
|
||||||
|
name: channel.name,
|
||||||
|
avatar: video.channel_thumbnail.into(),
|
||||||
|
verification: video.owner_badges.into(),
|
||||||
|
subscriber_count: None,
|
||||||
|
subscriber_count_txt: None,
|
||||||
|
},
|
||||||
|
publish_date: video.published_time_text.as_ref().and_then(|txt| {
|
||||||
|
timeago::parse_timeago_or_warn(lang, txt, &mut warnings)
|
||||||
|
}),
|
||||||
|
publish_date_txt: video.published_time_text,
|
||||||
|
view_count: util::parse_numeric_or_warn(
|
||||||
|
&video.view_count_text,
|
||||||
|
&mut warnings,
|
||||||
|
),
|
||||||
|
is_live: video.badges.is_live(),
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
warnings.push(e.to_string());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response::VideoListItem::ContinuationItemRenderer {
|
||||||
|
continuation_endpoint,
|
||||||
|
} => {
|
||||||
|
ctoken = Some(continuation_endpoint.continuation_command.token);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
response::VideoListItem::None => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
MapResult {
|
||||||
|
c: Paginator {
|
||||||
|
count: None,
|
||||||
|
items,
|
||||||
|
ctoken,
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_comment(
|
||||||
|
c: response::video_details::CommentRenderer,
|
||||||
|
replies: Option<response::video_details::Replies>,
|
||||||
|
priority: response::video_details::CommentPriority,
|
||||||
|
lang: Language,
|
||||||
|
) -> MapResult<Comment> {
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
let mut reply_ctoken = None;
|
||||||
|
let replies = replies.map(|replies| {
|
||||||
|
replies
|
||||||
|
.comment_replies_renderer
|
||||||
|
.contents
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| match item {
|
||||||
|
response::video_details::CommentListItem::CommentRenderer(comment) => {
|
||||||
|
let mut res = map_comment(
|
||||||
|
comment,
|
||||||
|
None,
|
||||||
|
response::video_details::CommentPriority::default(),
|
||||||
|
lang,
|
||||||
|
);
|
||||||
|
warnings.append(&mut res.warnings);
|
||||||
|
Some(res.c)
|
||||||
|
}
|
||||||
|
response::video_details::CommentListItem::ContinuationItemRenderer {
|
||||||
|
continuation_endpoint,
|
||||||
|
} => {
|
||||||
|
reply_ctoken = Some(continuation_endpoint.continuation_command.token);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
MapResult {
|
||||||
|
c: Comment {
|
||||||
|
id: c.comment_id,
|
||||||
|
text: c.content_text,
|
||||||
|
author: match (c.author_endpoint, c.author_text) {
|
||||||
|
(Some(aep), Some(name)) => Some(Channel {
|
||||||
|
id: aep.browse_endpoint.browse_id,
|
||||||
|
name,
|
||||||
|
avatar: c.author_thumbnail.into(),
|
||||||
|
verification: c
|
||||||
|
.author_comment_badge
|
||||||
|
.map(|b| b.author_comment_badge_renderer.icon.into())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
subscriber_count: None,
|
||||||
|
subscriber_count_txt: None,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
publish_date: timeago::parse_timeago_or_warn(
|
||||||
|
lang,
|
||||||
|
&c.published_time_text,
|
||||||
|
&mut warnings,
|
||||||
|
),
|
||||||
|
publish_date_txt: c.published_time_text,
|
||||||
|
like_count: util::parse_numeric_or_warn(
|
||||||
|
&c.action_buttons
|
||||||
|
.comment_action_buttons_renderer
|
||||||
|
.like_button
|
||||||
|
.toggle_button_renderer
|
||||||
|
.accessibility_data,
|
||||||
|
&mut warnings,
|
||||||
|
),
|
||||||
|
reply_count: c.reply_count,
|
||||||
|
replies: replies
|
||||||
|
.map(|items| Paginator {
|
||||||
|
count: Some(c.reply_count),
|
||||||
|
items,
|
||||||
|
ctoken: reply_ctoken,
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
by_owner: c.author_is_channel_owner,
|
||||||
|
pinned: priority
|
||||||
|
== response::video_details::CommentPriority::RenderingPriorityPinnedComment,
|
||||||
|
hearted: c
|
||||||
|
.action_buttons
|
||||||
|
.comment_action_buttons_renderer
|
||||||
|
.creator_heart
|
||||||
|
.map(|h| h.creator_heart_renderer.is_hearted)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use crate::client::RustyPipe;
|
||||||
|
|
||||||
|
#[test_log::test(tokio::test)]
|
||||||
|
async fn get_video_details() {
|
||||||
|
let rp = RustyPipe::builder().strict().build();
|
||||||
|
let details = rp.query().video_details("HRKu0cvrr_o").await.unwrap();
|
||||||
|
|
||||||
|
dbg!(&details);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_log::test(tokio::test)]
|
||||||
|
async fn get_video_recommendations() {
|
||||||
|
let rp = RustyPipe::builder().strict().build();
|
||||||
|
let details = rp.query().video_details("ZeerrnuLi5E").await.unwrap();
|
||||||
|
let rec = rp
|
||||||
|
.query()
|
||||||
|
.video_recommendations(&details.recommended.ctoken.unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
dbg!(&rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_log::test(tokio::test)]
|
||||||
|
async fn get_video_comments() {
|
||||||
|
let rp = RustyPipe::builder().strict().build();
|
||||||
|
let details = rp.query().video_details("ZeerrnuLi5E").await.unwrap();
|
||||||
|
let rec = rp
|
||||||
|
.query()
|
||||||
|
.video_comments(&details.top_comments.ctoken.unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
dbg!(&rec);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -269,8 +269,9 @@ pub async fn download_video(
|
||||||
let download_dir = PathBuf::from(output_dir);
|
let download_dir = PathBuf::from(output_dir);
|
||||||
let title = player_data.details.title.to_owned();
|
let title = player_data.details.title.to_owned();
|
||||||
let output_fname_set = output_fname.is_some();
|
let output_fname_set = output_fname.is_some();
|
||||||
let output_fname = output_fname
|
let output_fname = output_fname.unwrap_or_else(|| {
|
||||||
.unwrap_or_else(|| filenamify::filenamify(format!("{} [{}]", title, player_data.details.id)));
|
filenamify::filenamify(format!("{} [{}]", title, player_data.details.id))
|
||||||
|
});
|
||||||
|
|
||||||
// Select streams to download
|
// Select streams to download
|
||||||
let (video, audio) = player_data.select_video_audio_stream(filter);
|
let (video, audio) = player_data.select_video_audio_stream(filter);
|
||||||
|
|
50
src/error.rs
50
src/error.rs
|
@ -1,50 +0,0 @@
|
||||||
/// Errors that can occur during the id extraction or the video download process.
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
pub enum Error {
|
|
||||||
/*
|
|
||||||
#[error("the provided raw Id does not match any known Id-pattern")]
|
|
||||||
BadIdFormat,
|
|
||||||
#[cfg(feature = "fetch")]
|
|
||||||
#[error("the video you requested is unavailable:\n{0:#?}")]
|
|
||||||
VideoUnavailable(Box<crate::video_info::player_response::playability_status::PlayabilityStatus>),
|
|
||||||
#[cfg(feature = "download")]
|
|
||||||
#[error("the video contains no streams")]
|
|
||||||
NoStreams,
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
#[cfg(feature = "fetch")]
|
|
||||||
IO(#[from] std::io::Error),
|
|
||||||
#[error(transparent)]
|
|
||||||
#[cfg(feature = "fetch")]
|
|
||||||
Request(#[from] reqwest::Error),
|
|
||||||
#[error("YouTube returned an unexpected response: `{0}`")]
|
|
||||||
UnexpectedResponse(String),
|
|
||||||
#[error(transparent)]
|
|
||||||
#[cfg(feature = "fetch")]
|
|
||||||
QueryDeserialization(#[from] serde_qs::Error),
|
|
||||||
#[error(transparent)]
|
|
||||||
#[cfg(feature = "fetch")]
|
|
||||||
JsonDeserialization(#[from] serde_json::Error),
|
|
||||||
#[error(transparent)]
|
|
||||||
UrlParseError(#[from] url::ParseError),
|
|
||||||
|
|
||||||
#[error("{0}")]
|
|
||||||
Custom(String),
|
|
||||||
#[error("a potentially dangerous error occurred: {0}")]
|
|
||||||
Fatal(String),
|
|
||||||
#[error(
|
|
||||||
"the error, which occurred is not meant an error, but is used for internal comunication.\
|
|
||||||
This error should never be propagated to the public API."
|
|
||||||
)]
|
|
||||||
Internal(&'static str),
|
|
||||||
#[error("The internal channel has been closed")]
|
|
||||||
#[cfg(feature = "callback")]
|
|
||||||
ChannelClosed,
|
|
||||||
*/
|
|
||||||
#[error("paginator is exhausted")]
|
|
||||||
PaginatorExhausted,
|
|
||||||
|
|
||||||
// TODO: Remove anyhow
|
|
||||||
#[error(transparent)]
|
|
||||||
Anyhow(#[from] anyhow::Error),
|
|
||||||
}
|
|
|
@ -12,7 +12,6 @@ mod util;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod download;
|
pub mod download;
|
||||||
pub mod error;
|
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod report;
|
pub mod report;
|
||||||
pub mod timeago;
|
pub mod timeago;
|
||||||
|
|
132
src/model/mod.rs
132
src/model/mod.rs
|
@ -183,8 +183,8 @@ pub struct Playlist {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub videos: Paginator<PlaylistVideo>,
|
pub videos: Paginator<PlaylistVideo>,
|
||||||
pub n_videos: u32,
|
pub video_count: u32,
|
||||||
pub thumbnails: Vec<Thumbnail>,
|
pub thumbnail: Vec<Thumbnail>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub channel: Option<ChannelId>,
|
pub channel: Option<ChannelId>,
|
||||||
pub last_update: Option<DateTime<Local>>,
|
pub last_update: Option<DateTime<Local>>,
|
||||||
|
@ -196,7 +196,7 @@ pub struct PlaylistVideo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub length: u32,
|
pub length: u32,
|
||||||
pub thumbnails: Vec<Thumbnail>,
|
pub thumbnail: Vec<Thumbnail>,
|
||||||
pub channel: ChannelId,
|
pub channel: ChannelId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -212,24 +212,122 @@ pub struct ChannelId {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct VideoDetails {
|
pub struct VideoDetails {
|
||||||
|
/// Unique YouTube video ID
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
/// Video title
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
/// Video description
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
/// Channel of the video
|
||||||
|
pub channel: Channel,
|
||||||
|
/// Number of views
|
||||||
|
pub view_count: u64,
|
||||||
|
/// Number of likes
|
||||||
|
///
|
||||||
|
/// `None` if the like count was hidden by the creator.
|
||||||
|
pub like_count: Option<u32>,
|
||||||
|
/// Video publishing date. Start date in case of a livestream.
|
||||||
|
///
|
||||||
|
/// `None` if the date could not be parsed.
|
||||||
|
pub publish_date: Option<DateTime<Local>>,
|
||||||
|
/// Textual video publishing date (e.g. `Aug 2, 2013`, depends on language)
|
||||||
|
pub publish_date_txt: String,
|
||||||
|
/// Is the video a livestream?
|
||||||
|
pub is_live: bool,
|
||||||
|
/// Is the video published under the Creative Commons BY 3.0 license?
|
||||||
|
///
|
||||||
|
/// Information about the license:
|
||||||
|
///
|
||||||
|
/// https://www.youtube.com/t/creative_commons
|
||||||
|
///
|
||||||
|
/// https://creativecommons.org/licenses/by/3.0/
|
||||||
|
pub is_ccommons: bool,
|
||||||
|
/// Recommended videos
|
||||||
|
///
|
||||||
|
/// Note: Recommendations are not available for age-restricted videos
|
||||||
|
pub recommended: Paginator<RecommendedVideo>,
|
||||||
|
/// Paginator to fetch comments (most liked first)
|
||||||
|
pub top_comments: Paginator<Comment>,
|
||||||
|
/// Paginator to fetch comments (latest first)
|
||||||
|
pub latest_comments: Paginator<Comment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
@RECOMMENDATIONS
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct RecommendedVideo {
|
||||||
|
/// Unique YouTube video ID
|
||||||
|
pub id: String,
|
||||||
|
/// Video title
|
||||||
|
pub title: String,
|
||||||
|
/// Video length in seconds.
|
||||||
|
///
|
||||||
|
/// Is `None` for livestreams.
|
||||||
|
pub length: Option<u32>,
|
||||||
|
/// Video thumbnail
|
||||||
|
pub thumbnail: Vec<Thumbnail>,
|
||||||
|
/// Channel of the video
|
||||||
|
pub channel: Channel,
|
||||||
|
/// Video publishing date.
|
||||||
|
///
|
||||||
|
/// `None` if the date could not be parsed.
|
||||||
|
pub publish_date: Option<DateTime<Local>>,
|
||||||
|
/// Textual video publish date (e.g. `11 months ago`, depends on language)
|
||||||
|
///
|
||||||
|
/// Is `None` for livestreams.
|
||||||
|
pub publish_date_txt: Option<String>,
|
||||||
|
/// View count
|
||||||
|
///
|
||||||
|
/// Is `None` if it could not be parsed
|
||||||
|
pub view_count: Option<u64>,
|
||||||
|
/// Is the video an active livestream?
|
||||||
|
pub is_live: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Channel {
|
||||||
|
/// Unique YouTube channel ID
|
||||||
|
pub id: String,
|
||||||
|
/// Channel name
|
||||||
|
pub name: String,
|
||||||
|
/// Channel avatar/profile picture
|
||||||
|
pub avatar: Vec<Thumbnail>,
|
||||||
|
/// Channel verification mark
|
||||||
|
pub verification: Verification,
|
||||||
|
/// Approximate number of subscribers
|
||||||
|
///
|
||||||
|
/// Info: This is only present in the `VideoDetails` response
|
||||||
|
pub subscriber_count: Option<u32>,
|
||||||
|
/// Textual subscriber count (e.g `1.41M subscribers`, depends on language)
|
||||||
|
pub subscriber_count_txt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@COMMENTS
|
@COMMENTS
|
||||||
*/
|
*/
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct Channel {
|
#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||||
pub id: String,
|
#[serde(rename_all = "snake_case")]
|
||||||
pub name: String,
|
pub enum Verification {
|
||||||
pub avatars: Vec<Thumbnail>,
|
#[default]
|
||||||
pub verified: bool,
|
/// Unverified channel (default)
|
||||||
|
None,
|
||||||
|
/// Verified channel (✓ checkmark symbol)
|
||||||
|
Verified,
|
||||||
|
/// Verified music artist (♪ music note symbol)
|
||||||
|
Artist,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verification {
|
||||||
|
pub fn verified(&self) -> bool {
|
||||||
|
self != &Self::None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: impl popularity comparison
|
// TODO: impl popularity comparison
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
/// Unique YouTube Comment-ID (e.g. `UgynScMrsqGSL8qvePl4AaABAg`)
|
/// Unique YouTube Comment-ID (e.g. `UgynScMrsqGSL8qvePl4AaABAg`)
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
@ -239,16 +337,22 @@ pub struct Comment {
|
||||||
///
|
///
|
||||||
/// There may be comments with missing authors (possibly deleted users?).
|
/// There may be comments with missing authors (possibly deleted users?).
|
||||||
pub author: Option<Channel>,
|
pub author: Option<Channel>,
|
||||||
/// Number of upvotes
|
/// Comment publishing date.
|
||||||
pub upvotes: u32,
|
///
|
||||||
|
/// `None` if the date could not be parsed.
|
||||||
|
pub publish_date: Option<DateTime<Local>>,
|
||||||
|
/// Textual comment publish date (e.g. `14 hours ago`), depends on language setting
|
||||||
|
pub publish_date_txt: String,
|
||||||
|
/// Number of comment likes
|
||||||
|
pub like_count: Option<u32>,
|
||||||
/// Number of replies
|
/// Number of replies
|
||||||
pub n_replies: u32,
|
pub reply_count: u32,
|
||||||
/// Paginator to fetch comment replies
|
/// Paginator to fetch comment replies
|
||||||
pub replies: Paginator<Comment>,
|
pub replies: Paginator<Comment>,
|
||||||
/// Is the comment from the channel owner?
|
/// Is the comment from the channel owner?
|
||||||
pub by_owner: bool,
|
pub by_owner: bool,
|
||||||
/// Has the channel owner pinned the comment to the top?
|
/// Has the channel owner pinned the comment to the top?
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
/// Has the channel owner marked the comment with a ❤️ ?
|
/// Has the channel owner marked the comment with a ❤️ heart ?
|
||||||
pub hearted: bool,
|
pub hearted: bool,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
||||||
/// The paginator is a wrapper around a list of items that are fetched
|
/// The paginator is a wrapper around a list of items that are fetched
|
||||||
/// in pages from the YouTube API (e.g. playlist items,
|
/// in pages from the YouTube API (e.g. playlist items,
|
||||||
/// video recommendations or comments).
|
/// video recommendations or comments).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct Paginator<T> {
|
pub struct Paginator<T> {
|
||||||
/*
|
|
||||||
/// Total number of items if finite and known.
|
/// Total number of items if finite and known.
|
||||||
///
|
///
|
||||||
/// Note that this number may not be 100% accurate, as this is the
|
/// Note that this number may not be 100% accurate, as this is the
|
||||||
|
@ -18,7 +16,6 @@ pub struct Paginator<T> {
|
||||||
/// Don't use this number to check if all items were fetched or for
|
/// Don't use this number to check if all items were fetched or for
|
||||||
/// iterating over the items.
|
/// iterating over the items.
|
||||||
pub count: Option<u32>,
|
pub count: Option<u32>,
|
||||||
*/
|
|
||||||
/// Content of the paginator
|
/// Content of the paginator
|
||||||
pub items: Vec<T>,
|
pub items: Vec<T>,
|
||||||
/// The continuation token is passed to the YouTube API to fetch
|
/// The continuation token is passed to the YouTube API to fetch
|
||||||
|
@ -31,6 +28,7 @@ pub struct Paginator<T> {
|
||||||
impl<T> Default for Paginator<T> {
|
impl<T> Default for Paginator<T> {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
count: None,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
ctoken: None,
|
ctoken: None,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
use serde::{Deserialize, Deserializer};
|
use serde::{Deserialize, Deserializer};
|
||||||
use serde_with::{serde_as, DefaultOnError, DeserializeAs};
|
use serde_with::{serde_as, DefaultOnError, DeserializeAs};
|
||||||
|
|
||||||
|
@ -240,12 +243,35 @@ impl<'de> DeserializeAs<'de, Vec<TextLink>> for TextLinks {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
impl TryFrom<TextLink> for crate::model::ChannelId {
|
||||||
pub struct AccessibilityText {
|
type Error = anyhow::Error;
|
||||||
accessibility: AccessibilityData,
|
|
||||||
|
fn try_from(value: TextLink) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
TextLink::Browse {
|
||||||
|
text,
|
||||||
|
page_type,
|
||||||
|
browse_id,
|
||||||
|
} => match page_type {
|
||||||
|
PageType::Channel => Ok(crate::model::ChannelId {
|
||||||
|
id: browse_id,
|
||||||
|
name: text,
|
||||||
|
}),
|
||||||
|
_ => Err(anyhow!("invalid channel link type")),
|
||||||
|
},
|
||||||
|
_ => Err(anyhow!("invalid channel link")),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AccessibilityText {
|
||||||
|
accessibility_data: AccessibilityData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
struct AccessibilityData {
|
struct AccessibilityData {
|
||||||
label: String,
|
label: String,
|
||||||
}
|
}
|
||||||
|
@ -256,7 +282,7 @@ impl<'de> DeserializeAs<'de, String> for AccessibilityText {
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let text = AccessibilityText::deserialize(deserializer)?;
|
let text = AccessibilityText::deserialize(deserializer)?;
|
||||||
Ok(text.accessibility.label)
|
Ok(text.accessibility_data.label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -148,6 +148,18 @@ pub fn parse_timeago_to_dt(lang: Language, textual_date: &str) -> Option<DateTim
|
||||||
parse_timeago(lang, textual_date).map(|ta| ta.into())
|
parse_timeago(lang, textual_date).map(|ta| ta.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_timeago_or_warn(
|
||||||
|
lang: Language,
|
||||||
|
textual_date: &str,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
) -> Option<DateTime<Local>> {
|
||||||
|
let res = parse_timeago_to_dt(lang, textual_date);
|
||||||
|
if res.is_none() {
|
||||||
|
warnings.push(format!("could not parse timeago `{}`", textual_date));
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_textual_date(lang: Language, textual_date: &str) -> Option<ParsedDate> {
|
pub fn parse_textual_date(lang: Language, textual_date: &str) -> Option<ParsedDate> {
|
||||||
let entry = dictionary::entry(lang);
|
let entry = dictionary::entry(lang);
|
||||||
let filtered_str = filter_str(textual_date);
|
let filtered_str = filter_str(textual_date);
|
||||||
|
@ -198,6 +210,18 @@ pub fn parse_textual_date_to_dt(lang: Language, textual_date: &str) -> Option<Da
|
||||||
parse_textual_date(lang, textual_date).map(|ta| ta.into())
|
parse_textual_date(lang, textual_date).map(|ta| ta.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_textual_date_or_warn(
|
||||||
|
lang: Language,
|
||||||
|
textual_date: &str,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
) -> Option<DateTime<Local>> {
|
||||||
|
let res = parse_textual_date_to_dt(lang, textual_date);
|
||||||
|
if res.is_none() {
|
||||||
|
warnings.push(format!("could not parse timeago `{}`", textual_date));
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{collections::BTreeMap, fs::File, io::BufReader, path::Path};
|
use std::{collections::BTreeMap, fs::File, io::BufReader, path::Path};
|
||||||
|
|
99
src/util.rs
99
src/util.rs
|
@ -2,6 +2,7 @@ use std::{collections::BTreeMap, str::FromStr};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use fancy_regex::Regex;
|
use fancy_regex::Regex;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
@ -89,6 +90,48 @@ where
|
||||||
numbers
|
numbers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse textual video length (e.g. `0:49`, `2:02` or `1:48:18`)
|
||||||
|
/// and return the duration in seconds.
|
||||||
|
pub fn parse_video_length(text: &str) -> Option<u32> {
|
||||||
|
static VIDEO_LENGTH_REGEX: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r#"(?:(\d+):)?(\d{1,2}):(\d{2})"#).unwrap());
|
||||||
|
VIDEO_LENGTH_REGEX.captures(text).ok().flatten().map(|cap| {
|
||||||
|
let hrs = cap
|
||||||
|
.get(1)
|
||||||
|
.and_then(|x| x.as_str().parse::<u32>().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let min = cap
|
||||||
|
.get(2)
|
||||||
|
.and_then(|x| x.as_str().parse::<u32>().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let sec = cap
|
||||||
|
.get(3)
|
||||||
|
.and_then(|x| x.as_str().parse::<u32>().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
hrs * 3600 + min * 60 + sec
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_numeric_or_warn<F>(string: &str, warnings: &mut Vec<String>) -> Option<F>
|
||||||
|
where
|
||||||
|
F: FromStr,
|
||||||
|
{
|
||||||
|
let res = parse_numeric::<F>(string);
|
||||||
|
if res.is_err() {
|
||||||
|
warnings.push(format!("could not parse number `{}`", string));
|
||||||
|
}
|
||||||
|
res.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_video_length_or_warn(text: &str, warnings: &mut Vec<String>) -> Option<u32> {
|
||||||
|
let res = parse_video_length(text);
|
||||||
|
if res.is_none() {
|
||||||
|
warnings.push(format!("could not parse video length `{}`", text));
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
pub fn retry_delay(
|
pub fn retry_delay(
|
||||||
n_past_retries: u32,
|
n_past_retries: u32,
|
||||||
min_retry_interval: u32,
|
min_retry_interval: u32,
|
||||||
|
@ -104,6 +147,7 @@ pub fn retry_delay(
|
||||||
min_retry_interval.max(jittered_delay.min(max_retry_interval))
|
min_retry_interval.max(jittered_delay.min(max_retry_interval))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait TryRemove<T> {
|
||||||
/// Removes and returns the element at position `index` within the vector,
|
/// Removes and returns the element at position `index` within the vector,
|
||||||
/// shifting all elements after it to the left.
|
/// shifting all elements after it to the left.
|
||||||
///
|
///
|
||||||
|
@ -112,13 +156,7 @@ pub fn retry_delay(
|
||||||
/// Note: Because this shifts over the remaining elements, it has a
|
/// Note: Because this shifts over the remaining elements, it has a
|
||||||
/// worst-case performance of *O*(*n*). If you don't need the order of elements
|
/// worst-case performance of *O*(*n*). If you don't need the order of elements
|
||||||
/// to be preserved, use [`vec_try_swap_remove`] instead.
|
/// to be preserved, use [`vec_try_swap_remove`] instead.
|
||||||
pub fn vec_try_remove<T>(vec: &mut Vec<T>, index: usize) -> Option<T> {
|
fn try_remove(&mut self, index: usize) -> Option<T>;
|
||||||
if index < vec.len() {
|
|
||||||
Some(vec.remove(index))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes an element from the vector and returns it.
|
/// Removes an element from the vector and returns it.
|
||||||
///
|
///
|
||||||
|
@ -128,14 +166,27 @@ pub fn vec_try_remove<T>(vec: &mut Vec<T>, index: usize) -> Option<T> {
|
||||||
///
|
///
|
||||||
/// This does not preserve ordering, but is *O*(1).
|
/// This does not preserve ordering, but is *O*(1).
|
||||||
/// If you need to preserve the element order, use [`vec_try_remove`] instead.
|
/// If you need to preserve the element order, use [`vec_try_remove`] instead.
|
||||||
pub fn vec_try_swap_remove<T>(vec: &mut Vec<T>, index: usize) -> Option<T> {
|
fn try_swap_remove(&mut self, index: usize) -> Option<T>;
|
||||||
if index < vec.len() {
|
}
|
||||||
Some(vec.swap_remove(index))
|
|
||||||
|
impl<T> TryRemove<T> for Vec<T> {
|
||||||
|
fn try_remove(&mut self, index: usize) -> Option<T> {
|
||||||
|
if index < self.len() {
|
||||||
|
Some(self.remove(index))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn try_swap_remove(&mut self, index: usize) -> Option<T> {
|
||||||
|
if index < self.len() {
|
||||||
|
Some(self.swap_remove(index))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
|
@ -159,6 +210,18 @@ mod tests {
|
||||||
assert_eq!(n, expect);
|
assert_eq!(n, expect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case("0:49", Some(49))]
|
||||||
|
#[case("bla 2:02 h3llo w0rld", Some(122))]
|
||||||
|
#[case("18:22", Some(1102))]
|
||||||
|
#[case("1:48:18", Some(6498))]
|
||||||
|
#[case("102:12:39", Some(367959))]
|
||||||
|
#[case("42", None)]
|
||||||
|
fn t_parse_video_length(#[case] text: &str, #[case] expect: Option<u32>) {
|
||||||
|
let n = parse_video_length(text);
|
||||||
|
assert_eq!(n, expect);
|
||||||
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[case(0, 800, 1500)]
|
#[case(0, 800, 1500)]
|
||||||
#[case(1, 2400, 4500)]
|
#[case(1, 2400, 4500)]
|
||||||
|
@ -174,4 +237,20 @@ mod tests {
|
||||||
expect_max
|
expect_max
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_vec_try_remove() {
|
||||||
|
let mut v = vec![1, 2, 3];
|
||||||
|
assert_eq!(v.try_remove(0).unwrap(), 1);
|
||||||
|
assert_eq!(v.try_remove(1).unwrap(), 3);
|
||||||
|
assert_eq!(v.try_remove(1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_vec_try_swap_remove() {
|
||||||
|
let mut v = vec![1, 2, 3];
|
||||||
|
assert_eq!(v.try_swap_remove(0).unwrap(), 1);
|
||||||
|
assert_eq!(v.try_swap_remove(1).unwrap(), 2);
|
||||||
|
assert_eq!(v.try_swap_remove(1), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
12846
testfiles/video_details/comments_top.json
Normal file
12846
testfiles/video_details/comments_top.json
Normal file
File diff suppressed because it is too large
Load diff
12728
testfiles/video_details/comments_top_with_count.json
Normal file
12728
testfiles/video_details/comments_top_with_count.json
Normal file
File diff suppressed because it is too large
Load diff
10215
testfiles/video_details/video_details_agegate.json
Normal file
10215
testfiles/video_details/video_details_agegate.json
Normal file
File diff suppressed because it is too large
Load diff
13192
testfiles/video_details/video_details_live.json
Normal file
13192
testfiles/video_details/video_details_live.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue