ytmapi_rs/parse/
podcasts.rs

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