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)]
55pub struct ParsedPodcastChannel {
57 pub name: String,
58 pub id: Option<PodcastChannelID<'static>>,
59}
60#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
61pub enum IsSaved {
63 Saved,
64 NotSaved,
65}
66#[derive(Eq, PartialEq, Debug, Clone, Deserialize, Serialize, Hash)]
67pub 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 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
96impl 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 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}