ytmapi_rs/parse/
recommendations.rs

1use super::{
2    ParseFrom, CATEGORY_TITLE, GRID, RUN_TEXT, TASTE_ITEM_CONTENTS, TASTE_PROFILE_ARTIST,
3    TASTE_PROFILE_IMPRESSION, TASTE_PROFILE_ITEMS, TASTE_PROFILE_SELECTION,
4};
5use crate::{
6    common::{MoodCategoryParams, PlaylistID, TasteToken, Thumbnail},
7    nav_consts::{
8        CAROUSEL, CAROUSEL_TITLE, CATEGORY_PARAMS, MTRIR, NAVIGATION_BROWSE_ID, SECTION_LIST,
9        SINGLE_COLUMN_TAB, SUBTITLE_RUNS, THUMBNAIL_RENDERER, TITLE_TEXT,
10    },
11    query::{
12        GetMoodCategoriesQuery, GetMoodPlaylistsQuery, GetTasteProfileQuery, SetTasteProfileQuery,
13    },
14    Result,
15};
16use const_format::concatcp;
17use itertools::Itertools;
18use json_crawler::{CrawlerResult, JsonCrawler, JsonCrawlerBorrowed, JsonCrawlerOwned};
19use serde::{Deserialize, Serialize};
20
21#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
22pub struct TasteProfileArtist {
23    pub artist: String,
24    pub taste_tokens: TasteToken<'static>,
25}
26#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
27pub struct MoodCategorySection {
28    pub section_name: String,
29    pub mood_categories: Vec<MoodCategory>,
30}
31
32#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
33pub struct MoodCategory {
34    pub title: String,
35    pub params: MoodCategoryParams<'static>,
36}
37
38#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
39pub struct MoodPlaylistCategory {
40    pub category_name: String,
41    pub playlists: Vec<MoodPlaylist>,
42}
43
44#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
45pub struct MoodPlaylist {
46    pub playlist_id: PlaylistID<'static>,
47    pub title: String,
48    pub thumbnails: Vec<Thumbnail>,
49    pub author: String,
50}
51
52impl<'a, I> ParseFrom<SetTasteProfileQuery<'a, I>> for ()
53where
54    I: Iterator<Item = TasteToken<'a>> + Clone,
55{
56    fn parse_from(_: super::ProcessedResult<SetTasteProfileQuery<'a, I>>) -> Result<Self> {
57        // Doesn't seem to be an identifier in the response to determine if success or
58        // failure - so always assume success.
59        Ok(())
60    }
61}
62
63impl ParseFrom<GetTasteProfileQuery> for Vec<TasteProfileArtist> {
64    fn parse_from(p: super::ProcessedResult<GetTasteProfileQuery>) -> Result<Self> {
65        let crawler = JsonCrawlerOwned::from(p);
66        crawler
67            .navigate_pointer(TASTE_PROFILE_ITEMS)?
68            .try_into_iter()?
69            .map(|item| -> Result<_> {
70                Ok(item
71                    .navigate_pointer(TASTE_ITEM_CONTENTS)?
72                    .try_into_iter()?
73                    .map(get_taste_profile_artist))
74            })
75            .process_results(|nested_iter| nested_iter.flatten().collect::<Result<_>>())?
76    }
77}
78
79impl ParseFrom<GetMoodCategoriesQuery> for Vec<MoodCategorySection> {
80    fn parse_from(p: super::ProcessedResult<GetMoodCategoriesQuery>) -> crate::Result<Self> {
81        let crawler = JsonCrawlerOwned::from(p);
82        crawler
83            .navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST))?
84            .try_into_iter()?
85            .map(parse_mood_category_sections)
86            .collect()
87    }
88}
89impl<'a> ParseFrom<GetMoodPlaylistsQuery<'a>> for Vec<MoodPlaylistCategory> {
90    fn parse_from(p: super::ProcessedResult<GetMoodPlaylistsQuery<'a>>) -> Result<Self> {
91        fn parse_mood_playlist_category(
92            mut crawler: JsonCrawlerOwned,
93        ) -> Result<MoodPlaylistCategory> {
94            let array = vec![
95                |s: &mut JsonCrawlerOwned| -> std::result::Result<_, json_crawler::CrawlerError> {
96                    parse_mood_playlist_category_grid(s.borrow_pointer(GRID)?)
97                },
98                |s: &mut JsonCrawlerOwned| -> std::result::Result<_, json_crawler::CrawlerError> {
99                    parse_mood_playlist_category_carousel(s.borrow_pointer(CAROUSEL)?)
100                },
101            ];
102            crawler.try_functions(array).map_err(Into::into)
103        }
104        fn parse_mood_playlist_category_grid(
105            mut crawler: JsonCrawlerBorrowed,
106        ) -> json_crawler::CrawlerResult<MoodPlaylistCategory> {
107            let category_name =
108                crawler.take_value_pointer(concatcp!("/header/gridHeaderRenderer", TITLE_TEXT))?;
109            let playlists = crawler
110                .navigate_pointer("/items")?
111                .try_iter_mut()?
112                .map(parse_mood_playlist)
113                .collect::<CrawlerResult<_>>()?;
114            Ok(MoodPlaylistCategory {
115                category_name,
116                playlists,
117            })
118        }
119        fn parse_mood_playlist_category_carousel(
120            mut crawler: JsonCrawlerBorrowed,
121        ) -> json_crawler::CrawlerResult<MoodPlaylistCategory> {
122            let category_name = crawler.take_value_pointer(concatcp!(CAROUSEL_TITLE, "/text"))?;
123            let playlists = crawler
124                .navigate_pointer("/contents")?
125                .try_iter_mut()?
126                .map(parse_mood_playlist)
127                .collect::<CrawlerResult<_>>()?;
128            Ok(MoodPlaylistCategory {
129                category_name,
130                playlists,
131            })
132        }
133        fn parse_mood_playlist(
134            crawler: JsonCrawlerBorrowed,
135        ) -> json_crawler::CrawlerResult<MoodPlaylist> {
136            let mut item = crawler.navigate_pointer(MTRIR)?;
137            let playlist_id = item.take_value_pointer(NAVIGATION_BROWSE_ID)?;
138            let title = item.take_value_pointer(TITLE_TEXT)?;
139            let thumbnails = item.take_value_pointer(THUMBNAIL_RENDERER)?;
140
141            let author = item.borrow_pointer(SUBTITLE_RUNS)?.try_expect(
142                "Subtitle runs should contain at least 1 item",
143                |subtitle_runs| {
144                    subtitle_runs
145                        .try_iter_mut()?
146                        .take(3)
147                        .next_back()
148                        .map(|mut run| run.take_value_pointer("/text"))
149                        .transpose()
150                },
151            )?;
152
153            Ok(MoodPlaylist {
154                playlist_id,
155                title,
156                thumbnails,
157                author,
158            })
159        }
160        let json_crawler: JsonCrawlerOwned = p.into();
161        json_crawler
162            .navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST))?
163            .try_into_iter()?
164            .map(parse_mood_playlist_category)
165            .collect()
166    }
167}
168
169fn parse_mood_category_sections(crawler: JsonCrawlerOwned) -> Result<MoodCategorySection> {
170    let mut crawler = crawler.navigate_pointer(GRID)?;
171    let section_name =
172        crawler.take_value_pointer(concatcp!("/header/gridHeaderRenderer/title", RUN_TEXT))?;
173    let mood_categories = crawler
174        .navigate_pointer("/items")?
175        .try_into_iter()?
176        .map(parse_mood_categories)
177        .collect::<Result<Vec<_>>>()?;
178    Ok(MoodCategorySection {
179        section_name,
180        mood_categories,
181    })
182}
183fn parse_mood_categories(crawler: JsonCrawlerOwned) -> Result<MoodCategory> {
184    let mut crawler = crawler.navigate_pointer("/musicNavigationButtonRenderer")?;
185    let title = crawler.take_value_pointer(concatcp!(CATEGORY_TITLE))?;
186    let params = crawler.take_value_pointer(concatcp!(CATEGORY_PARAMS))?;
187    Ok(MoodCategory { title, params })
188}
189
190fn get_taste_profile_artist(mut crawler: JsonCrawlerOwned) -> Result<TasteProfileArtist> {
191    let artist = crawler.take_value_pointer(TASTE_PROFILE_ARTIST)?;
192    let impression_value = crawler.take_value_pointer(TASTE_PROFILE_IMPRESSION)?;
193    let selection_value = crawler.take_value_pointer(TASTE_PROFILE_SELECTION)?;
194    let taste_tokens = TasteToken {
195        impression_value,
196        selection_value,
197    };
198    Ok(TasteProfileArtist {
199        artist,
200        taste_tokens,
201    })
202}
203
204#[cfg(test)]
205mod tests {
206    use crate::{
207        auth::BrowserToken,
208        common::{
209            MoodCategoryParams, TasteToken, TasteTokenImpression, TasteTokenSelection, YoutubeID,
210        },
211        query::{
212            GetMoodCategoriesQuery, GetMoodPlaylistsQuery, GetTasteProfileQuery,
213            SetTasteProfileQuery,
214        },
215    };
216
217    #[tokio::test]
218    async fn test_get_mood_categories() {
219        parse_test!(
220            "./test_json/get_mood_categories_20240723.json",
221            "./test_json/get_mood_categories_20240723_output.txt",
222            GetMoodCategoriesQuery,
223            BrowserToken
224        );
225    }
226    #[tokio::test]
227    async fn test_get_mood_playlists() {
228        parse_test!(
229            "./test_json/get_mood_playlists_20240723.json",
230            "./test_json/get_mood_playlists_20240723_output.txt",
231            GetMoodPlaylistsQuery::new(MoodCategoryParams::from_raw("")),
232            BrowserToken
233        );
234    }
235    #[tokio::test]
236    async fn test_get_taste_profile() {
237        parse_test!(
238            "./test_json/get_taste_profile_20240722.json",
239            "./test_json/get_taste_profile_20240722_output.txt",
240            GetTasteProfileQuery,
241            BrowserToken
242        );
243    }
244    #[tokio::test]
245    async fn test_set_taste_profile() {
246        parse_test_value!(
247            "./test_json/set_taste_profile_20240723.json",
248            (),
249            SetTasteProfileQuery::new([TasteToken {
250                impression_value: TasteTokenImpression::from_raw(""),
251                selection_value: TasteTokenSelection::from_raw("")
252            }]),
253            BrowserToken
254        );
255    }
256}