Skip to main content

ytmapi_rs/parse/
history.rs

1use super::{
2    BADGE_LABEL, DELETION_ENTITY_ID, EpisodeDate, EpisodeDuration, MENU_ITEMS, MENU_LIKE_STATUS,
3    MRLIR, MUSIC_SHELF, ParseFrom, ParsedSongAlbum, ParsedUploadArtist, ParsedUploadSongAlbum,
4    TEXT_RUN_TEXT, THUMBNAILS, TITLE_TEXT, fixed_column_item_pointer, flex_column_item_pointer,
5    parse_library_management_items_from_menu, parse_upload_song_album, parse_upload_song_artists,
6};
7use crate::Result;
8use crate::common::{
9    ApiOutcome, ArtistChannelID, EpisodeID, Explicit, FeedbackTokenRemoveFromHistory,
10    LibraryManager, LikeStatus, PlaylistID, Thumbnail, UploadEntityID, VideoID,
11};
12use crate::nav_consts::{
13    FEEDBACK_TOKEN, LIVE_BADGE_LABEL, MENU_SERVICE, NAVIGATION_BROWSE_ID, NAVIGATION_PLAYLIST_ID,
14    NAVIGATION_VIDEO_TYPE, PLAY_BUTTON, SECTION_LIST, SINGLE_COLUMN_TAB, TEXT_RUN, WATCH_VIDEO_ID,
15};
16use crate::parse::parse_flex_column_item;
17use crate::query::{AddHistoryItemQuery, GetHistoryQuery, RemoveHistoryItemsQuery};
18use crate::youtube_enums::YoutubeMusicVideoType;
19use const_format::concatcp;
20use json_crawler::{JsonCrawler, JsonCrawlerBorrowed, JsonCrawlerIterator, JsonCrawlerOwned};
21use serde::{Deserialize, Serialize};
22
23#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
24#[non_exhaustive]
25pub struct HistoryPeriod {
26    pub period_name: String,
27    pub items: Vec<HistoryItem>,
28}
29
30#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
31pub enum HistoryItem {
32    Song(HistoryItemSong),
33    Video(HistoryItemVideo),
34    Episode(HistoryItemEpisode),
35    UploadSong(HistoryItemUploadSong),
36}
37
38#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
39#[non_exhaustive]
40// Could this alternatively be Result<Song>?
41// May need to be enum to track 'Not Available' case.
42pub struct HistoryItemSong {
43    pub video_id: VideoID<'static>,
44    pub album: ParsedSongAlbum,
45    pub duration: String,
46    /// Some songs may not have library management features. There could be
47    /// various resons for this.
48    pub library_management: Option<LibraryManager>,
49    pub title: String,
50    pub artists: Vec<super::ParsedSongArtist>,
51    // TODO: Song like feedback tokens.
52    pub like_status: LikeStatus,
53    pub thumbnails: Vec<super::Thumbnail>,
54    pub explicit: Explicit,
55    pub is_available: bool,
56    /// Id of the playlist that will get created when pressing 'Start Radio'.
57    pub playlist_id: PlaylistID<'static>,
58    pub feedback_token_remove: FeedbackTokenRemoveFromHistory<'static>,
59}
60
61#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
62#[non_exhaustive]
63pub struct HistoryItemVideo {
64    pub video_id: VideoID<'static>,
65    pub duration: String,
66    pub title: String,
67    // Could be 'ParsedVideoChannel'
68    pub channel_name: String,
69    pub channel_id: ArtistChannelID<'static>,
70    // TODO: Song like feedback tokens.
71    pub like_status: LikeStatus,
72    pub thumbnails: Vec<super::Thumbnail>,
73    pub is_available: bool,
74    /// Id of the playlist that will get created when pressing 'Start Radio'.
75    pub playlist_id: PlaylistID<'static>,
76    pub feedback_token_remove: FeedbackTokenRemoveFromHistory<'static>,
77}
78
79#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
80#[non_exhaustive]
81pub struct HistoryItemEpisode {
82    pub episode_id: EpisodeID<'static>,
83    // May be live or non-live...
84    pub date: EpisodeDate,
85    pub duration: EpisodeDuration,
86    pub title: String,
87    pub podcast_name: String,
88    pub podcast_id: PlaylistID<'static>,
89    // TODO: Song like feedback tokens.
90    pub like_status: LikeStatus,
91    pub thumbnails: Vec<super::Thumbnail>,
92    pub is_available: bool,
93    pub feedback_token_remove: FeedbackTokenRemoveFromHistory<'static>,
94}
95
96#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
97#[non_exhaustive]
98// May need to be enum to track 'Not Available' case.
99// TODO: Move to common
100pub struct HistoryItemUploadSong {
101    pub entity_id: UploadEntityID<'static>,
102    pub video_id: VideoID<'static>,
103    pub album: ParsedUploadSongAlbum,
104    pub duration: String,
105    pub like_status: LikeStatus,
106    pub title: String,
107    pub artists: Vec<ParsedUploadArtist>,
108    pub thumbnails: Vec<Thumbnail>,
109    pub feedback_token_remove: FeedbackTokenRemoveFromHistory<'static>,
110}
111
112impl ParseFrom<GetHistoryQuery> for Vec<HistoryPeriod> {
113    fn parse_from(p: super::ProcessedResult<GetHistoryQuery>) -> Result<Self> {
114        let json_crawler = JsonCrawlerOwned::from(p);
115        let contents = json_crawler.navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST))?;
116        contents
117            .try_into_iter()?
118            .map(parse_history_period)
119            .collect()
120    }
121}
122impl ParseFrom<RemoveHistoryItemsQuery<'_>> for Vec<ApiOutcome> {
123    fn parse_from(p: super::ProcessedResult<RemoveHistoryItemsQuery>) -> Result<Self> {
124        let json_crawler = JsonCrawlerOwned::from(p);
125        json_crawler
126            .navigate_pointer("/feedbackResponses")?
127            .try_into_iter()?
128            .map(|mut response| {
129                response
130                    .take_value_pointer::<bool>("/isProcessed")
131                    .map(|p| {
132                        if p {
133                            return ApiOutcome::Success;
134                        }
135                        // Better handled in another way...
136                        ApiOutcome::Failure
137                    })
138            })
139            .rev()
140            .collect::<json_crawler::CrawlerResult<_>>()
141            .map_err(Into::into)
142    }
143}
144impl ParseFrom<AddHistoryItemQuery<'_>> for () {
145    fn parse_from(_: crate::parse::ProcessedResult<AddHistoryItemQuery>) -> crate::Result<Self> {
146        // Api only returns an empty string, no way of validating if correct or not.
147        Ok(())
148    }
149}
150
151fn parse_history_period(json: JsonCrawlerOwned) -> Result<HistoryPeriod> {
152    let mut data = json.navigate_pointer(MUSIC_SHELF)?;
153    let period_name = data.take_value_pointer(TITLE_TEXT)?;
154    let items = data
155        .navigate_pointer("/contents")?
156        .try_into_iter()?
157        .filter_map(|item| parse_history_item(item).transpose())
158        .collect::<Result<_>>()?;
159    Ok(HistoryPeriod { period_name, items })
160}
161fn parse_history_item(mut json: JsonCrawlerOwned) -> Result<Option<HistoryItem>> {
162    let Ok(mut data) = json.borrow_pointer(MRLIR) else {
163        return Ok(None);
164    };
165    let title = super::parse_flex_column_item(&mut data, 0, 0)?;
166    if title == "Shuffle all" {
167        return Ok(None);
168    }
169    let video_type_path = concatcp!(
170        PLAY_BUTTON,
171        "/playNavigationEndpoint",
172        NAVIGATION_VIDEO_TYPE
173    );
174    let video_type: YoutubeMusicVideoType = data.take_value_pointer(video_type_path)?;
175    let item = match video_type {
176        // NOTE - Possible for History, but most likely not possible for Library.
177        YoutubeMusicVideoType::Upload => Some(HistoryItem::UploadSong(
178            parse_history_item_upload_song(title, data)?,
179        )),
180        // NOTE - Possible for Library, but most likely not possible for History.
181        YoutubeMusicVideoType::Episode => Some(HistoryItem::Episode(parse_history_item_episode(
182            title, data,
183        )?)),
184        YoutubeMusicVideoType::Ugc
185        | YoutubeMusicVideoType::Omv
186        | YoutubeMusicVideoType::Shoulder
187        | YoutubeMusicVideoType::OfficialSourceMusic => {
188            Some(HistoryItem::Video(parse_history_item_video(title, data)?))
189        }
190        YoutubeMusicVideoType::Atv => {
191            Some(HistoryItem::Song(parse_history_item_song(title, data)?))
192        }
193    };
194    Ok(item)
195}
196
197fn parse_history_item_episode(
198    title: String,
199    mut data: JsonCrawlerBorrowed,
200) -> Result<HistoryItemEpisode> {
201    let video_id = data.take_value_pointer(concatcp!(
202        PLAY_BUTTON,
203        "/playNavigationEndpoint",
204        WATCH_VIDEO_ID
205    ))?;
206    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
207    let is_live = data.path_exists(LIVE_BADGE_LABEL);
208    let (duration, date) = match is_live {
209        true => (EpisodeDuration::Live, EpisodeDate::Live),
210        false => {
211            let date = parse_flex_column_item(&mut data, 2, 0)?;
212            let duration = data
213                .borrow_pointer(fixed_column_item_pointer(0))?
214                .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
215            (
216                EpisodeDuration::Recorded { duration },
217                EpisodeDate::Recorded { date },
218            )
219        }
220    };
221    let podcast_name = parse_flex_column_item(&mut data, 1, 0)?;
222    let podcast_id = data
223        .borrow_pointer(flex_column_item_pointer(1))?
224        .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
225    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
226    let is_available = data
227        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
228        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
229        .unwrap_or(true);
230    let feedback_token_remove = data
231        .navigate_pointer(MENU_ITEMS)?
232        .try_into_iter()?
233        .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
234        .take_value()?;
235    Ok(HistoryItemEpisode {
236        episode_id: video_id,
237        duration,
238        title,
239        like_status,
240        thumbnails,
241        date,
242        podcast_name,
243        podcast_id,
244        is_available,
245        feedback_token_remove,
246    })
247}
248fn parse_history_item_video(
249    title: String,
250    mut data: JsonCrawlerBorrowed,
251) -> Result<HistoryItemVideo> {
252    let video_id = data.take_value_pointer(concatcp!(
253        PLAY_BUTTON,
254        "/playNavigationEndpoint",
255        WATCH_VIDEO_ID
256    ))?;
257    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
258    let channel_name = parse_flex_column_item(&mut data, 1, 0)?;
259    let channel_id = data
260        .borrow_pointer(flex_column_item_pointer(1))?
261        .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
262    let duration = data
263        .borrow_pointer(fixed_column_item_pointer(0))?
264        .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
265    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
266    let is_available = data
267        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
268        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
269        .unwrap_or(true);
270    let mut menu = data.navigate_pointer(MENU_ITEMS)?;
271    let playlist_id = menu.take_value_pointer(concatcp!(
272        "/0/menuNavigationItemRenderer",
273        NAVIGATION_PLAYLIST_ID
274    ))?;
275    let feedback_token_remove = menu
276        .try_into_iter()?
277        .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
278        .take_value()?;
279    Ok(HistoryItemVideo {
280        video_id,
281        duration,
282        title,
283        like_status,
284        thumbnails,
285        playlist_id,
286        is_available,
287        channel_name,
288        channel_id,
289        feedback_token_remove,
290    })
291}
292fn parse_history_item_upload_song(
293    title: String,
294    mut data: JsonCrawlerBorrowed,
295) -> Result<HistoryItemUploadSong> {
296    let duration = data
297        .borrow_pointer(fixed_column_item_pointer(0))?
298        .take_value_pointer(TEXT_RUN_TEXT)?;
299    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
300    let video_id = data.take_value_pointer(concatcp!(
301        PLAY_BUTTON,
302        "/playNavigationEndpoint/watchEndpoint/videoId"
303    ))?;
304    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
305    let artists = parse_upload_song_artists(data.borrow_mut(), 1)?;
306    let album = parse_upload_song_album(data.borrow_mut(), 2)?;
307    let mut menu = data.navigate_pointer(MENU_ITEMS)?;
308    let entity_id = menu
309        .try_iter_mut()?
310        .find_path(DELETION_ENTITY_ID)?
311        .take_value()?;
312    let feedback_token_remove = menu
313        .try_into_iter()?
314        .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
315        .take_value()?;
316    Ok(HistoryItemUploadSong {
317        entity_id,
318        video_id,
319        album,
320        duration,
321        like_status,
322        title,
323        artists,
324        thumbnails,
325        feedback_token_remove,
326    })
327}
328fn parse_history_item_song(
329    title: String,
330    mut data: JsonCrawlerBorrowed,
331) -> Result<HistoryItemSong> {
332    let video_id = data.take_value_pointer(concatcp!(
333        PLAY_BUTTON,
334        "/playNavigationEndpoint",
335        WATCH_VIDEO_ID
336    ))?;
337    let library_management =
338        parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
339    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
340    let artists = super::parse_song_artists(&mut data, 1)?;
341    let album = super::parse_song_album(&mut data, 2)?;
342    let duration = data
343        .borrow_pointer(fixed_column_item_pointer(0))?
344        .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
345    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
346    let is_available = data
347        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
348        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
349        .unwrap_or(true);
350    let explicit = if data.path_exists(BADGE_LABEL) {
351        Explicit::IsExplicit
352    } else {
353        Explicit::NotExplicit
354    };
355    let mut menu = data.navigate_pointer(MENU_ITEMS)?;
356    let playlist_id = menu.take_value_pointer(concatcp!(
357        "/0/menuNavigationItemRenderer",
358        NAVIGATION_PLAYLIST_ID
359    ))?;
360    let feedback_token_remove = menu
361        .try_into_iter()?
362        .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
363        .take_value()?;
364    Ok(HistoryItemSong {
365        video_id,
366        duration,
367        library_management,
368        title,
369        artists,
370        like_status,
371        thumbnails,
372        explicit,
373        album,
374        playlist_id,
375        is_available,
376        feedback_token_remove,
377    })
378}
379
380#[cfg(test)]
381mod tests {
382    use crate::auth::BrowserToken;
383    use crate::common::{SongTrackingUrl, YoutubeID};
384    use crate::query::AddHistoryItemQuery;
385
386    #[tokio::test]
387    async fn test_add_history_item_query() {
388        let source = String::new();
389        crate::process_json::<_, BrowserToken>(
390            source,
391            AddHistoryItemQuery::new(SongTrackingUrl::from_raw("")),
392        )
393        .unwrap();
394    }
395    #[tokio::test]
396    async fn test_get_history() {
397        parse_test!(
398            "./test_json/get_history_20240701.json",
399            "./test_json/get_history_20240701_output.txt",
400            crate::query::GetHistoryQuery,
401            BrowserToken
402        );
403    }
404    #[tokio::test]
405    async fn test_get_history_with_upload_song() {
406        parse_test!(
407            "./test_json/get_history_20240713.json",
408            "./test_json/get_history_20240713_output.txt",
409            crate::query::GetHistoryQuery,
410            BrowserToken
411        );
412    }
413    #[tokio::test]
414    async fn test_remove_history_items() {
415        parse_test!(
416            "./test_json/remove_history_items_20240704.json",
417            "./test_json/remove_history_items_20240704_output.txt",
418            crate::query::RemoveHistoryItemsQuery::new(Vec::new()),
419            BrowserToken
420        );
421    }
422}