Skip to main content

ytmapi_rs/parse/
recommendations.rs

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