Skip to main content

ytmapi_rs/parse/
podcasts.rs

1use super::{
2    ParseFrom, RUN_TEXT, SECONDARY_SECTION_LIST_ITEM, STRAPLINE_RUNS, TAB_CONTENT,
3    THUMBNAIL_RENDERER, THUMBNAILS, TITLE_TEXT, VISUAL_HEADER,
4};
5use crate::Result;
6use crate::common::{
7    EpisodeID, LibraryStatus, PlaylistID, PodcastChannelID, PodcastChannelParams, PodcastID,
8    Thumbnail,
9};
10use crate::nav_consts::{
11    CAROUSEL, CAROUSEL_TITLE, DESCRIPTION, DESCRIPTION_SHELF, GRID_ITEMS, MMRLIR, MTRIR,
12    MUSIC_SHELF, NAVIGATION_BROWSE, NAVIGATION_BROWSE_ID, PLAYBACK_DURATION_TEXT,
13    PLAYBACK_PROGRESS_TEXT, RESPONSIVE_HEADER, SECTION_LIST, SECTION_LIST_ITEM, SINGLE_COLUMN_TAB,
14    SUBTITLE, SUBTITLE_RUNS, SUBTITLE3, TITLE, TWO_COLUMN,
15};
16use crate::query::{
17    GetChannelEpisodesQuery, GetChannelQuery, GetEpisodeQuery, GetNewEpisodesQuery, GetPodcastQuery,
18};
19use const_format::concatcp;
20use itertools::Itertools;
21use json_crawler::{JsonCrawler, JsonCrawlerOwned};
22use serde::{Deserialize, Serialize};
23
24#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
25#[non_exhaustive]
26pub struct GetPodcastChannel {
27    pub title: String,
28    pub thumbnails: Vec<Thumbnail>,
29    pub episode_params: Option<PodcastChannelParams<'static>>,
30    pub episodes: Vec<Episode>,
31    pub podcasts: Vec<GetPodcastChannelPodcast>,
32    pub playlists: Vec<GetPodcastChannelPlaylist>,
33}
34#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
35#[non_exhaustive]
36pub struct Episode {
37    pub title: String,
38    pub description: String,
39    pub total_duration: String,
40    pub remaining_duration: String,
41    pub date: String,
42    pub episode_id: EpisodeID<'static>,
43    pub thumbnails: Vec<Thumbnail>,
44}
45#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
46#[non_exhaustive]
47pub struct GetPodcastChannelPodcast {
48    pub title: String,
49    pub channels: Vec<ParsedPodcastChannel>,
50    pub podcast_id: PodcastID<'static>,
51    pub thumbnails: Vec<Thumbnail>,
52}
53#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
54#[non_exhaustive]
55pub struct GetPodcastChannelPlaylist {
56    pub title: String,
57    pub channel: ParsedPodcastChannel,
58    pub playlist_id: PlaylistID<'static>,
59    pub views: String,
60    pub thumbnails: Vec<Thumbnail>,
61}
62#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
63// Intentionally not marked non_exhaustive - not expected to change.
64pub struct ParsedPodcastChannel {
65    pub name: String,
66    pub id: Option<PodcastChannelID<'static>>,
67}
68#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
69// Intentionally not marked non_exhaustive - not expected to change.
70pub enum IsSaved {
71    Saved,
72    NotSaved,
73}
74#[derive(Eq, PartialEq, Debug, Clone, Deserialize, Serialize, Hash)]
75// Intentionally not marked non_exhaustive - not expected to change.
76pub enum PodcastChannelTopResult {
77    #[serde(rename = "Latest episodes")]
78    Episodes,
79    Podcasts,
80    Playlists,
81}
82#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
83#[non_exhaustive]
84pub struct GetPodcast {
85    pub channels: Vec<ParsedPodcastChannel>,
86    pub title: String,
87    pub description: String,
88    // TODO: How to add a podcast to library?
89    pub library_status: LibraryStatus,
90    pub episodes: Vec<Episode>,
91}
92#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
93#[non_exhaustive]
94pub struct GetEpisode {
95    pub podcast_name: String,
96    pub podcast_id: PodcastID<'static>,
97    pub title: String,
98    pub date: String,
99    pub total_duration: String,
100    pub remaining_duration: String,
101    pub saved: IsSaved,
102    pub description: String,
103}
104
105// NOTE: This is technically the same page as the GetArtist page. It's possible
106// this could be generalised.
107impl ParseFrom<GetChannelQuery<'_>> for GetPodcastChannel {
108    fn parse_from(p: crate::ProcessedResult<GetChannelQuery>) -> Result<Self> {
109        fn parse_podcast(crawler: impl JsonCrawler) -> Result<GetPodcastChannelPodcast> {
110            let mut podcast = crawler.navigate_pointer(MTRIR)?;
111            let title = podcast.take_value_pointer(TITLE_TEXT)?;
112            let podcast_id = podcast.take_value_pointer(NAVIGATION_BROWSE_ID)?;
113            let thumbnails = podcast.take_value_pointer(THUMBNAIL_RENDERER)?;
114            let channels = podcast
115                .navigate_pointer(SUBTITLE_RUNS)?
116                .try_into_iter()?
117                .map(parse_podcast_channel)
118                .collect::<Result<Vec<_>>>()?;
119            Ok(GetPodcastChannelPodcast {
120                title,
121                channels,
122                podcast_id,
123                thumbnails,
124            })
125        }
126        fn parse_playlist(crawler: impl JsonCrawler) -> Result<GetPodcastChannelPlaylist> {
127            let mut podcast = crawler.navigate_pointer(MTRIR)?;
128            let title = podcast.take_value_pointer(TITLE_TEXT)?;
129            let playlist_id = podcast.take_value_pointer(NAVIGATION_BROWSE_ID)?;
130            let thumbnails = podcast.take_value_pointer(THUMBNAIL_RENDERER)?;
131            let views = podcast.take_value_pointer(SUBTITLE3)?;
132            let channel =
133                parse_podcast_channel(podcast.navigate_pointer(SUBTITLE_RUNS)?.navigate_index(2)?)?;
134            Ok(GetPodcastChannelPlaylist {
135                title,
136                channel,
137                thumbnails,
138                playlist_id,
139                views,
140            })
141        }
142        let mut json_crawler = JsonCrawlerOwned::from(p);
143        let mut header = json_crawler.borrow_pointer(VISUAL_HEADER)?;
144        let title = header.take_value_pointer(TITLE_TEXT)?;
145        let thumbnails = header.take_value_pointer(THUMBNAILS)?;
146        let mut podcasts = Vec::new();
147        let mut episodes = Vec::new();
148        let mut playlists = Vec::new();
149        let mut episode_params = None;
150        // I spent a good few hours trying to make this declarative. It this stage this
151        // seems to be more readable and more efficient. The best declarative approach I
152        // could find used a collect into a HashMap and the process_results()
153        // function...
154        for carousel in json_crawler
155            .borrow_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST))?
156            .try_into_iter()?
157            .map(|item| item.navigate_pointer(CAROUSEL))
158        {
159            let mut carousel = carousel?;
160            match carousel
161                .take_value_pointer::<PodcastChannelTopResult>(concatcp!(CAROUSEL_TITLE, "/text"))?
162            {
163                PodcastChannelTopResult::Episodes => {
164                    episode_params = carousel.take_value_pointer(concatcp!(
165                        CAROUSEL_TITLE,
166                        NAVIGATION_BROWSE,
167                        "/params"
168                    ))?;
169                    episodes = carousel
170                        .navigate_pointer("/contents")?
171                        .try_into_iter()?
172                        .map(parse_episode)
173                        .collect::<Result<_>>()?;
174                }
175                PodcastChannelTopResult::Podcasts => {
176                    podcasts = carousel
177                        .navigate_pointer("/contents")?
178                        .try_into_iter()?
179                        .map(parse_podcast)
180                        .collect::<Result<_>>()?;
181                }
182                PodcastChannelTopResult::Playlists => {
183                    playlists = carousel
184                        .navigate_pointer("/contents")?
185                        .try_into_iter()?
186                        .map(parse_playlist)
187                        .collect::<Result<_>>()?;
188                }
189            }
190        }
191        Ok(GetPodcastChannel {
192            title,
193            thumbnails,
194            episode_params,
195            episodes,
196            podcasts,
197            playlists,
198        })
199    }
200}
201impl ParseFrom<GetChannelEpisodesQuery<'_>> for Vec<Episode> {
202    fn parse_from(p: crate::ProcessedResult<GetChannelEpisodesQuery>) -> Result<Self> {
203        let json_crawler = JsonCrawlerOwned::from(p);
204        json_crawler
205            .navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST_ITEM, GRID_ITEMS))?
206            .try_into_iter()?
207            .map(parse_episode)
208            .collect()
209    }
210}
211impl ParseFrom<GetPodcastQuery<'_>> for GetPodcast {
212    fn parse_from(p: crate::ProcessedResult<GetPodcastQuery>) -> Result<Self> {
213        let json_crawler = JsonCrawlerOwned::from(p);
214        let mut two_column = json_crawler.navigate_pointer(TWO_COLUMN)?;
215        let episodes = two_column
216            .borrow_pointer(concatcp!(
217                "/secondaryContents",
218                SECTION_LIST_ITEM,
219                MUSIC_SHELF,
220                "/contents"
221            ))?
222            .try_into_iter()?
223            .map(parse_episode)
224            .collect::<Result<_>>()?;
225        let mut responsive_header = two_column.navigate_pointer(concatcp!(
226            TAB_CONTENT,
227            SECTION_LIST_ITEM,
228            RESPONSIVE_HEADER,
229        ))?;
230        let library_status = match responsive_header
231            .take_value_pointer::<bool>("/buttons/1/toggleButtonRenderer/isToggled")?
232        {
233            true => LibraryStatus::InLibrary,
234            false => LibraryStatus::NotInLibrary,
235        };
236        let channels = responsive_header
237            .borrow_pointer(STRAPLINE_RUNS)?
238            .try_into_iter()?
239            .map(parse_podcast_channel)
240            .collect::<Result<_>>()?;
241        let mut description_shelf =
242            responsive_header.navigate_pointer(concatcp!("/description", DESCRIPTION_SHELF))?;
243        let description = description_shelf.take_value_pointer(DESCRIPTION)?;
244        let title = description_shelf.take_value_pointer(concatcp!("/header", RUN_TEXT))?;
245        Ok(GetPodcast {
246            channels,
247            title,
248            description,
249            library_status,
250            episodes,
251        })
252    }
253}
254impl ParseFrom<GetEpisodeQuery<'_>> for GetEpisode {
255    fn parse_from(p: crate::ProcessedResult<GetEpisodeQuery>) -> Result<Self> {
256        let json_crawler = JsonCrawlerOwned::from(p);
257        let mut two_column = json_crawler.navigate_pointer(TWO_COLUMN)?;
258        let mut responsive_header = two_column.borrow_pointer(concatcp!(
259            TAB_CONTENT,
260            SECTION_LIST_ITEM,
261            RESPONSIVE_HEADER,
262        ))?;
263        let title = responsive_header.take_value_pointer(TITLE_TEXT)?;
264        let date = responsive_header.take_value_pointer(SUBTITLE)?;
265        let total_duration = responsive_header.take_value_pointer(
266            "/progress/musicPlaybackProgressRenderer/playbackProgressText/runs/1/text",
267        )?;
268        let remaining_duration = responsive_header.take_value_pointer(
269            "/progress/musicPlaybackProgressRenderer/durationText/runs/1/text",
270        )?;
271        let saved = match responsive_header
272            .take_value_pointer::<bool>("/buttons/0/toggleButtonRenderer/isToggled")?
273        {
274            true => IsSaved::Saved,
275            false => IsSaved::NotSaved,
276        };
277        let mut strapline = responsive_header.navigate_pointer(concatcp!(STRAPLINE_RUNS, "/0"))?;
278        let podcast_name = strapline.take_value_pointer("/text")?;
279        let podcast_id = strapline.take_value_pointer(NAVIGATION_BROWSE_ID)?;
280        let description = two_column
281            .navigate_pointer(concatcp!(
282                SECONDARY_SECTION_LIST_ITEM,
283                DESCRIPTION_SHELF,
284                "/description/runs"
285            ))?
286            .try_into_iter()?
287            .map(|mut item| item.take_value_pointer::<String>("/text"))
288            .process_results(|iter| iter.collect())?;
289        Ok(GetEpisode {
290            title,
291            date,
292            total_duration,
293            remaining_duration,
294            saved,
295            description,
296            podcast_name,
297            podcast_id,
298        })
299    }
300}
301impl ParseFrom<GetNewEpisodesQuery> for Vec<Episode> {
302    fn parse_from(p: crate::ProcessedResult<GetNewEpisodesQuery>) -> Result<Self> {
303        let json_crawler = JsonCrawlerOwned::from(p);
304        json_crawler
305            .navigate_pointer(concatcp!(
306                TWO_COLUMN,
307                "/secondaryContents",
308                SECTION_LIST_ITEM,
309                MUSIC_SHELF,
310                "/contents"
311            ))?
312            .try_into_iter()?
313            .map(parse_episode)
314            .collect()
315    }
316}
317
318pub(crate) fn parse_podcast_channel(mut data: impl JsonCrawler) -> Result<ParsedPodcastChannel> {
319    Ok(ParsedPodcastChannel {
320        name: data.take_value_pointer("/text")?,
321        id: data.take_value_pointer(NAVIGATION_BROWSE_ID).ok(),
322    })
323}
324
325fn parse_episode(crawler: impl JsonCrawler) -> Result<Episode> {
326    let mut episode = crawler.navigate_pointer(MMRLIR)?;
327    let description = episode.take_value_pointer(DESCRIPTION)?;
328    let total_duration = episode.take_value_pointer(PLAYBACK_DURATION_TEXT)?;
329    let remaining_duration = episode.take_value_pointer(PLAYBACK_PROGRESS_TEXT)?;
330    let date = episode.take_value_pointer(SUBTITLE)?;
331    let thumbnails = episode.take_value_pointer(THUMBNAILS)?;
332    let mut title_run = episode.navigate_pointer(TITLE)?;
333    let title = title_run.take_value_pointer("/text")?;
334    let episode_id = title_run.take_value_pointer(NAVIGATION_BROWSE_ID)?;
335    Ok(Episode {
336        title,
337        description,
338        total_duration,
339        remaining_duration,
340        date,
341        episode_id,
342        thumbnails,
343    })
344}
345
346#[cfg(test)]
347mod tests {
348    use crate::auth::BrowserToken;
349    use crate::common::{EpisodeID, PodcastChannelID, PodcastChannelParams, PodcastID, YoutubeID};
350    use crate::query::{
351        GetChannelEpisodesQuery, GetChannelQuery, GetEpisodeQuery, GetNewEpisodesQuery,
352        GetPodcastQuery,
353    };
354
355    #[tokio::test]
356    async fn test_get_channel() {
357        parse_test!(
358            "./test_json/get_channel_20240830.json",
359            "./test_json/get_channel_20240830_output.txt",
360            GetChannelQuery::new(PodcastChannelID::from_raw("")),
361            BrowserToken
362        );
363    }
364    #[tokio::test]
365    async fn test_get_channel_episodes() {
366        parse_test!(
367            "./test_json/get_channel_episodes_20240830.json",
368            "./test_json/get_channel_episodes_20240830_output.txt",
369            GetChannelEpisodesQuery::new(
370                PodcastChannelID::from_raw(""),
371                PodcastChannelParams::from_raw("")
372            ),
373            BrowserToken
374        );
375    }
376    #[tokio::test]
377    async fn test_get_podcast() {
378        parse_test!(
379            "./test_json/get_podcast_20240830.json",
380            "./test_json/get_podcast_20240830_output.txt",
381            GetPodcastQuery::new(PodcastID::from_raw("")),
382            BrowserToken
383        );
384    }
385    #[tokio::test]
386    async fn test_get_episode() {
387        parse_test!(
388            "./test_json/get_episode_20240830.json",
389            "./test_json/get_episode_20240830_output.txt",
390            GetEpisodeQuery::new(EpisodeID::from_raw("")),
391            BrowserToken
392        );
393    }
394    #[tokio::test]
395    async fn test_get_new_episodes() {
396        parse_test!(
397            "./test_json/get_new_episodes_20240830.json",
398            "./test_json/get_new_episodes_20240830_output.txt",
399            GetNewEpisodesQuery,
400            BrowserToken
401        );
402    }
403}