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)]
63pub struct ParsedPodcastChannel {
65 pub name: String,
66 pub id: Option<PodcastChannelID<'static>>,
67}
68#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
69pub enum IsSaved {
71 Saved,
72 NotSaved,
73}
74#[derive(Eq, PartialEq, Debug, Clone, Deserialize, Serialize, Hash)]
75pub 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 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
105impl 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 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}