diff --git a/src/client/response/video_details.rs b/src/client/response/video_details.rs index c96b7fc..e5e6c3d 100644 --- a/src/client/response/video_details.rs +++ b/src/client/response/video_details.rs @@ -13,8 +13,8 @@ use crate::serializer::{ }; use super::{ - ChannelBadge, ContentsRenderer, ContinuationEndpoint, ContinuationItemRenderer, Icon, - Thumbnails, VideoBadge, VideoListItem, VideoOwner, + ChannelBadge, ContinuationEndpoint, ContinuationItemRenderer, Icon, Thumbnails, VideoBadge, + VideoListItem, VideoOwner, }; /* @@ -348,7 +348,16 @@ pub enum EngagementPanelRenderer { #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChapterMarkersContent { - pub macro_markers_list_renderer: ContentsRenderer, + pub macro_markers_list_renderer: MacroMarkersListRenderer, +} + +/// Chapter markers +#[serde_as] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MacroMarkersListRenderer { + #[serde_as(as = "VecLogError<_>")] + pub contents: MapResult>, } /// Chapter marker @@ -366,9 +375,6 @@ 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, diff --git a/src/client/video_details.rs b/src/client/video_details.rs index 37ac522..ce8774a 100644 --- a/src/client/video_details.rs +++ b/src/client/video_details.rs @@ -5,7 +5,9 @@ use reqwest::Method; use serde::Serialize; use crate::{ - model::{Channel, ChannelId, Comment, Language, Paginator, RecommendedVideo, VideoDetails}, + model::{ + Channel, ChannelId, Chapter, Comment, Language, Paginator, RecommendedVideo, VideoDetails, + }, serializer::MapResult, timeago, util::{self, TryRemove}, @@ -258,6 +260,26 @@ impl MapResponse for response::VideoDetails { response::video_details::EngagementPanelRenderer::None => {}, }); + let chapters = chapter_panel + .map(|chapters| { + let mut content = chapters.macro_markers_list_renderer.contents; + warnings.append(&mut content.warnings); + content + .c + .into_iter() + .map(|item| Chapter { + title: item.macro_markers_list_item_renderer.title, + position: item + .macro_markers_list_item_renderer + .on_tap + .watch_endpoint + .start_time_seconds, + thumbnail: item.macro_markers_list_item_renderer.thumbnail.into(), + }) + .collect::>() + }) + .unwrap_or_default(); + let latest_comments_ctoken = comment_panel.and_then(|comments| { let mut items = comments .engagement_panel_title_header_renderer @@ -288,6 +310,7 @@ impl MapResponse for response::VideoDetails { publish_date_txt, is_live, is_ccommons, + chapters, recommended, top_comments: Paginator::new(None, Vec::new(), comment_ctoken), latest_comments: Paginator::new(None, Vec::new(), latest_comments_ctoken), @@ -942,6 +965,57 @@ mod tests { assert!(!details.is_live); assert!(!details.is_ccommons); + insta::assert_yaml_snapshot!(details.chapters, { + "[].thumbnail" => insta::dynamic_redaction(move |value, _path| { + assert!(!value.as_slice().unwrap().is_empty()); + "[ok]" + }), + }, @r###" + --- + - title: Intro + position: 0 + thumbnail: "[ok]" + - title: The PC Built for Super Efficiency + position: 42 + thumbnail: "[ok]" + - title: Our BURIAL ENCLOSURE?! + position: 161 + thumbnail: "[ok]" + - title: Our Power Solution (Thanks Jackery!) + position: 211 + thumbnail: "[ok]" + - title: "Diggin' Holes" + position: 287 + thumbnail: "[ok]" + - title: Colonoscopy? + position: 330 + thumbnail: "[ok]" + - title: "Diggin' like a man" + position: 424 + thumbnail: "[ok]" + - title: "The world's worst woodsman" + position: 509 + thumbnail: "[ok]" + - title: Backyard cable management + position: 543 + thumbnail: "[ok]" + - title: Time to bury this boy + position: 602 + thumbnail: "[ok]" + - title: Solar Power Generation + position: 646 + thumbnail: "[ok]" + - title: Issues + position: 697 + thumbnail: "[ok]" + - title: First Play Test + position: 728 + thumbnail: "[ok]" + - title: Conclusion + position: 800 + thumbnail: "[ok]" + "###); + assert!(!details.recommended.items.is_empty()); assert!(!details.recommended.is_exhausted()); @@ -1032,7 +1106,7 @@ mod tests { let rp = RustyPipe::builder().strict().build(); let details = rp.query().video_details("HRKu0cvrr_o").await.unwrap(); - dbg!(&details); + // dbg!(&details); assert_eq!(details.id, "HRKu0cvrr_o"); assert_eq!( diff --git a/src/model/mod.rs b/src/model/mod.rs index 88c0300..218a53f 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -245,6 +245,8 @@ pub struct VideoDetails { /// /// https://creativecommons.org/licenses/by/3.0/ pub is_ccommons: bool, + /// Chapters of the video + pub chapters: Vec, /// Recommended videos /// /// Note: Recommendations are not available for age-restricted videos @@ -255,6 +257,18 @@ pub struct VideoDetails { pub latest_comments: Paginator, } +/// Videos can consist of different chapters, which YouTube shows +/// on the seek bar and below the description text. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Chapter { + /// Chapter title + pub title: String, + /// Chapter position in seconds + pub position: u32, + /// Chapter thumbnail + pub thumbnail: Vec, +} + /* @RECOMMENDATIONS */