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            Some(HistoryItem::Video(parse_history_item_video(title, data)?))
188        }
189        YoutubeMusicVideoType::Atv => {
190            Some(HistoryItem::Song(parse_history_item_song(title, data)?))
191        }
192    };
193    Ok(item)
194}
195
196fn parse_history_item_episode(
197    title: String,
198    mut data: JsonCrawlerBorrowed,
199) -> Result<HistoryItemEpisode> {
200    let video_id = data.take_value_pointer(concatcp!(
201        PLAY_BUTTON,
202        "/playNavigationEndpoint",
203        WATCH_VIDEO_ID
204    ))?;
205    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
206    let is_live = data.path_exists(LIVE_BADGE_LABEL);
207    let (duration, date) = match is_live {
208        true => (EpisodeDuration::Live, EpisodeDate::Live),
209        false => {
210            let date = parse_flex_column_item(&mut data, 2, 0)?;
211            let duration = data
212                .borrow_pointer(fixed_column_item_pointer(0))?
213                .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
214            (
215                EpisodeDuration::Recorded { duration },
216                EpisodeDate::Recorded { date },
217            )
218        }
219    };
220    let podcast_name = parse_flex_column_item(&mut data, 1, 0)?;
221    let podcast_id = data
222        .borrow_pointer(flex_column_item_pointer(1))?
223        .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
224    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
225    let is_available = data
226        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
227        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
228        .unwrap_or(true);
229    let feedback_token_remove = data
230        .navigate_pointer(MENU_ITEMS)?
231        .try_into_iter()?
232        .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
233        .take_value()?;
234    Ok(HistoryItemEpisode {
235        episode_id: video_id,
236        duration,
237        title,
238        like_status,
239        thumbnails,
240        date,
241        podcast_name,
242        podcast_id,
243        is_available,
244        feedback_token_remove,
245    })
246}
247fn parse_history_item_video(
248    title: String,
249    mut data: JsonCrawlerBorrowed,
250) -> Result<HistoryItemVideo> {
251    let video_id = data.take_value_pointer(concatcp!(
252        PLAY_BUTTON,
253        "/playNavigationEndpoint",
254        WATCH_VIDEO_ID
255    ))?;
256    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
257    let channel_name = parse_flex_column_item(&mut data, 1, 0)?;
258    let channel_id = data
259        .borrow_pointer(flex_column_item_pointer(1))?
260        .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
261    let duration = data
262        .borrow_pointer(fixed_column_item_pointer(0))?
263        .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
264    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
265    let is_available = data
266        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
267        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
268        .unwrap_or(true);
269    let mut menu = data.navigate_pointer(MENU_ITEMS)?;
270    let playlist_id = menu.take_value_pointer(concatcp!(
271        "/0/menuNavigationItemRenderer",
272        NAVIGATION_PLAYLIST_ID
273    ))?;
274    let feedback_token_remove = menu
275        .try_into_iter()?
276        .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
277        .take_value()?;
278    Ok(HistoryItemVideo {
279        video_id,
280        duration,
281        title,
282        like_status,
283        thumbnails,
284        playlist_id,
285        is_available,
286        channel_name,
287        channel_id,
288        feedback_token_remove,
289    })
290}
291fn parse_history_item_upload_song(
292    title: String,
293    mut data: JsonCrawlerBorrowed,
294) -> Result<HistoryItemUploadSong> {
295    let duration = data
296        .borrow_pointer(fixed_column_item_pointer(0))?
297        .take_value_pointer(TEXT_RUN_TEXT)?;
298    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
299    let video_id = data.take_value_pointer(concatcp!(
300        PLAY_BUTTON,
301        "/playNavigationEndpoint/watchEndpoint/videoId"
302    ))?;
303    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
304    let artists = parse_upload_song_artists(data.borrow_mut(), 1)?;
305    let album = parse_upload_song_album(data.borrow_mut(), 2)?;
306    let mut menu = data.navigate_pointer(MENU_ITEMS)?;
307    let entity_id = menu
308        .try_iter_mut()?
309        .find_path(DELETION_ENTITY_ID)?
310        .take_value()?;
311    let feedback_token_remove = menu
312        .try_into_iter()?
313        .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
314        .take_value()?;
315    Ok(HistoryItemUploadSong {
316        entity_id,
317        video_id,
318        album,
319        duration,
320        like_status,
321        title,
322        artists,
323        thumbnails,
324        feedback_token_remove,
325    })
326}
327fn parse_history_item_song(
328    title: String,
329    mut data: JsonCrawlerBorrowed,
330) -> Result<HistoryItemSong> {
331    let video_id = data.take_value_pointer(concatcp!(
332        PLAY_BUTTON,
333        "/playNavigationEndpoint",
334        WATCH_VIDEO_ID
335    ))?;
336    let library_management =
337        parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
338    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
339    let artists = super::parse_song_artists(&mut data, 1)?;
340    let album = super::parse_song_album(&mut data, 2)?;
341    let duration = data
342        .borrow_pointer(fixed_column_item_pointer(0))?
343        .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
344    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
345    let is_available = data
346        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
347        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
348        .unwrap_or(true);
349    let explicit = if data.path_exists(BADGE_LABEL) {
350        Explicit::IsExplicit
351    } else {
352        Explicit::NotExplicit
353    };
354    let mut menu = data.navigate_pointer(MENU_ITEMS)?;
355    let playlist_id = menu.take_value_pointer(concatcp!(
356        "/0/menuNavigationItemRenderer",
357        NAVIGATION_PLAYLIST_ID
358    ))?;
359    let feedback_token_remove = menu
360        .try_into_iter()?
361        .find_path(concatcp!(MENU_SERVICE, FEEDBACK_TOKEN))?
362        .take_value()?;
363    Ok(HistoryItemSong {
364        video_id,
365        duration,
366        library_management,
367        title,
368        artists,
369        like_status,
370        thumbnails,
371        explicit,
372        album,
373        playlist_id,
374        is_available,
375        feedback_token_remove,
376    })
377}
378
379#[cfg(test)]
380mod tests {
381    use crate::auth::BrowserToken;
382    use crate::common::{SongTrackingUrl, YoutubeID};
383    use crate::query::AddHistoryItemQuery;
384
385    #[tokio::test]
386    async fn test_add_history_item_query() {
387        let source = String::new();
388        crate::process_json::<_, BrowserToken>(
389            source,
390            AddHistoryItemQuery::new(SongTrackingUrl::from_raw("")),
391        )
392        .unwrap();
393    }
394    #[tokio::test]
395    async fn test_get_history() {
396        parse_test!(
397            "./test_json/get_history_20240701.json",
398            "./test_json/get_history_20240701_output.txt",
399            crate::query::GetHistoryQuery,
400            BrowserToken
401        );
402    }
403    #[tokio::test]
404    async fn test_get_history_with_upload_song() {
405        parse_test!(
406            "./test_json/get_history_20240713.json",
407            "./test_json/get_history_20240713_output.txt",
408            crate::query::GetHistoryQuery,
409            BrowserToken
410        );
411    }
412    #[tokio::test]
413    async fn test_remove_history_items() {
414        parse_test!(
415            "./test_json/remove_history_items_20240704.json",
416            "./test_json/remove_history_items_20240704_output.txt",
417            crate::query::RemoveHistoryItemsQuery::new(Vec::new()),
418            BrowserToken
419        );
420    }
421}