feat: add video details response model
- add paginator, impl for playlist items - small model refactor - add ignore_any deserializer - removed unnecessary clones in response mapping
This commit is contained in:
parent
17b6844eb0
commit
972288d810
32 changed files with 61791 additions and 5316 deletions
|
|
@ -60,7 +60,7 @@ async fn download_single_video(
|
|||
let res = async {
|
||||
let player_data = rp
|
||||
.query()
|
||||
.get_player(video_id.as_str(), ClientType::TvHtml5Embed)
|
||||
.player(video_id.as_str(), ClientType::TvHtml5Embed)
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to fetch player data for video {}",
|
||||
|
|
@ -89,7 +89,7 @@ async fn download_single_video(
|
|||
.await
|
||||
.context(format!(
|
||||
"Failed to download video '{}' [{}]",
|
||||
player_data.info.title, video_id
|
||||
player_data.details.title, video_id
|
||||
))
|
||||
}
|
||||
.await;
|
||||
|
|
@ -149,7 +149,7 @@ async fn download_playlist(
|
|||
.expect("unable to build the HTTP client");
|
||||
|
||||
let rp = RustyPipe::default();
|
||||
let playlist = rp.query().get_playlist(id).await.unwrap();
|
||||
let playlist = rp.query().playlist(id).await.unwrap();
|
||||
|
||||
// Indicatif setup
|
||||
let multi = MultiProgress::new();
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ pub async fn collect_dates(project_root: &Path, concurrency: usize) {
|
|||
let mut map: BTreeMap<DateCase, String> = BTreeMap::new();
|
||||
|
||||
for (case, pl_id) in cases {
|
||||
let playlist = rp.query().lang(lang).get_playlist(pl_id).await.unwrap();
|
||||
let playlist = rp.query().lang(lang).playlist(pl_id).await.unwrap();
|
||||
map.insert(case, playlist.last_update_txt.unwrap());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@ use rustypipe::{
|
|||
report::{Report, Reporter},
|
||||
};
|
||||
|
||||
pub async fn download_testfiles(project_root: &Path) {
|
||||
let mut testfiles = project_root.to_path_buf();
|
||||
testfiles.push("testfiles");
|
||||
|
||||
tokio::join!(
|
||||
player(&testfiles),
|
||||
player_model(&testfiles),
|
||||
playlist(&testfiles),
|
||||
video_details(&testfiles),
|
||||
);
|
||||
}
|
||||
|
||||
const CLIENT_TYPES: [ClientType; 5] = [
|
||||
ClientType::Desktop,
|
||||
ClientType::DesktopMusic,
|
||||
|
|
@ -31,6 +43,10 @@ impl TestFileReporter {
|
|||
|
||||
impl Reporter for TestFileReporter {
|
||||
fn report(&self, report: &Report) {
|
||||
let mut root = self.path.clone();
|
||||
root.set_file_name("");
|
||||
std::fs::create_dir_all(root).unwrap();
|
||||
|
||||
let data =
|
||||
serde_json::from_str::<serde_json::Value>(&report.http_request.resp_body).unwrap();
|
||||
let file = File::create(&self.path).unwrap();
|
||||
|
|
@ -49,17 +65,6 @@ fn rp_testfile(json_path: &Path) -> RustyPipe {
|
|||
.build()
|
||||
}
|
||||
|
||||
pub async fn download_testfiles(project_root: &Path) {
|
||||
let mut testfiles = project_root.to_path_buf();
|
||||
testfiles.push("testfiles");
|
||||
|
||||
tokio::join!(
|
||||
player(&testfiles),
|
||||
player_model(&testfiles),
|
||||
playlist(&testfiles)
|
||||
);
|
||||
}
|
||||
|
||||
async fn player(testfiles: &Path) {
|
||||
let video_id = "pPvd8UxmSbQ";
|
||||
|
||||
|
|
@ -73,7 +78,7 @@ async fn player(testfiles: &Path) {
|
|||
}
|
||||
|
||||
let rp = rp_testfile(&json_path);
|
||||
rp.query().get_player(video_id, client_type).await.unwrap();
|
||||
rp.query().player(video_id, client_type).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,11 +94,7 @@ async fn player_model(testfiles: &Path) {
|
|||
continue;
|
||||
}
|
||||
|
||||
let player_data = rp
|
||||
.query()
|
||||
.get_player(id, ClientType::Desktop)
|
||||
.await
|
||||
.unwrap();
|
||||
let player_data = rp.query().player(id, ClientType::Desktop).await.unwrap();
|
||||
let file = File::create(&json_path).unwrap();
|
||||
serde_json::to_writer_pretty(file, &player_data).unwrap();
|
||||
|
||||
|
|
@ -115,6 +116,32 @@ async fn playlist(testfiles: &Path) {
|
|||
}
|
||||
|
||||
let rp = rp_testfile(&json_path);
|
||||
rp.query().get_playlist(id).await.unwrap();
|
||||
rp.query().playlist(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn video_details(testfiles: &Path) {
|
||||
for (name, id) in [
|
||||
("music", "MZOgTu2dMTg"),
|
||||
("mv", "ZeerrnuLi5E"),
|
||||
("ccommons", "0rb9CfOvojk"),
|
||||
("chapters", "nFDBxBUfE74"),
|
||||
] {
|
||||
let mut json_path = testfiles.to_path_buf();
|
||||
json_path.push("video_details");
|
||||
json_path.push(format!("video_details_{}.json", name));
|
||||
println!("{}", json_path.display());
|
||||
if json_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rp = rp_testfile(&json_path);
|
||||
rp.query().video_details(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn x() {
|
||||
video_details(Path::new("../testfiles")).await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod player;
|
||||
pub mod playlist;
|
||||
pub mod video_details;
|
||||
|
||||
mod response;
|
||||
|
||||
|
|
@ -12,9 +13,7 @@ use fancy_regex::Regex;
|
|||
use log::{error, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::Rng;
|
||||
use reqwest::{
|
||||
header, Client, ClientBuilder, Method, Request, RequestBuilder, Response, StatusCode,
|
||||
};
|
||||
use reqwest::{header, Client, ClientBuilder, Method, Request, RequestBuilder, Response};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
|
|
@ -315,7 +314,7 @@ impl RustyPipeBuilder {
|
|||
rand::thread_rng().gen_range(100..1000)
|
||||
),
|
||||
cache: Mutex::new(cache),
|
||||
default_opts: RustyPipeOpts::default(),
|
||||
default_opts: self.default_opts,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -455,6 +454,7 @@ impl RustyPipe {
|
|||
if status.is_success() || !status.is_server_error() {
|
||||
return res;
|
||||
}
|
||||
// TODO: handle 429 (captcha)
|
||||
status.to_string()
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ use serde::Serialize;
|
|||
use crate::{
|
||||
deobfuscate::Deobfuscator,
|
||||
model::{
|
||||
AudioCodec, AudioFormat, AudioStream, AudioTrack, Channel, Language, Subtitle, VideoCodec,
|
||||
VideoFormat, VideoInfo, VideoPlayer, VideoStream,
|
||||
AudioCodec, AudioFormat, AudioStream, AudioTrack, ChannelId, Language, Subtitle,
|
||||
VideoCodec, VideoFormat, VideoPlayer, VideoPlayerDetails, VideoStream,
|
||||
},
|
||||
util,
|
||||
};
|
||||
|
|
@ -58,7 +58,7 @@ struct QContentPlaybackContext {
|
|||
}
|
||||
|
||||
impl RustyPipeQuery {
|
||||
pub async fn get_player(self, video_id: &str, client_type: ClientType) -> Result<VideoPlayer> {
|
||||
pub async fn player(self, video_id: &str, client_type: ClientType) -> Result<VideoPlayer> {
|
||||
let q1 = self.clone();
|
||||
let t_context = tokio::spawn(async move { q1.get_context(client_type, false).await });
|
||||
let q2 = self.client.clone();
|
||||
|
|
@ -95,7 +95,7 @@ impl RustyPipeQuery {
|
|||
|
||||
self.execute_request_deobf::<response::Player, _, _>(
|
||||
client_type,
|
||||
"get_player",
|
||||
"player",
|
||||
video_id,
|
||||
Method::POST,
|
||||
"player",
|
||||
|
|
@ -169,13 +169,13 @@ impl MapResponse<VideoPlayer> for response::Player {
|
|||
);
|
||||
}
|
||||
|
||||
let video_info = VideoInfo {
|
||||
let video_info = VideoPlayerDetails {
|
||||
id: video_details.video_id,
|
||||
title: video_details.title,
|
||||
description: video_details.short_description,
|
||||
length: video_details.length_seconds,
|
||||
thumbnails: video_details.thumbnail.unwrap_or_default().into(),
|
||||
channel: Channel {
|
||||
channel: ChannelId {
|
||||
id: video_details.channel_id,
|
||||
name: video_details.author,
|
||||
},
|
||||
|
|
@ -196,7 +196,7 @@ impl MapResponse<VideoPlayer> for response::Player {
|
|||
warnings.append(&mut streaming_data.formats.warnings);
|
||||
warnings.append(&mut streaming_data.adaptive_formats.warnings);
|
||||
|
||||
let mut last_nsig: [String; 2] = ["".to_owned(), "".to_owned()];
|
||||
let mut last_nsig: [String; 2] = [String::new(), String::new()];
|
||||
|
||||
let mut video_streams: Vec<VideoStream> = Vec::new();
|
||||
let mut video_only_streams: Vec<VideoStream> = Vec::new();
|
||||
|
|
@ -237,23 +237,26 @@ impl MapResponse<VideoPlayer> for response::Player {
|
|||
video_only_streams.sort();
|
||||
audio_streams.sort();
|
||||
|
||||
let mut subtitles = vec![];
|
||||
if let Some(captions) = self.captions {
|
||||
for c in captions.player_captions_tracklist_renderer.caption_tracks {
|
||||
let lang_auto = c.name.strip_suffix(" (auto-generated)");
|
||||
|
||||
subtitles.push(Subtitle {
|
||||
url: c.base_url,
|
||||
lang: c.language_code,
|
||||
lang_name: lang_auto.unwrap_or(&c.name).to_owned(),
|
||||
auto_generated: lang_auto.is_some(),
|
||||
let subtitles = self.captions.map_or(Vec::new(), |captions| {
|
||||
captions
|
||||
.player_captions_tracklist_renderer
|
||||
.caption_tracks
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let lang_auto = c.name.strip_suffix(" (auto-generated)");
|
||||
Subtitle {
|
||||
url: c.base_url,
|
||||
lang: c.language_code,
|
||||
lang_name: lang_auto.unwrap_or(&c.name).to_owned(),
|
||||
auto_generated: lang_auto.is_some(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.collect()
|
||||
});
|
||||
|
||||
Ok(MapResult {
|
||||
c: VideoPlayer {
|
||||
info: video_info,
|
||||
details: video_info,
|
||||
video_streams,
|
||||
video_only_streams,
|
||||
audio_streams,
|
||||
|
|
@ -599,7 +602,7 @@ mod tests {
|
|||
);
|
||||
let is_desktop = name == "desktop" || name == "desktopmusic";
|
||||
insta::assert_yaml_snapshot!(format!("map_player_data_{}", name), map_res.c, {
|
||||
".info.publish_date" => insta::dynamic_redaction(move |value, _path| {
|
||||
".details.publish_date" => insta::dynamic_redaction(move |value, _path| {
|
||||
if is_desktop {
|
||||
assert!(value.as_str().unwrap().starts_with("2019-05-30T00:00:00"));
|
||||
"2019-05-30T00:00:00"
|
||||
|
|
@ -632,40 +635,36 @@ mod tests {
|
|||
#[test_log::test(tokio::test)]
|
||||
async fn t_get_player(#[case] client_type: ClientType) {
|
||||
let rp = RustyPipe::builder().strict().build();
|
||||
let player_data = rp
|
||||
.query()
|
||||
.get_player("n4tK7LYFxI0", client_type)
|
||||
.await
|
||||
.unwrap();
|
||||
let player_data = rp.query().player("n4tK7LYFxI0", client_type).await.unwrap();
|
||||
|
||||
// dbg!(&player_data);
|
||||
|
||||
assert_eq!(player_data.info.id, "n4tK7LYFxI0");
|
||||
assert_eq!(player_data.info.title, "Spektrem - Shine [NCS Release]");
|
||||
assert_eq!(player_data.details.id, "n4tK7LYFxI0");
|
||||
assert_eq!(player_data.details.title, "Spektrem - Shine [NCS Release]");
|
||||
if client_type == ClientType::DesktopMusic {
|
||||
assert!(player_data.info.description.is_none());
|
||||
assert!(player_data.details.description.is_none());
|
||||
} else {
|
||||
assert!(player_data.info.description.unwrap().starts_with(
|
||||
assert!(player_data.details.description.unwrap().starts_with(
|
||||
"NCS (NoCopyrightSounds): Empowering Creators through Copyright / Royalty Free Music"
|
||||
));
|
||||
}
|
||||
assert_eq!(player_data.info.length, 259);
|
||||
assert!(!player_data.info.thumbnails.is_empty());
|
||||
assert_eq!(player_data.info.channel.id, "UC_aEa8K-EOJ3D6gOs7HcyNg");
|
||||
assert_eq!(player_data.info.channel.name, "NoCopyrightSounds");
|
||||
assert!(player_data.info.view_count > 146818808);
|
||||
assert_eq!(player_data.info.keywords[0], "spektrem");
|
||||
assert_eq!(player_data.info.is_live_content, false);
|
||||
assert_eq!(player_data.details.length, 259);
|
||||
assert!(!player_data.details.thumbnails.is_empty());
|
||||
assert_eq!(player_data.details.channel.id, "UC_aEa8K-EOJ3D6gOs7HcyNg");
|
||||
assert_eq!(player_data.details.channel.name, "NoCopyrightSounds");
|
||||
assert!(player_data.details.view_count > 146818808);
|
||||
assert_eq!(player_data.details.keywords[0], "spektrem");
|
||||
assert_eq!(player_data.details.is_live_content, false);
|
||||
|
||||
if client_type == ClientType::Desktop || client_type == ClientType::DesktopMusic {
|
||||
assert!(player_data
|
||||
.info
|
||||
.details
|
||||
.publish_date
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.starts_with("2013-05-05 00:00:00"));
|
||||
assert_eq!(player_data.info.category.unwrap(), "Music");
|
||||
assert_eq!(player_data.info.is_family_safe.unwrap(), true);
|
||||
assert_eq!(player_data.details.category.unwrap(), "Music");
|
||||
assert_eq!(player_data.details.is_family_safe.unwrap(), true);
|
||||
}
|
||||
|
||||
if client_type == ClientType::Ios {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde::Serialize;
|
|||
|
||||
use crate::{
|
||||
deobfuscate::Deobfuscator,
|
||||
model::{Channel, Language, Playlist, Thumbnail, Video},
|
||||
model::{ChannelId, Language, Paginator, Playlist, PlaylistVideo},
|
||||
serializer::text::{PageType, TextLink},
|
||||
timeago, util,
|
||||
};
|
||||
|
|
@ -26,7 +26,7 @@ struct QPlaylistCont {
|
|||
}
|
||||
|
||||
impl RustyPipeQuery {
|
||||
pub async fn get_playlist(self, playlist_id: &str) -> Result<Playlist> {
|
||||
pub async fn playlist(self, playlist_id: &str) -> Result<Playlist> {
|
||||
let context = self.get_context(ClientType::Desktop, true).await;
|
||||
let request_body = QPlaylist {
|
||||
context,
|
||||
|
|
@ -35,7 +35,7 @@ impl RustyPipeQuery {
|
|||
|
||||
self.execute_request::<response::Playlist, _, _>(
|
||||
ClientType::Desktop,
|
||||
"get_playlist",
|
||||
"playlist",
|
||||
playlist_id,
|
||||
Method::POST,
|
||||
"browse",
|
||||
|
|
@ -44,37 +44,22 @@ impl RustyPipeQuery {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn get_playlist_cont(self, playlist: &mut Playlist) -> Result<()> {
|
||||
match &playlist.ctoken {
|
||||
Some(ctoken) => {
|
||||
let context = self.get_context(ClientType::Desktop, true).await;
|
||||
let request_body = QPlaylistCont {
|
||||
context,
|
||||
continuation: ctoken.to_owned(),
|
||||
};
|
||||
pub async fn get_playlist_continuation(self, ctoken: &str) -> Result<Paginator<PlaylistVideo>> {
|
||||
let context = self.get_context(ClientType::Desktop, true).await;
|
||||
let request_body = QPlaylistCont {
|
||||
context,
|
||||
continuation: ctoken.to_owned(),
|
||||
};
|
||||
|
||||
let (mut videos, ctoken) = self
|
||||
.execute_request::<response::PlaylistCont, _, _>(
|
||||
ClientType::Desktop,
|
||||
"get_playlist_cont",
|
||||
&playlist.id,
|
||||
Method::POST,
|
||||
"browse",
|
||||
&request_body,
|
||||
)
|
||||
.await?;
|
||||
|
||||
playlist.videos.append(&mut videos);
|
||||
playlist.ctoken = ctoken;
|
||||
|
||||
if playlist.ctoken.is_none() {
|
||||
playlist.n_videos = playlist.videos.len() as u32;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
None => Err(anyhow!("no ctoken")),
|
||||
}
|
||||
self.execute_request::<response::PlaylistCont, _, _>(
|
||||
ClientType::Desktop,
|
||||
"get_playlist_continuation",
|
||||
ctoken,
|
||||
Method::POST,
|
||||
"browse",
|
||||
&request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,85 +70,72 @@ impl MapResponse<Playlist> for response::Playlist {
|
|||
lang: Language,
|
||||
_deobf: Option<&Deobfuscator>,
|
||||
) -> Result<MapResult<Playlist>> {
|
||||
let video_items = &some_or_bail!(
|
||||
some_or_bail!(
|
||||
some_or_bail!(
|
||||
self.contents
|
||||
.two_column_browse_results_renderer
|
||||
.contents
|
||||
.get(0),
|
||||
Err(anyhow!("twoColumnBrowseResultsRenderer empty"))
|
||||
// TODO: think about a deserializer that deserializes only first list item
|
||||
let mut tcbr_contents = self.contents.two_column_browse_results_renderer.contents;
|
||||
let video_items = some_or_bail!(
|
||||
util::vec_try_swap_remove(
|
||||
&mut some_or_bail!(
|
||||
util::vec_try_swap_remove(
|
||||
&mut some_or_bail!(
|
||||
util::vec_try_swap_remove(&mut tcbr_contents, 0),
|
||||
Err(anyhow!("twoColumnBrowseResultsRenderer empty"))
|
||||
)
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents,
|
||||
0,
|
||||
),
|
||||
Err(anyhow!("sectionListRenderer empty"))
|
||||
)
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents
|
||||
.get(0),
|
||||
Err(anyhow!("sectionListRenderer empty"))
|
||||
)
|
||||
.item_section_renderer
|
||||
.contents
|
||||
.get(0),
|
||||
.item_section_renderer
|
||||
.contents,
|
||||
0
|
||||
),
|
||||
Err(anyhow!("itemSectionRenderer empty"))
|
||||
)
|
||||
.playlist_video_list_renderer
|
||||
.contents;
|
||||
|
||||
let (videos, ctoken) = map_playlist_items(&video_items.c);
|
||||
let (videos, ctoken) = map_playlist_items(video_items.c);
|
||||
|
||||
let (thumbnails, last_update_txt) = match &self.sidebar {
|
||||
let (thumbnails, last_update_txt) = match self.sidebar {
|
||||
Some(sidebar) => {
|
||||
let primary = some_or_bail!(
|
||||
sidebar.playlist_sidebar_renderer.items.get(0),
|
||||
let mut sidebar_items = sidebar.playlist_sidebar_renderer.items;
|
||||
let mut primary = some_or_bail!(
|
||||
util::vec_try_swap_remove(&mut sidebar_items, 0),
|
||||
Err(anyhow!("no primary sidebar"))
|
||||
);
|
||||
|
||||
(
|
||||
&primary
|
||||
primary
|
||||
.playlist_sidebar_primary_info_renderer
|
||||
.thumbnail_renderer
|
||||
.playlist_video_thumbnail_renderer
|
||||
.thumbnail
|
||||
.thumbnails,
|
||||
primary
|
||||
.playlist_sidebar_primary_info_renderer
|
||||
.stats
|
||||
.get(2)
|
||||
.map(|t| t.to_owned()),
|
||||
.thumbnail,
|
||||
util::vec_try_swap_remove(
|
||||
&mut primary.playlist_sidebar_primary_info_renderer.stats,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let header_banner = some_or_bail!(
|
||||
&self.header.playlist_header_renderer.playlist_header_banner,
|
||||
self.header.playlist_header_renderer.playlist_header_banner,
|
||||
Err(anyhow!("no thumbnail found"))
|
||||
);
|
||||
|
||||
let last_update_txt = self
|
||||
.header
|
||||
.playlist_header_renderer
|
||||
.byline
|
||||
.get(1)
|
||||
.map(|b| b.playlist_byline_renderer.text.to_owned());
|
||||
let mut byline = self.header.playlist_header_renderer.byline;
|
||||
let last_update_txt = util::vec_try_swap_remove(&mut byline, 1)
|
||||
.map(|b| b.playlist_byline_renderer.text);
|
||||
|
||||
(
|
||||
&header_banner
|
||||
.hero_playlist_thumbnail_renderer
|
||||
.thumbnail
|
||||
.thumbnails,
|
||||
header_banner.hero_playlist_thumbnail_renderer.thumbnail,
|
||||
last_update_txt,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let thumbnails = thumbnails
|
||||
.iter()
|
||||
.map(|t| Thumbnail {
|
||||
url: t.url.to_owned(),
|
||||
width: t.width,
|
||||
height: t.height,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let n_videos = match ctoken {
|
||||
Some(_) => {
|
||||
ok_or_bail!(
|
||||
|
|
@ -187,14 +159,14 @@ impl MapResponse<Playlist> for response::Playlist {
|
|||
text,
|
||||
page_type: PageType::Channel,
|
||||
browse_id,
|
||||
}) => Some(Channel {
|
||||
}) => Some(ChannelId {
|
||||
id: browse_id,
|
||||
name: text,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut warnings = video_items.warnings.to_owned();
|
||||
let mut warnings = video_items.warnings;
|
||||
let last_update = match &last_update_txt {
|
||||
Some(textual_date) => {
|
||||
let parsed = timeago::parse_textual_date_to_dt(lang, textual_date);
|
||||
|
|
@ -210,10 +182,12 @@ impl MapResponse<Playlist> for response::Playlist {
|
|||
c: Playlist {
|
||||
id: playlist_id,
|
||||
name,
|
||||
videos,
|
||||
videos: Paginator {
|
||||
items: videos,
|
||||
ctoken,
|
||||
},
|
||||
n_videos,
|
||||
ctoken,
|
||||
thumbnails,
|
||||
thumbnails: thumbnails.into(),
|
||||
description,
|
||||
channel,
|
||||
last_update,
|
||||
|
|
@ -224,60 +198,52 @@ impl MapResponse<Playlist> for response::Playlist {
|
|||
}
|
||||
}
|
||||
|
||||
impl MapResponse<(Vec<Video>, Option<String>)> for response::PlaylistCont {
|
||||
impl MapResponse<Paginator<PlaylistVideo>> for response::PlaylistCont {
|
||||
fn map_response(
|
||||
self,
|
||||
id: &str,
|
||||
_id: &str,
|
||||
_lang: Language,
|
||||
_deobf: Option<&Deobfuscator>,
|
||||
) -> Result<MapResult<(Vec<Video>, Option<String>)>> {
|
||||
) -> Result<MapResult<Paginator<PlaylistVideo>>> {
|
||||
let mut actions = self.on_response_received_actions;
|
||||
let action = some_or_bail!(
|
||||
self.on_response_received_actions
|
||||
.iter()
|
||||
.find(|a| a.append_continuation_items_action.target_id == id),
|
||||
util::vec_try_swap_remove(&mut actions, 0),
|
||||
Err(anyhow!("no continuation action"))
|
||||
);
|
||||
|
||||
let (items, ctoken) =
|
||||
map_playlist_items(action.append_continuation_items_action.continuation_items.c);
|
||||
|
||||
Ok(MapResult {
|
||||
c: map_playlist_items(&action.append_continuation_items_action.continuation_items.c),
|
||||
c: Paginator { items, ctoken },
|
||||
warnings: action
|
||||
.append_continuation_items_action
|
||||
.continuation_items
|
||||
.warnings
|
||||
.to_owned(),
|
||||
.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn map_playlist_items(
|
||||
items: &[response::VideoListItem<response::playlist::PlaylistVideo>],
|
||||
) -> (Vec<Video>, Option<String>) {
|
||||
items: Vec<response::VideoListItem<response::playlist::PlaylistVideo>>,
|
||||
) -> (Vec<PlaylistVideo>, Option<String>) {
|
||||
let mut ctoken: Option<String> = None;
|
||||
let videos = items
|
||||
.iter()
|
||||
.into_iter()
|
||||
.filter_map(|it| match it {
|
||||
response::VideoListItem::GridVideoRenderer { video } => match &video.channel {
|
||||
response::VideoListItem::GridVideoRenderer { video } => match video.channel {
|
||||
TextLink::Browse {
|
||||
text,
|
||||
page_type: PageType::Channel,
|
||||
browse_id,
|
||||
} => Some(Video {
|
||||
id: video.video_id.to_owned(),
|
||||
title: video.title.to_owned(),
|
||||
} => Some(PlaylistVideo {
|
||||
id: video.video_id,
|
||||
title: video.title,
|
||||
length: video.length_seconds,
|
||||
thumbnails: video
|
||||
.thumbnail
|
||||
.thumbnails
|
||||
.iter()
|
||||
.map(|t| Thumbnail {
|
||||
url: t.url.to_owned(),
|
||||
width: t.width,
|
||||
height: t.height,
|
||||
})
|
||||
.collect(),
|
||||
channel: Channel {
|
||||
id: browse_id.to_string(),
|
||||
name: text.to_owned(),
|
||||
thumbnails: video.thumbnail.into(),
|
||||
channel: ChannelId {
|
||||
id: browse_id,
|
||||
name: text,
|
||||
},
|
||||
}),
|
||||
_ => None,
|
||||
|
|
@ -285,14 +251,66 @@ fn map_playlist_items(
|
|||
response::VideoListItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => {
|
||||
ctoken = Some(continuation_endpoint.continuation_command.token.to_owned());
|
||||
ctoken = Some(continuation_endpoint.continuation_command.token);
|
||||
None
|
||||
}
|
||||
response::VideoListItem::None => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(videos, ctoken)
|
||||
}
|
||||
|
||||
impl Paginator<PlaylistVideo> {
|
||||
pub async fn next(&self, query: RustyPipeQuery) -> Result<Self, crate::error::Error> {
|
||||
match &self.ctoken {
|
||||
Some(ctoken) => Ok(query.get_playlist_continuation(ctoken).await?),
|
||||
None => Err(crate::error::Error::PaginatorExhausted),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extend(&mut self, query: RustyPipeQuery) -> Result<(), crate::error::Error> {
|
||||
match self.next(query).await {
|
||||
Ok(paginator) => {
|
||||
let mut items = paginator.items;
|
||||
self.items.append(&mut items);
|
||||
self.ctoken = paginator.ctoken;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extend_pages(&mut self, query: RustyPipeQuery, n_pages: usize) -> Result<()> {
|
||||
for _ in 0..n_pages {
|
||||
match self.extend(query.clone()).await {
|
||||
Err(crate::error::Error::PaginatorExhausted) => {
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn extend_limit(&mut self, query: RustyPipeQuery, n_items: usize) -> Result<()> {
|
||||
while self.items.len() < n_items {
|
||||
match self.extend(query.clone()).await {
|
||||
Err(crate::error::Error::PaginatorExhausted) => {
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::BufReader, path::Path};
|
||||
|
|
@ -309,7 +327,7 @@ mod tests {
|
|||
"Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022",
|
||||
true,
|
||||
None,
|
||||
Some(Channel {
|
||||
Some(ChannelId {
|
||||
id: "UCIekuFeMaV78xYfvpmoCnPg".to_owned(),
|
||||
name: "Best Music".to_owned(),
|
||||
})
|
||||
|
|
@ -326,7 +344,7 @@ mod tests {
|
|||
"Minecraft SHINE",
|
||||
false,
|
||||
Some("SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben...".to_owned()),
|
||||
Some(Channel {
|
||||
Some(ChannelId {
|
||||
id: "UCQM0bS4_04-Y4JuYrgmnpZQ".to_owned(),
|
||||
name: "Chaosflo44".to_owned(),
|
||||
})
|
||||
|
|
@ -337,15 +355,15 @@ mod tests {
|
|||
#[case] name: &str,
|
||||
#[case] is_long: bool,
|
||||
#[case] description: Option<String>,
|
||||
#[case] channel: Option<Channel>,
|
||||
#[case] channel: Option<ChannelId>,
|
||||
) {
|
||||
let rp = RustyPipe::builder().strict().build();
|
||||
let playlist = rp.query().get_playlist(id).await.unwrap();
|
||||
let playlist = rp.query().playlist(id).await.unwrap();
|
||||
|
||||
assert_eq!(playlist.id, id);
|
||||
assert_eq!(playlist.name, name);
|
||||
assert!(!playlist.videos.is_empty());
|
||||
assert_eq!(playlist.ctoken.is_some(), is_long);
|
||||
assert_eq!(!playlist.videos.is_exhausted(), is_long);
|
||||
assert!(playlist.n_videos > 10);
|
||||
assert_eq!(playlist.n_videos > 100, is_long);
|
||||
assert_eq!(playlist.description, description);
|
||||
|
|
@ -383,14 +401,32 @@ mod tests {
|
|||
let rp = RustyPipe::builder().strict().build();
|
||||
let mut playlist = rp
|
||||
.query()
|
||||
.get_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi")
|
||||
.playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
while playlist.ctoken.is_some() {
|
||||
rp.query().get_playlist_cont(&mut playlist).await.unwrap();
|
||||
}
|
||||
playlist
|
||||
.videos
|
||||
.extend_pages(rp.query(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(playlist.videos.items.len() > 100);
|
||||
}
|
||||
|
||||
assert!(playlist.videos.len() > 100);
|
||||
#[test_log::test(tokio::test)]
|
||||
async fn t_playlist_cont2() {
|
||||
let rp = RustyPipe::builder().strict().build();
|
||||
let mut playlist = rp
|
||||
.query()
|
||||
.playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
playlist
|
||||
.videos
|
||||
.extend_limit(rp.query(), 101)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(playlist.videos.items.len() > 100);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,24 @@ pub mod channel;
|
|||
pub mod player;
|
||||
pub mod playlist;
|
||||
pub mod playlist_music;
|
||||
pub mod video;
|
||||
pub mod video_details;
|
||||
|
||||
pub use channel::Channel;
|
||||
pub use player::Player;
|
||||
pub use playlist::Playlist;
|
||||
pub use playlist::PlaylistCont;
|
||||
pub use playlist_music::PlaylistMusic;
|
||||
pub use video::Video;
|
||||
pub use video::VideoComments;
|
||||
pub use video::VideoRecommendations;
|
||||
pub use video_details::VideoComments;
|
||||
pub use video_details::VideoDetails;
|
||||
pub use video_details::VideoRecommendations;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::serializer::text::{Text, TextLink, TextLinks};
|
||||
use crate::serializer::{
|
||||
ignore_any,
|
||||
text::{Text, TextLink, TextLinks},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -64,6 +67,9 @@ pub enum VideoListItem<T> {
|
|||
ContinuationItemRenderer {
|
||||
continuation_endpoint: ContinuationEndpoint,
|
||||
},
|
||||
/// No video list item (e.g. ad)
|
||||
#[serde(other, deserialize_with = "ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
@ -215,6 +221,10 @@ pub struct MusicContinuationData {
|
|||
pub continuation: String,
|
||||
}
|
||||
|
||||
/*
|
||||
#MAPPING
|
||||
*/
|
||||
|
||||
impl From<Thumbnail> for crate::model::Thumbnail {
|
||||
fn from(tn: Thumbnail) -> Self {
|
||||
crate::model::Thumbnail {
|
||||
|
|
@ -227,10 +237,13 @@ impl From<Thumbnail> for crate::model::Thumbnail {
|
|||
|
||||
impl From<Thumbnails> for Vec<crate::model::Thumbnail> {
|
||||
fn from(ts: Thumbnails) -> Self {
|
||||
let mut thumbnails = vec![];
|
||||
for t in ts.thumbnails {
|
||||
thumbnails.push(t.into());
|
||||
}
|
||||
thumbnails
|
||||
ts.thumbnails
|
||||
.into_iter()
|
||||
.map(|t| crate::model::Thumbnail {
|
||||
url: t.url,
|
||||
width: t.width,
|
||||
height: t.height,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,9 +148,9 @@ pub struct SidebarItemPrimary {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SidebarPrimaryInfoRenderer {
|
||||
pub thumbnail_renderer: PlaylistThumbnailRenderer,
|
||||
// - `"495", " videos"`
|
||||
// - `"3,310,996 views"`
|
||||
// - `"Last updated on ", "Aug 7, 2022"`
|
||||
/// - `"495", " videos"`
|
||||
/// - `"3,310,996 views"`
|
||||
/// - `"Last updated on ", "Aug 7, 2022"`
|
||||
#[serde_as(as = "Vec<Text>")]
|
||||
pub stats: Vec<String>,
|
||||
}
|
||||
|
|
@ -175,5 +175,4 @@ pub struct OnResponseReceivedAction {
|
|||
pub struct AppendAction {
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub continuation_items: MapResult<Vec<VideoListItem<PlaylistVideo>>>,
|
||||
pub target_id: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,36 +4,32 @@ use serde::Deserialize;
|
|||
use serde_with::serde_as;
|
||||
use serde_with::{DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::serializer::text::TextLink;
|
||||
use crate::serializer::MapResult;
|
||||
use crate::serializer::{
|
||||
ignore_any,
|
||||
text::{AccessibilityText, Text, TextLink, TextLinks},
|
||||
VecLogError,
|
||||
};
|
||||
|
||||
use super::{ContinuationEndpoint, Icon, Thumbnails, VideoListItem, VideoOwner};
|
||||
use super::{ContentsRenderer, ContinuationEndpoint, Icon, Thumbnails, VideoListItem, VideoOwner};
|
||||
|
||||
/// Video info response
|
||||
/*
|
||||
#VIDEO DETAILS
|
||||
*/
|
||||
|
||||
/// Video details response
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Video {
|
||||
pub struct VideoDetails {
|
||||
/// Video metadata + recommended videos
|
||||
pub contents: Contents,
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub engagement_panels: Vec<EngagementPanel>,
|
||||
}
|
||||
|
||||
/// Video recommendations response
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoRecommendations {
|
||||
pub on_response_received_endpoints: Vec<RecommendationsContItem>,
|
||||
}
|
||||
|
||||
/// Video comments response
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoComments {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub on_response_received_endpoints: Vec<CommentsContItem>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
/// Video chapters + comment section
|
||||
pub engagement_panels: MapResult<Vec<EngagementPanel>>,
|
||||
}
|
||||
|
||||
/// Video details main object, contains video metadata and recommended videos
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Contents {
|
||||
|
|
@ -43,74 +39,92 @@ pub struct Contents {
|
|||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TwoColumnWatchNextResults {
|
||||
/// Metadata about the video
|
||||
pub results: VideoResultsWrap,
|
||||
/// Video recommendations
|
||||
pub secondary_results: RecommendationResultsWrap,
|
||||
}
|
||||
|
||||
/// Metadata about the video
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoResultsWrap {
|
||||
pub results: VideoResults,
|
||||
}
|
||||
|
||||
/// Video metadata items
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoResults {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub contents: Vec<VideoResultsItem>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub contents: MapResult<Vec<VideoResultsItem>>,
|
||||
}
|
||||
|
||||
/// Video metadata item
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VideoResultsItem {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
VideoPrimaryInfoRenderer {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
#[serde_as(as = "Text")]
|
||||
title: String,
|
||||
view_count: ViewCountWrap,
|
||||
view_count: ViewCount,
|
||||
/// Like/Dislike button
|
||||
video_actions: VideoActions,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
/// Absolute textual date (e.g. `Dec 29, 2019`)
|
||||
#[serde_as(as = "Text")]
|
||||
date_text: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
VideoSecondaryInfoRenderer {
|
||||
owner: VideoOwner,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
#[serde_as(as = "Text")]
|
||||
description: String,
|
||||
/// Additional metadata (e.g. Creative Commons License)
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
metadata_row_container: Option<MetadataRowContainer>,
|
||||
},
|
||||
/*
|
||||
/// The comment section consists of 2 ItemSectionRenderers:
|
||||
///
|
||||
/// 1. sectionIdentifier: "comments-entry-point", contains number of comments
|
||||
/// 2. sectionIdentifier: "comment-item-section", contains continuation token
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ItemSectionRenderer {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
contents: Vec<ItemSection>,
|
||||
section_identifier: String,
|
||||
},
|
||||
*/
|
||||
#[serde(other, deserialize_with = "ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewCountWrap {
|
||||
pub video_view_count_renderer: ViewCount,
|
||||
pub struct ViewCount {
|
||||
pub video_view_count_renderer: ViewCountRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewCount {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub struct ViewCountRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
pub view_count: String,
|
||||
}
|
||||
|
||||
/// Like/Dislike buttons
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoActions {
|
||||
pub menu_renderer: VideoActionsMenu,
|
||||
}
|
||||
|
||||
/// Like/Dislike buttons
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -119,58 +133,73 @@ pub struct VideoActionsMenu {
|
|||
pub top_level_buttons: Vec<ToggleButtonWrap>,
|
||||
}
|
||||
|
||||
/// Like/Dislike button
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToggleButtonWrap {
|
||||
pub toggle_button_renderer: ToggleButton,
|
||||
}
|
||||
|
||||
/// Like/Dislike button
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToggleButton {
|
||||
/// Icon type: `LIKE` / `DISLIKE`
|
||||
pub default_icon: Icon,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
/// Number of likes (`4,010,157 likes`)
|
||||
#[serde_as(as = "AccessibilityText")]
|
||||
pub default_text: String,
|
||||
}
|
||||
|
||||
/// Shows additional video metadata. Its only known use is for
|
||||
/// the Creative Commonse License.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetadataRowContainer {
|
||||
pub metadata_row_container_renderer: MetadataRowContainerRenderer,
|
||||
}
|
||||
|
||||
/// Shows additional video metadata. Its only known use is for
|
||||
/// the Creative Commonse License.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetadataRowContainerRenderer {
|
||||
pub rows: Vec<MetadataRow>,
|
||||
}
|
||||
|
||||
/// Additional video metadata item (Creative Commons License)
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetadataRow {
|
||||
pub metadata_row_renderer: MetadataRowRenderer,
|
||||
}
|
||||
|
||||
/// Additional video metadata item (Creative Commons License)
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetadataRowRenderer {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub title: String,
|
||||
#[serde_as(as = "Vec<crate::serializer::text::TextLinks>")]
|
||||
// `License`
|
||||
// #[serde_as(as = "Text")]
|
||||
// pub title: String,
|
||||
/// Creative commons license:
|
||||
///
|
||||
/// Text (en): `Creative Commons Attribution license (reuse allowed)`
|
||||
///
|
||||
/// URL: `https://www.youtube.com/t/creative_commons`
|
||||
#[serde_as(as = "Vec<TextLinks>")]
|
||||
pub contents: Vec<Vec<TextLink>>,
|
||||
}
|
||||
|
||||
/*
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ItemSection {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CommentsEntryPointHeaderRenderer {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
header_text: String,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
#[serde_as(as = "Text")]
|
||||
comment_count: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -178,49 +207,57 @@ pub enum ItemSection {
|
|||
continuation_endpoint: ContinuationEndpoint,
|
||||
},
|
||||
}
|
||||
*/
|
||||
|
||||
/// Video recommendations
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecommendationResultsWrap {
|
||||
pub secondary_results: RecommendationResults,
|
||||
}
|
||||
|
||||
/// Video recommendations
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecommendationResults {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub results: Vec<VideoListItem<RecommendedVideo>>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub results: MapResult<Vec<VideoListItem<RecommendedVideo>>>,
|
||||
}
|
||||
|
||||
/// Video recommendation item
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecommendedVideo {
|
||||
pub video_id: String,
|
||||
pub thumbnail: Thumbnails,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
#[serde_as(as = "Text")]
|
||||
pub title: String,
|
||||
#[serde(rename = "shortBylineText")]
|
||||
#[serde_as(as = "crate::serializer::text::TextLink")]
|
||||
#[serde_as(as = "TextLink")]
|
||||
pub channel: TextLink,
|
||||
#[serde_as(as = "Option<crate::serializer::text::Text>")]
|
||||
#[serde_as(as = "Option<Text>")]
|
||||
pub length_text: Option<String>,
|
||||
#[serde_as(as = "Option<crate::serializer::text::Text>")]
|
||||
#[serde_as(as = "Option<Text>")]
|
||||
pub published_time_text: Option<String>,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
#[serde_as(as = "Text")]
|
||||
pub view_count_text: String,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
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 {
|
||||
|
|
@ -230,63 +267,181 @@ pub struct VideoBadgeRenderer {
|
|||
#[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
|
||||
/// and the comment section.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngagementPanel {
|
||||
pub engagement_panel_section_list_renderer: EngagementPanelRenderer,
|
||||
}
|
||||
|
||||
/// The engagement panels are displayed below the video and contain chapter markers
|
||||
/// and the comment section.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngagementPanelRenderer {
|
||||
pub header: EngagementPanelHeader,
|
||||
#[serde(rename_all = "kebab-case", tag = "panelIdentifier")]
|
||||
pub enum EngagementPanelRenderer {
|
||||
/// Chapter markers
|
||||
EngagementPanelMacroMarkersDescriptionChapters { content: ChapterMarkersContent },
|
||||
/// Comment section (contains no comments, but the
|
||||
/// continuation tokens for fetching top/latest comments)
|
||||
CommentItemSection { header: CommentItemSectionHeader },
|
||||
/// Ignored items:
|
||||
/// - `engagement-panel-ads`
|
||||
/// - `engagement-panel-structured-description`
|
||||
/// (Desctiption already included in `VideoSecondaryInfoRenderer`)
|
||||
/// - `engagement-panel-searchable-transcript`
|
||||
/// (basically video subtitles in a different format)
|
||||
#[serde(other, deserialize_with = "ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
||||
/// Chapter markers
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngagementPanelHeader {
|
||||
pub engagement_panel_title_header_renderer: EngagementPanelHeaderRenderer,
|
||||
pub struct ChapterMarkersContent {
|
||||
pub macro_markers_list_renderer: ContentsRenderer<MacroMarkersListItem>,
|
||||
}
|
||||
|
||||
/// Chapter marker
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngagementPanelHeaderRenderer {
|
||||
pub menu: EngagementPanelMenu,
|
||||
pub struct MacroMarkersListItem {
|
||||
pub macro_markers_list_item_renderer: MacroMarkersListItemRenderer,
|
||||
}
|
||||
|
||||
/// Chapter marker
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngagementPanelMenu {
|
||||
pub sort_filter_sub_menu_renderer: EngagementPanelMenuRenderer,
|
||||
pub struct MacroMarkersListItemRenderer {
|
||||
/// Contains chapter start time in seconds
|
||||
pub on_tap: MacroMarkersListItemOnTap,
|
||||
pub thumbnail: Thumbnails,
|
||||
/// Textual time (`1:42`)
|
||||
#[serde_as(as = "Text")]
|
||||
pub time_description: String,
|
||||
/// Chapter title
|
||||
#[serde_as(as = "Text")]
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Contains chapter start time in seconds
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngagementPanelMenuRenderer {
|
||||
pub sub_menu_items: Vec<EngagementPanelMenuItem>,
|
||||
pub struct MacroMarkersListItemOnTap {
|
||||
pub watch_endpoint: MacroMarkersListItemWatchEndpoint,
|
||||
}
|
||||
/// Contains chapter start time in seconds
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MacroMarkersListItemWatchEndpoint {
|
||||
/// Chapter start time in seconds
|
||||
pub start_time_seconds: u32,
|
||||
}
|
||||
|
||||
/// Comment section header
|
||||
/// (contains continuation tokens for fetching top/latest comments)
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngagementPanelMenuItem {
|
||||
pub struct CommentItemSectionHeader {
|
||||
pub engagement_panel_title_header_renderer: CommentItemSectionHeaderRenderer,
|
||||
}
|
||||
|
||||
/// Comment section header
|
||||
/// (contains continuation tokens for fetching top/latest comments)
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommentItemSectionHeaderRenderer {
|
||||
/// Average comment count (e.g. `81`, `2.2K`, `705K`)
|
||||
///
|
||||
/// The accurate count is included in the first comment response.
|
||||
#[serde_as(as = "Text")]
|
||||
pub contextual_info: String,
|
||||
pub menu: CommentItemSectionHeaderMenu,
|
||||
}
|
||||
|
||||
/// Comment section menu
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommentItemSectionHeaderMenu {
|
||||
pub sort_filter_sub_menu_renderer: CommentItemSectionHeaderMenuRenderer,
|
||||
}
|
||||
|
||||
/// Comment section menu
|
||||
///
|
||||
/// Items:
|
||||
/// - Top comments
|
||||
/// - Latest comments
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommentItemSectionHeaderMenuRenderer {
|
||||
pub sub_menu_items: Vec<CommentItemSectionHeaderMenuItem>,
|
||||
}
|
||||
|
||||
/// Comment section menu item
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommentItemSectionHeaderMenuItem {
|
||||
/// Continuation token for fetching comments
|
||||
pub service_endpoint: ContinuationEndpoint,
|
||||
}
|
||||
|
||||
/*
|
||||
#RECOMMENDATIONS CONTINUATION
|
||||
*/
|
||||
|
||||
/// Video recommendations continuation response
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoRecommendations {
|
||||
pub on_response_received_endpoints: Vec<RecommendationsContItem>,
|
||||
}
|
||||
|
||||
/// Video recommendations continuation
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecommendationsContItem {
|
||||
pub append_continuation_items_action: AppendRecommendations,
|
||||
}
|
||||
|
||||
/// Video recommendations continuation
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppendRecommendations {
|
||||
pub continuation_items: Vec<VideoListItem<RecommendedVideo>>,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub continuation_items: MapResult<Vec<VideoListItem<RecommendedVideo>>>,
|
||||
}
|
||||
|
||||
/*
|
||||
#COMMENTS CONTINUATION
|
||||
*/
|
||||
|
||||
/// Video comments continuation response
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoComments {
|
||||
/// - Initial response: 2*reloadContinuationItemsCommand
|
||||
/// - 1*commentsHeaderRenderer: number of comments
|
||||
/// - n*commentThreadRenderer, continuationItemRenderer:
|
||||
/// comments + continuation
|
||||
/// - Continuation response: appendContinuationItemsAction
|
||||
/// - n*commentThreadRenderer, continuationItemRenderer:
|
||||
/// comments + continuation
|
||||
/// - Comment replies: appendContinuationItemsAction
|
||||
/// - n*commentRenderer, continuationItemRenderer:
|
||||
/// replies + continuation
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub on_response_received_endpoints: MapResult<Vec<CommentsContItem>>,
|
||||
}
|
||||
|
||||
/// Video comments continuation
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommentsContItem {
|
||||
|
|
@ -294,40 +449,46 @@ pub struct CommentsContItem {
|
|||
pub append_continuation_items_action: AppendComments,
|
||||
}
|
||||
|
||||
/// Video comments continuation action
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppendComments {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub continuation_items: Vec<CommentListItem>,
|
||||
pub target_id: String,
|
||||
#[serde_as(as = "VecLogError<_>")]
|
||||
pub continuation_items: MapResult<Vec<CommentListItem>>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CommentListItem {
|
||||
/// Top-level comment
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CommentThreadRenderer {
|
||||
comment: Comment,
|
||||
/// Continuation token to fetch replies
|
||||
#[serde(default)]
|
||||
replies: Replies,
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
rendering_priority: CommentPriority,
|
||||
},
|
||||
/// Reply comment
|
||||
CommentRenderer {
|
||||
#[serde(flatten)]
|
||||
comment: CommentRenderer,
|
||||
},
|
||||
/// Continuation token to fetch more comments
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ContinuationItemRenderer {
|
||||
continuation_endpoint: ContinuationEndpoint,
|
||||
},
|
||||
|
||||
// TODO: TMP
|
||||
/// Header of the comment section (contains number of comments)
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CommentsHeaderRenderer { count_text: Option<String> },
|
||||
CommentsHeaderRenderer {
|
||||
#[serde_as(as = "Text")]
|
||||
count_text: Vec<String>
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
@ -340,22 +501,26 @@ pub struct Comment {
|
|||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommentRenderer {
|
||||
// There may be comments with missing authors (possibly deleted users?)
|
||||
/// Author name
|
||||
///
|
||||
/// There may be comments with missing authors (possibly deleted users?)
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "DefaultOnError<Option<crate::serializer::text::Text>>")]
|
||||
#[serde_as(as = "DefaultOnError<Option<Text>>")]
|
||||
pub author_text: Option<String>,
|
||||
pub author_thumbnail: Thumbnails,
|
||||
#[serde(default)]
|
||||
/// ID of the author's channel
|
||||
#[serde_as(as = "DefaultOnError")]
|
||||
pub author_endpoint: Option<AuthorEndpoint>,
|
||||
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
/// Comment text
|
||||
#[serde_as(as = "Text")]
|
||||
pub content_text: String,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
/// Textual publish date (e.g. `15 minutes ago`, `2 days ago`)
|
||||
#[serde_as(as = "Text")]
|
||||
pub published_time_text: String,
|
||||
pub comment_id: String,
|
||||
pub author_is_channel_owner: bool,
|
||||
#[serde_as(as = "Option<crate::serializer::text::Text>")]
|
||||
#[serde_as(as = "Option<Text>")]
|
||||
pub vote_count: Option<String>,
|
||||
pub author_comment_badge: Option<AuthorCommentBadge>,
|
||||
#[serde(default)]
|
||||
|
|
@ -378,17 +543,23 @@ pub struct BrowseEndpoint {
|
|||
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum CommentPriority {
|
||||
/// Default rendering priority
|
||||
#[default]
|
||||
RenderingPriorityUnknown,
|
||||
/// Comment pinned by the creator
|
||||
RenderingPriorityPinnedComment,
|
||||
}
|
||||
|
||||
/// Does not contain replies directly but a continuation token
|
||||
/// for fetching them.
|
||||
#[derive(Default, Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Replies {
|
||||
pub comment_replies_renderer: RepliesRenderer,
|
||||
}
|
||||
|
||||
/// Does not contain replies directly but a continuation token
|
||||
/// for fetching them.
|
||||
#[serde_as]
|
||||
#[derive(Default, Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -397,24 +568,28 @@ pub struct RepliesRenderer {
|
|||
pub contents: Vec<CommentListItem>,
|
||||
}
|
||||
|
||||
/// These are the buttons for comment interaction. Contains the CreatorHeart.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommentActionButtons {
|
||||
pub comment_action_buttons_renderer: CommentActionButtonsRenderer,
|
||||
}
|
||||
|
||||
/// These are the buttons for comment interaction. Contains the CreatorHeart.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommentActionButtonsRenderer {
|
||||
pub creator_heart: Option<CreatorHeart>,
|
||||
}
|
||||
|
||||
/// Video creators can endorse comments by marking them with a ❤️.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatorHeart {
|
||||
pub creator_heart_renderer: CreatorHeartRenderer,
|
||||
}
|
||||
|
||||
/// Video creators can endorse comments by marking them with a ❤️.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatorHeartRenderer {
|
||||
|
|
@ -427,8 +602,10 @@ pub struct AuthorCommentBadge {
|
|||
pub author_comment_badge_renderer: AuthorCommentBadgeRenderer,
|
||||
}
|
||||
|
||||
/// YouTube channel badge (verified) of the comment author
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthorCommentBadgeRenderer {
|
||||
/// Verified: `CHECK`
|
||||
pub icon: Icon,
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
source: src/client/player.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
info:
|
||||
details:
|
||||
id: pPvd8UxmSbQ
|
||||
title: Inspiring Cinematic Uplifting (Creative Commons)
|
||||
description: "► Download Music: http://bit.ly/2QLufeh\nImportant to know! You can download this track for free through Patreon. You will pay only for new tracks! So join others and let's make next track together!\n\n► MORE MUSIC: Become my patron and get access to all our music from Patreon library. More Info here: http://bit.ly/2JJDFHb\n\n► Additional edit versions of this track you can download here: http://bit.ly/2WdRinT (5 versions)\n--------------------- \n\n►DESCRIPTION:\nInspiring Cinematic Uplifting Trailer Background - epic music for trailer video project with powerful drums, energetic orchestra and gentle piano melody. This motivational cinematic theme will work as perfect background for beautiful epic moments, landscapes, nature, drone video, motivational products and achievements.\n--------------------- \n\n► LICENSE:\n● If you need a license for your project, you can purchase it here: \nhttps://1.envato.market/ajicu (Audiojungle)\nhttps://bit.ly/3fWZZuI (Pond5)\n--------------------- \n\n► LISTEN ON:\n● Spotify - https://spoti.fi/2sHm3UH\n● Apple Music - https://apple.co/3qBjbUO\n--------------------- \n\n► SUBSCRIBE FOR MORE: \nPatreon: http://bit.ly/2JJDFHb\nYoutube: http://bit.ly/2AYBzfA\nFacebook: http://bit.ly/2T6dTx5\nInstagram: http://bit.ly/2BHJ8rB\nTwitter: http://bit.ly/2MwtOlT\nSoundCloud: http://bit.ly/2IwVVmt\nAudiojungle: https://1.envato.market/ajrsm\nPond5: https://bit.ly/2TLi1rW\n--------------------- \n►Photo by Vittorio Staffolani from Pexels\n--------------------- \n\nFAQ:\n\n► Can I use this music in my videos? \n● Sure! Just download this track and you are ready to use it! We only ask to credit us. \n-------------------- \n\n► What is \"Creative Commons\"? \nCreative Commons is a system that allows you to legally use “some rights reserved” music, movies, images, and other content — all for free. Licensees may copy, distribute, display and perform the work and make derivative works and remixes based on it only if they give the author or licensor the credits.\n-------------------- \n\n► Will I have any copyright issues with this track?\n● No, you should not have any copyright problems with this track!\n-------------------- \n\n► Is it necessary to become your patron?\n● No it's not necessary. But we recommend you to become our patron because you will get access to huge library of music. You will download only highest quality files. You will find additional edited versions of every track. You always be tuned with our news. You will find music not only from Roman Senyk but also from another talented authors.\n-------------------- \n\n► Why I received a copyright claim when I used this track?\n● Do not panic! This is very common situation. Content ID fingerprint system can mismatch our music. Just dispute the claim by showing our original track. Or send us the link to your video (romansenykmusic@gmail.com) and attach some screenshot with claim information. Claim will be released until 24 hours!\n\n► How to credit you in my video?\n● Just add to the description of your project information about Author, Name of Song and the link to our original track. Or copy and paste:\n\nMusic Info: Inspiring Cinematic Uplifting by RomanSenykMusic.\nMusic Link: https://youtu.be/pPvd8UxmSbQ\n--------------------- \n\n► If you have any questions, you can write in the comments for this video or by email: romansenykmusic@gmail.com\n--------------------- \n\nStay tuned! The best is yet to come! \nThanks For Listening!\nRoman Senyk"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
source: src/client/player.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
info:
|
||||
details:
|
||||
id: pPvd8UxmSbQ
|
||||
title: Inspiring Cinematic Uplifting (Creative Commons)
|
||||
description: "► Download Music: http://bit.ly/2QLufeh\nImportant to know! You can download this track for free through Patreon. You will pay only for new tracks! So join others and let's make next track together!\n\n► MORE MUSIC: Become my patron and get access to all our music from Patreon library. More Info here: http://bit.ly/2JJDFHb\n\n► Additional edit versions of this track you can download here: http://bit.ly/2WdRinT (5 versions)\n--------------------- \n\n►DESCRIPTION:\nInspiring Cinematic Uplifting Trailer Background - epic music for trailer video project with powerful drums, energetic orchestra and gentle piano melody. This motivational cinematic theme will work as perfect background for beautiful epic moments, landscapes, nature, drone video, motivational products and achievements.\n--------------------- \n\n► LICENSE:\n● If you need a license for your project, you can purchase it here: \nhttps://1.envato.market/ajicu (Audiojungle)\nhttps://bit.ly/3fWZZuI (Pond5)\n--------------------- \n\n► LISTEN ON:\n● Spotify - https://spoti.fi/2sHm3UH\n● Apple Music - https://apple.co/3qBjbUO\n--------------------- \n\n► SUBSCRIBE FOR MORE: \nPatreon: http://bit.ly/2JJDFHb\nYoutube: http://bit.ly/2AYBzfA\nFacebook: http://bit.ly/2T6dTx5\nInstagram: http://bit.ly/2BHJ8rB\nTwitter: http://bit.ly/2MwtOlT\nSoundCloud: http://bit.ly/2IwVVmt\nAudiojungle: https://1.envato.market/ajrsm\nPond5: https://bit.ly/2TLi1rW\n--------------------- \n►Photo by Vittorio Staffolani from Pexels\n--------------------- \n\nFAQ:\n\n► Can I use this music in my videos? \n● Sure! Just download this track and you are ready to use it! We only ask to credit us. \n-------------------- \n\n► What is \"Creative Commons\"? \nCreative Commons is a system that allows you to legally use “some rights reserved” music, movies, images, and other content — all for free. Licensees may copy, distribute, display and perform the work and make derivative works and remixes based on it only if they give the author or licensor the credits.\n-------------------- \n\n► Will I have any copyright issues with this track?\n● No, you should not have any copyright problems with this track!\n-------------------- \n\n► Is it necessary to become your patron?\n● No it's not necessary. But we recommend you to become our patron because you will get access to huge library of music. You will download only highest quality files. You will find additional edited versions of every track. You always be tuned with our news. You will find music not only from Roman Senyk but also from another talented authors.\n-------------------- \n\n► Why I received a copyright claim when I used this track?\n● Do not panic! This is very common situation. Content ID fingerprint system can mismatch our music. Just dispute the claim by showing our original track. Or send us the link to your video (romansenykmusic@gmail.com) and attach some screenshot with claim information. Claim will be released until 24 hours!\n\n► How to credit you in my video?\n● Just add to the description of your project information about Author, Name of Song and the link to our original track. Or copy and paste:\n\nMusic Info: Inspiring Cinematic Uplifting by RomanSenykMusic.\nMusic Link: https://youtu.be/pPvd8UxmSbQ\n--------------------- \n\n► If you have any questions, you can write in the comments for this video or by email: romansenykmusic@gmail.com\n--------------------- \n\nStay tuned! The best is yet to come! \nThanks For Listening!\nRoman Senyk"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
source: src/client/player.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
info:
|
||||
details:
|
||||
id: pPvd8UxmSbQ
|
||||
title: Inspiring Cinematic Uplifting
|
||||
description: ~
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
source: src/client/player.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
info:
|
||||
details:
|
||||
id: pPvd8UxmSbQ
|
||||
title: Inspiring Cinematic Uplifting (Creative Commons)
|
||||
description: "► Download Music: http://bit.ly/2QLufeh\nImportant to know! You can download this track for free through Patreon. You will pay only for new tracks! So join others and let's make next track together!\n\n► MORE MUSIC: Become my patron and get access to all our music from Patreon library. More Info here: http://bit.ly/2JJDFHb\n\n► Additional edit versions of this track you can download here: http://bit.ly/2WdRinT (5 versions)\n--------------------- \n\n►DESCRIPTION:\nInspiring Cinematic Uplifting Trailer Background - epic music for trailer video project with powerful drums, energetic orchestra and gentle piano melody. This motivational cinematic theme will work as perfect background for beautiful epic moments, landscapes, nature, drone video, motivational products and achievements.\n--------------------- \n\n► LICENSE:\n● If you need a license for your project, you can purchase it here: \nhttps://1.envato.market/ajicu (Audiojungle)\nhttps://bit.ly/3fWZZuI (Pond5)\n--------------------- \n\n► LISTEN ON:\n● Spotify - https://spoti.fi/2sHm3UH\n● Apple Music - https://apple.co/3qBjbUO\n--------------------- \n\n► SUBSCRIBE FOR MORE: \nPatreon: http://bit.ly/2JJDFHb\nYoutube: http://bit.ly/2AYBzfA\nFacebook: http://bit.ly/2T6dTx5\nInstagram: http://bit.ly/2BHJ8rB\nTwitter: http://bit.ly/2MwtOlT\nSoundCloud: http://bit.ly/2IwVVmt\nAudiojungle: https://1.envato.market/ajrsm\nPond5: https://bit.ly/2TLi1rW\n--------------------- \n►Photo by Vittorio Staffolani from Pexels\n--------------------- \n\nFAQ:\n\n► Can I use this music in my videos? \n● Sure! Just download this track and you are ready to use it! We only ask to credit us. \n-------------------- \n\n► What is \"Creative Commons\"? \nCreative Commons is a system that allows you to legally use “some rights reserved” music, movies, images, and other content — all for free. Licensees may copy, distribute, display and perform the work and make derivative works and remixes based on it only if they give the author or licensor the credits.\n-------------------- \n\n► Will I have any copyright issues with this track?\n● No, you should not have any copyright problems with this track!\n-------------------- \n\n► Is it necessary to become your patron?\n● No it's not necessary. But we recommend you to become our patron because you will get access to huge library of music. You will download only highest quality files. You will find additional edited versions of every track. You always be tuned with our news. You will find music not only from Roman Senyk but also from another talented authors.\n-------------------- \n\n► Why I received a copyright claim when I used this track?\n● Do not panic! This is very common situation. Content ID fingerprint system can mismatch our music. Just dispute the claim by showing our original track. Or send us the link to your video (romansenykmusic@gmail.com) and attach some screenshot with claim information. Claim will be released until 24 hours!\n\n► How to credit you in my video?\n● Just add to the description of your project information about Author, Name of Song and the link to our original track. Or copy and paste:\n\nMusic Info: Inspiring Cinematic Uplifting by RomanSenykMusic.\nMusic Link: https://youtu.be/pPvd8UxmSbQ\n--------------------- \n\n► If you have any questions, you can write in the comments for this video or by email: romansenykmusic@gmail.com\n--------------------- \n\nStay tuned! The best is yet to come! \nThanks For Listening!\nRoman Senyk"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
source: src/client/player.rs
|
||||
expression: map_res.c
|
||||
---
|
||||
info:
|
||||
details:
|
||||
id: pPvd8UxmSbQ
|
||||
title: Inspiring Cinematic Uplifting (Creative Commons)
|
||||
description: "► Download Music: http://bit.ly/2QLufeh\nImportant to know! You can download this track for free through Patreon. You will pay only for new tracks! So join others and let's make next track together!\n\n► MORE MUSIC: Become my patron and get access to all our music from Patreon library. More Info here: http://bit.ly/2JJDFHb\n\n► Additional edit versions of this track you can download here: http://bit.ly/2WdRinT (5 versions)\n--------------------- \n\n►DESCRIPTION:\nInspiring Cinematic Uplifting Trailer Background - epic music for trailer video project with powerful drums, energetic orchestra and gentle piano melody. This motivational cinematic theme will work as perfect background for beautiful epic moments, landscapes, nature, drone video, motivational products and achievements.\n--------------------- \n\n► LICENSE:\n● If you need a license for your project, you can purchase it here: \nhttps://1.envato.market/ajicu (Audiojungle)\nhttps://bit.ly/3fWZZuI (Pond5)\n--------------------- \n\n► LISTEN ON:\n● Spotify - https://spoti.fi/2sHm3UH\n● Apple Music - https://apple.co/3qBjbUO\n--------------------- \n\n► SUBSCRIBE FOR MORE: \nPatreon: http://bit.ly/2JJDFHb\nYoutube: http://bit.ly/2AYBzfA\nFacebook: http://bit.ly/2T6dTx5\nInstagram: http://bit.ly/2BHJ8rB\nTwitter: http://bit.ly/2MwtOlT\nSoundCloud: http://bit.ly/2IwVVmt\nAudiojungle: https://1.envato.market/ajrsm\nPond5: https://bit.ly/2TLi1rW\n--------------------- \n►Photo by Vittorio Staffolani from Pexels\n--------------------- \n\nFAQ:\n\n► Can I use this music in my videos? \n● Sure! Just download this track and you are ready to use it! We only ask to credit us. \n-------------------- \n\n► What is \"Creative Commons\"? \nCreative Commons is a system that allows you to legally use “some rights reserved” music, movies, images, and other content — all for free. Licensees may copy, distribute, display and perform the work and make derivative works and remixes based on it only if they give the author or licensor the credits.\n-------------------- \n\n► Will I have any copyright issues with this track?\n● No, you should not have any copyright problems with this track!\n-------------------- \n\n► Is it necessary to become your patron?\n● No it's not necessary. But we recommend you to become our patron because you will get access to huge library of music. You will download only highest quality files. You will find additional edited versions of every track. You always be tuned with our news. You will find music not only from Roman Senyk but also from another talented authors.\n-------------------- \n\n► Why I received a copyright claim when I used this track?\n● Do not panic! This is very common situation. Content ID fingerprint system can mismatch our music. Just dispute the claim by showing our original track. Or send us the link to your video (romansenykmusic@gmail.com) and attach some screenshot with claim information. Claim will be released until 24 hours!\n\n► How to credit you in my video?\n● Just add to the description of your project information about Author, Name of Song and the link to our original track. Or copy and paste:\n\nMusic Info: Inspiring Cinematic Uplifting by RomanSenykMusic.\nMusic Link: https://youtu.be/pPvd8UxmSbQ\n--------------------- \n\n► If you have any questions, you can write in the comments for this video or by email: romansenykmusic@gmail.com\n--------------------- \n\nStay tuned! The best is yet to come! \nThanks For Listening!\nRoman Senyk"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
112
src/client/video_details.rs
Normal file
112
src/client/video_details.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
use anyhow::Result;
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{model::VideoDetails, serializer::MapResult};
|
||||
|
||||
use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct QVideo {
|
||||
context: YTContext,
|
||||
/// YouTube video ID
|
||||
video_id: String,
|
||||
/// Set to true to allow extraction of streams with sensitive content
|
||||
content_check_ok: bool,
|
||||
/// Probably refers to allowing sensitive content, too
|
||||
racy_check_ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct QVideoCont {
|
||||
context: YTContext,
|
||||
continuation: String,
|
||||
}
|
||||
|
||||
impl RustyPipeQuery {
|
||||
pub async fn video_details(self, video_id: &str) -> Result<VideoDetails> {
|
||||
let context = self.get_context(ClientType::Desktop, true).await;
|
||||
let request_body = QVideo {
|
||||
context,
|
||||
video_id: video_id.to_owned(),
|
||||
content_check_ok: true,
|
||||
racy_check_ok: true,
|
||||
};
|
||||
|
||||
self.execute_request::<response::VideoDetails, _, _>(
|
||||
ClientType::Desktop,
|
||||
"video_details",
|
||||
video_id,
|
||||
Method::POST,
|
||||
"next",
|
||||
&request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/*
|
||||
async fn get_comments_response(&self, ctoken: &str) -> Result<response::VideoComments> {
|
||||
let client = self.get_ytclient(ClientType::Desktop);
|
||||
let context = client.get_context(true).await;
|
||||
let request_body = QVideoCont {
|
||||
context,
|
||||
continuation: ctoken.to_owned(),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.request_builder(Method::POST, "next")
|
||||
.await
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(resp.json::<response::VideoComments>().await?)
|
||||
}
|
||||
|
||||
async fn get_recommendations_response(
|
||||
&self,
|
||||
ctoken: &str,
|
||||
) -> Result<response::VideoRecommendations> {
|
||||
let client = self.get_ytclient(ClientType::Desktop);
|
||||
let context = client.get_context(true).await;
|
||||
let request_body = QVideoCont {
|
||||
context,
|
||||
continuation: ctoken.to_owned(),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.request_builder(Method::POST, "next")
|
||||
.await
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(resp.json::<response::VideoRecommendations>().await?)
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
impl MapResponse<VideoDetails> for response::VideoDetails {
|
||||
fn map_response(
|
||||
self,
|
||||
id: &str,
|
||||
lang: crate::model::Language,
|
||||
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||
) -> Result<MapResult<VideoDetails>> {
|
||||
Ok(MapResult {
|
||||
c: VideoDetails {
|
||||
id: id.to_owned(),
|
||||
title: "".to_owned(),
|
||||
description: "".to_owned(),
|
||||
},
|
||||
warnings: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
}
|
||||
|
|
@ -267,10 +267,10 @@ pub async fn download_video(
|
|||
) -> Result<()> {
|
||||
// Download filepath
|
||||
let download_dir = PathBuf::from(output_dir);
|
||||
let title = player_data.info.title.to_owned();
|
||||
let title = player_data.details.title.to_owned();
|
||||
let output_fname_set = output_fname.is_some();
|
||||
let output_fname = output_fname
|
||||
.unwrap_or_else(|| filenamify::filenamify(format!("{} [{}]", title, player_data.info.id)));
|
||||
.unwrap_or_else(|| filenamify::filenamify(format!("{} [{}]", title, player_data.details.id)));
|
||||
|
||||
// Select streams to download
|
||||
let (video, audio) = player_data.select_video_audio_stream(filter);
|
||||
|
|
|
|||
12
src/error.rs
12
src/error.rs
|
|
@ -1,8 +1,7 @@
|
|||
use url;
|
||||
|
||||
/// Errors that can occur during the id extraction or the video download process.
|
||||
/// 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")]
|
||||
|
|
@ -41,4 +40,11 @@ pub enum Error {
|
|||
#[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,6 +12,7 @@ mod util;
|
|||
pub mod cache;
|
||||
pub mod client;
|
||||
pub mod download;
|
||||
pub mod error;
|
||||
pub mod model;
|
||||
pub mod report;
|
||||
pub mod timeago;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
pub mod locale;
|
||||
mod ordering;
|
||||
mod paginator;
|
||||
pub mod stream_filter;
|
||||
|
||||
pub use locale::{Country, Language};
|
||||
pub use paginator::Paginator;
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/*
|
||||
#PLAYER
|
||||
*/
|
||||
|
||||
pub trait FileFormat {
|
||||
fn extension(&self) -> &str;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct VideoPlayer {
|
||||
pub info: VideoInfo,
|
||||
pub details: VideoPlayerDetails,
|
||||
pub video_streams: Vec<VideoStream>,
|
||||
pub video_only_streams: Vec<VideoStream>,
|
||||
pub audio_streams: Vec<AudioStream>,
|
||||
|
|
@ -23,28 +29,14 @@ pub struct VideoPlayer {
|
|||
pub expires_in_seconds: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Playlist {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub videos: Vec<Video>,
|
||||
pub n_videos: u32,
|
||||
pub ctoken: Option<String>,
|
||||
pub thumbnails: Vec<Thumbnail>,
|
||||
pub description: Option<String>,
|
||||
pub channel: Option<Channel>,
|
||||
pub last_update: Option<DateTime<Local>>,
|
||||
pub last_update_txt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct VideoInfo {
|
||||
pub struct VideoPlayerDetails {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub length: u32,
|
||||
pub thumbnails: Vec<Thumbnail>,
|
||||
pub channel: Channel,
|
||||
pub channel: ChannelId,
|
||||
pub publish_date: Option<DateTime<Local>>,
|
||||
pub view_count: u64,
|
||||
pub keywords: Vec<String>,
|
||||
|
|
@ -182,17 +174,81 @@ pub struct Subtitle {
|
|||
pub auto_generated: bool,
|
||||
}
|
||||
|
||||
/*
|
||||
#PLAYLIST
|
||||
*/
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Playlist {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub videos: Paginator<PlaylistVideo>,
|
||||
pub n_videos: u32,
|
||||
pub thumbnails: Vec<Thumbnail>,
|
||||
pub description: Option<String>,
|
||||
pub channel: Option<ChannelId>,
|
||||
pub last_update: Option<DateTime<Local>>,
|
||||
pub last_update_txt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Video {
|
||||
pub struct PlaylistVideo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub length: u32,
|
||||
pub thumbnails: Vec<Thumbnail>,
|
||||
pub channel: Channel,
|
||||
pub channel: ChannelId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChannelId {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/*
|
||||
#VIDEO DETAILS
|
||||
*/
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct VideoDetails {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/*
|
||||
@COMMENTS
|
||||
*/
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Channel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub avatars: Vec<Thumbnail>,
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
// TODO: impl popularity comparison
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Comment {
|
||||
/// Unique YouTube Comment-ID (e.g. `UgynScMrsqGSL8qvePl4AaABAg`)
|
||||
pub id: String,
|
||||
/// Comment text
|
||||
pub text: String,
|
||||
/// Comment author
|
||||
///
|
||||
/// There may be comments with missing authors (possibly deleted users?).
|
||||
pub author: Option<Channel>,
|
||||
/// Number of upvotes
|
||||
pub upvotes: u32,
|
||||
/// Number of replies
|
||||
pub n_replies: u32,
|
||||
/// Paginator to fetch comment replies
|
||||
pub replies: Paginator<Comment>,
|
||||
/// Is the comment from the channel owner?
|
||||
pub by_owner: bool,
|
||||
/// Has the channel owner pinned the comment to the top?
|
||||
pub pinned: bool,
|
||||
/// Has the channel owner marked the comment with a ❤️ ?
|
||||
pub hearted: bool,
|
||||
}
|
||||
|
|
|
|||
54
src/model/paginator.rs
Normal file
54
src/model/paginator.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
/// The paginator is a wrapper around a list of items that are fetched
|
||||
/// in pages from the YouTube API (e.g. playlist items,
|
||||
/// video recommendations or comments).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Paginator<T> {
|
||||
/*
|
||||
/// Total number of items if finite and known.
|
||||
///
|
||||
/// Note that this number may not be 100% accurate, as this is the
|
||||
/// number returned by the YouTube API at the initial fetch.
|
||||
///
|
||||
/// It is intended to be shown to the user (e.g. 1261 comments,
|
||||
/// 18 Videos) and for progress estimation.
|
||||
///
|
||||
/// Don't use this number to check if all items were fetched or for
|
||||
/// iterating over the items.
|
||||
pub count: Option<u32>,
|
||||
*/
|
||||
/// Content of the paginator
|
||||
pub items: Vec<T>,
|
||||
/// The continuation token is passed to the YouTube API to fetch
|
||||
/// more items.
|
||||
///
|
||||
/// If it is None, it means that no more items can be fetched.
|
||||
pub ctoken: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> Default for Paginator<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
ctoken: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Paginator<T> {
|
||||
/// Check if the paginator is exhausted, meaning that no more
|
||||
/// items can be fetched.
|
||||
///
|
||||
/// Equivalent to `paginator.ctoken.is_none()`.
|
||||
pub fn is_exhausted(&self) -> bool {
|
||||
self.ctoken.is_none()
|
||||
}
|
||||
|
||||
/// Check if the paginator does not contain any data, meaning that it
|
||||
/// is exhausted and does not contain any items.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty() && self.is_exhausted()
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ pub use vec_log_err::VecLogError;
|
|||
|
||||
use std::fmt::Debug;
|
||||
|
||||
use serde::{de::IgnoredAny, Deserializer};
|
||||
|
||||
/// This represents a result from a deserializing/mapping operation.
|
||||
/// It holds the desired content (`c`) and a list of warning messages,
|
||||
/// if there occurred minor error during the deserializing or mapping
|
||||
|
|
@ -38,3 +40,59 @@ where
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialization method that consumes anything and returns an empty value.
|
||||
/// Intended to be used for a wildcard enum option.
|
||||
///
|
||||
/// Example:
|
||||
/// ```rs
|
||||
/// #[derive(Deserialize)]
|
||||
/// enum Fruit {
|
||||
/// Apple {
|
||||
/// red: bool,
|
||||
/// },
|
||||
/// Banana {
|
||||
/// yellow: bool,
|
||||
/// },
|
||||
/// #[serde(other, deserialize_with = "deserialize_blackhole")]
|
||||
/// None,
|
||||
/// }
|
||||
/// ```
|
||||
pub fn ignore_any<'de, D>(deserializer: D) -> Result<(), D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_ignored_any(IgnoredAny).and(Ok(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
enum E {
|
||||
Apple {
|
||||
red: bool,
|
||||
},
|
||||
Banana {
|
||||
yellow: bool,
|
||||
},
|
||||
#[serde(other, deserialize_with = "ignore_any")]
|
||||
None,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_ignore_any() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<E>(r#"{"Apple": {"red": true}}"#).unwrap(),
|
||||
E::Apple { red: true }
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<E>(r#"{"Lemon": {"yellow": true}}"#).unwrap(),
|
||||
E::None
|
||||
);
|
||||
assert!(serde_json::from_str::<E>(r#"{"Apple": {"yellow": true}}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,19 @@ impl<'de> DeserializeAs<'de, String> for Text {
|
|||
}
|
||||
}
|
||||
|
||||
impl<'de> DeserializeAs<'de, Vec<String>> for Text {
|
||||
fn deserialize_as<D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let text = Text::deserialize(deserializer)?;
|
||||
match text {
|
||||
Text::Simple { text } => Ok(vec![text]),
|
||||
Text::Multiple { runs } => Ok(runs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TextLink {
|
||||
Video {
|
||||
|
|
@ -227,6 +240,26 @@ impl<'de> DeserializeAs<'de, Vec<TextLink>> for TextLinks {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AccessibilityText {
|
||||
accessibility: AccessibilityData,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AccessibilityData {
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeAs<'de, String> for AccessibilityText {
|
||||
fn deserialize_as<D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let text = AccessibilityText::deserialize(deserializer)?;
|
||||
Ok(text.accessibility.label)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TextLink;
|
||||
|
|
@ -241,7 +274,7 @@ mod tests {
|
|||
"text": "Hello World"
|
||||
}
|
||||
}"#,
|
||||
"Hello World"
|
||||
vec!["Hello World"]
|
||||
)]
|
||||
#[case(
|
||||
r#"{
|
||||
|
|
@ -249,7 +282,7 @@ mod tests {
|
|||
"simpleText": "Hello World"
|
||||
}
|
||||
}"#,
|
||||
"Hello World"
|
||||
vec!["Hello World"]
|
||||
)]
|
||||
#[case(
|
||||
r#"{
|
||||
|
|
@ -267,9 +300,9 @@ mod tests {
|
|||
]
|
||||
}
|
||||
}"#,
|
||||
"Abo für MBCkpop beenden?"
|
||||
vec!["Abo für ", "MBCkpop", " beenden?"]
|
||||
)]
|
||||
fn t_deserialize_text(#[case] test_json: &str, #[case] exp: &str) {
|
||||
fn t_deserialize_text(#[case] test_json: &str, #[case] exp: Vec<&str>) {
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
struct S {
|
||||
|
|
@ -277,8 +310,18 @@ mod tests {
|
|||
txt: String,
|
||||
}
|
||||
|
||||
let res = serde_json::from_str::<S>(&test_json).unwrap();
|
||||
assert_eq!(res.txt, exp)
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
struct SVec {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
txt: Vec<String>,
|
||||
}
|
||||
|
||||
let res_str = serde_json::from_str::<S>(&test_json).unwrap();
|
||||
let res_vec = serde_json::from_str::<SVec>(&test_json).unwrap();
|
||||
|
||||
assert_eq!(res_str.txt, exp.join(""));
|
||||
assert_eq!(res_vec.txt, exp);
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
|
|
|
|||
32
src/util.rs
32
src/util.rs
|
|
@ -104,6 +104,38 @@ pub fn retry_delay(
|
|||
min_retry_interval.max(jittered_delay.min(max_retry_interval))
|
||||
}
|
||||
|
||||
/// Removes and returns the element at position `index` within the vector,
|
||||
/// shifting all elements after it to the left.
|
||||
///
|
||||
/// Returns None if the index is out of bounds.
|
||||
///
|
||||
/// 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
|
||||
/// to be preserved, use [`vec_try_swap_remove`] instead.
|
||||
pub fn vec_try_remove<T>(vec: &mut Vec<T>, index: usize) -> Option<T> {
|
||||
if index < vec.len() {
|
||||
Some(vec.remove(index))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes an element from the vector and returns it.
|
||||
///
|
||||
/// The removed element is replaced by the last element of the vector.
|
||||
///
|
||||
/// Returns None if the index is out of bounds.
|
||||
///
|
||||
/// This does not preserve ordering, but is *O*(1).
|
||||
/// 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> {
|
||||
if index < vec.len() {
|
||||
Some(vec.swap_remove(index))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"info": {
|
||||
"details": {
|
||||
"id": "LXb3EKWsInQ",
|
||||
"title": "COSTA RICA IN 4K 60fps HDR (ULTRA HD)",
|
||||
"description": "We've re-mastered and re-uploaded our favorite video in HDR!\n\nCHECK OUT OUR MOST POPULAR VIDEO: https://youtu.be/tO01J-M3g0U\n► INSTAGRAM: http://www.instagram.com/mysterybox\n► INSTAGRAM: http://www.instagram.com/jacobschwarz\n►WEBSITE: http://www.mysterybox.us\n►FACEBOOK: https://www.facebook.com/mysteryboxdi...\n\nMake sure to follow us on Instagram for BTS and sneak-peaks at upcoming projects. \n\nLICENSING & BUSINESS INQUIRIES\n► contact@mysterybox.us\n\nCHECK OUT OUR VIDEO PRODUCTION COMPANY\n► https://www.mysterybox.us\n\n4K PLAYLISTS\n► https://www.youtube.com/playlist?list...\n\nBLOG Check out our blog for great information on working in HDR and 8K. \n► http://www.mysterybox.us/blog\n\nSUBSCRIBE FOR MORE VIDS\n►https://www.youtube.com/user/jacobsch...\n\nMUSIC\n► Storyworks Music \"Promise of Dawn\"\nhttps://soundcloud.com/joshuapeterson/promise-of-dawn\nwww.storyworksmusic.com\n\n► SHOT ON\nRed Weapon LE w/Helium 8K s35 sensor (Stormtrooper33)\nCanon 16-35mm III \nCanon 24-70mm II\nSigma 150-500mm\nZeiss Classic 15mm\nMOVI M10\nAdobe Premiere and DaVinci Resolve\n\n\n\nLICENSING & BUSINESS INQUIRIES\n► contact@mysterybox.us\n\nThis video is subject to copyright owned by Mystery Box LLC. Any reproduction or republication of all or part of this video is expressly prohibited, unless Mystery Box has explicitly granted its prior written consent. All other rights reserved.\n\nCopyright © 2017 Mystery Box, LLC. All Rights Reserved.",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"info": {
|
||||
"details": {
|
||||
"id": "tVWWp1PqDus",
|
||||
"title": "100 Girls Vs 100 Boys For $500,000",
|
||||
"description": "Giving away $25k on Current! Sign up and use my code “BEAST250” for a chance to win*: https://www.current.com/beast250\n\nSUBSCRIBE OR I TAKE YOUR DOG\n╔═╦╗╔╦╗╔═╦═╦╦╦╦╗╔═╗\n║╚╣║║║╚╣╚╣╔╣╔╣║╚╣═╣ \n╠╗║╚╝║║╠╗║╚╣║║║║║═╣\n╚═╩══╩═╩═╩═╩╝╚╩═╩═╝\n\n----------------------------------------------------------------\nfollow all of these or i will kick you\n• TikTok - https://www.tiktok.com/@mrbeast\n• Twitter - https://twitter.com/MrBeast\n• Instagram - https://www.instagram.com/mrbeast\n• Facebook - https://www.facebook.com/MrBeast6000/\n• Official Merch - https://www.shopmrbeast.com/\n• Beast Philanthropy - https://www.beastphilanthropy.org/\n\nText me @ +1 (917) 259-6364\nI'm Hiring! - https://www.mrbeastjobs.com/\nOrder a beast burger 🍔 - https://mrbeastburger.com\nChocolate 🍫 Win a Tesla or be in a MrBeast video - Buy now ▸ https://feastables.com\n-----------------------------------------------------------------—\n\nCurrent is a financial technology company, not a bank. Banking services provided by Choice Financial Group, Member FDIC. The Current Visa Debit Card is issued by Choice Financial Group pursuant to a license from Visa U.S.A. Inc. and may be used everywhere Visa debit cards are accepted.\n\n*NO PURCHASE OR PAYMENT NECESSARY TO ENTER OR WIN. Open to legal residents of the 50 U.S./D.C., age 18+ (19+ in AL and NE, 21+ in MS). Void outside the 50 U.S./D.C. and where prohibited. Sweepstakes starts at 12:00:01 AM ET on 7/9/22; ends at 11:59:59 PM ET on 10/9/22. Odds of winning will depend upon the number of eligible entries received. For full Official Rules and how to enter without becoming a Current member, visit https://www.current.com/beast250. Sponsor: Finco Services, Inc. d/b/a Current, 30 Cooper Square, Floor 4, New York, NY 10003.",
|
||||
|
|
|
|||
12411
testfiles/video_details/video_details_ccommons.json
Normal file
12411
testfiles/video_details/video_details_ccommons.json
Normal file
File diff suppressed because one or more lines are too long
18060
testfiles/video_details/video_details_chapters.json
Normal file
18060
testfiles/video_details/video_details_chapters.json
Normal file
File diff suppressed because one or more lines are too long
12443
testfiles/video_details/video_details_music.json
Normal file
12443
testfiles/video_details/video_details_music.json
Normal file
File diff suppressed because it is too large
Load diff
12945
testfiles/video_details/video_details_mv.json
Normal file
12945
testfiles/video_details/video_details_mv.json
Normal file
File diff suppressed because one or more lines are too long
Reference in a new issue