Compare commits

...

7 commits

Author SHA1 Message Date
3596861b77 fix: information panels (ÖRR, Covid, ...) 2022-09-20 21:47:22 +02:00
584d6aa3f5 feat: add is_live to video details 2022-09-20 21:22:18 +02:00
8c1e7bf6ac fix: fetching comment count 2022-09-20 17:24:16 +02:00
e800e16c68 feat: add video details mapping
- TODO: fix fetching comment count
2022-09-20 16:14:57 +02:00
df6543d62e refactor: remove thiserror for now 2022-09-19 13:24:12 +02:00
94c9a264a4 refactor: implemented TryRemove as trait 2022-09-19 12:47:06 +02:00
230b027b59 fix: adapt cli to playlist pagination
- fix clippy errors
2022-09-19 01:21:25 +02:00
23 changed files with 66997 additions and 7973 deletions

View file

@ -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"]}

View file

@ -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;

View file

@ -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,

View file

@ -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();
} }

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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);
} }
} }

View file

@ -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
})
}
}

View file

@ -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,
} }

View file

@ -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);
}
} }

View file

@ -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);

View file

@ -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),
}

View file

@ -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;

View file

@ -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,
} }

View file

@ -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,
} }

View file

@ -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)
} }
} }

View file

@ -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};

View file

@ -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);
}
} }

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff