ytmapi_rs/parse/
history.rs

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