Skip to main content

ytmapi_rs/parse/
library.rs

1use super::{
2    BADGE_LABEL, CONTINUATION_PARAMS, GRID_CONTINUATION, MENU_LIKE_STATUS,
3    MUSIC_SHELF_CONTINUATION, ParseFrom, ParsedPodcastChannel, ProcessedResult, SUBTITLE,
4    SUBTITLE_BADGE_LABEL, SUBTITLE2, SUBTITLE3, SearchResultAlbum, THUMBNAILS, TableListSong,
5    fixed_column_item_pointer, parse_flex_column_item, parse_library_management_items_from_menu,
6    parse_podcast_channel,
7};
8use crate::Result;
9use crate::common::{
10    ApiOutcome, ArtistChannelID, ContinuationParams, Explicit, PlaylistID, PodcastChannelID,
11    PodcastID, Thumbnail,
12};
13use crate::continuations::ParseFromContinuable;
14use crate::nav_consts::{
15    GRID, ITEM_SECTION, MENU_ITEMS, MRLIR, MTRIR, MUSIC_SHELF, NAVIGATION_BROWSE_ID,
16    NAVIGATION_PLAYLIST_ID, PLAY_BUTTON, RUN_TEXT, SECTION_LIST, SECTION_LIST_ITEM,
17    SINGLE_COLUMN_TAB, SUBTITLE_BADGE_ICON, THUMBNAIL_RENDERER, TITLE, TITLE_TEXT, WATCH_VIDEO_ID,
18};
19use crate::query::library::{GetLibraryChannelsQuery, GetLibraryPodcastsQuery};
20use crate::query::{
21    EditSongLibraryStatusQuery, GetContinuationsQuery, GetLibraryAlbumsQuery,
22    GetLibraryArtistSubscriptionsQuery, GetLibraryArtistsQuery, GetLibraryPlaylistsQuery,
23    GetLibrarySongsQuery,
24};
25use crate::youtube_enums::YoutubeMusicBadgeRendererIcon;
26use const_format::concatcp;
27use json_crawler::{CrawlerResult, JsonCrawler, JsonCrawlerBorrowed, JsonCrawlerOwned};
28use serde::{Deserialize, Serialize};
29
30#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
31#[non_exhaustive]
32// Very similar to LibraryArtist struct
33pub struct GetLibraryArtistSubscription {
34    pub name: String,
35    pub subscribers: String,
36    pub channel_id: ArtistChannelID<'static>,
37    pub thumbnails: Vec<Thumbnail>,
38}
39
40#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
41#[non_exhaustive]
42// Very similar to LibraryArtist struct
43pub struct LibraryArtistSubscription {
44    pub name: String,
45    pub subscribers: String,
46    pub channel_id: ArtistChannelID<'static>,
47    pub thumbnails: Vec<Thumbnail>,
48}
49
50#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
51#[non_exhaustive]
52pub struct LibraryPlaylist {
53    pub playlist_id: PlaylistID<'static>,
54    pub title: String,
55    pub thumbnails: Vec<Thumbnail>,
56    pub tracks: String,
57    pub author: String,
58    // Authoer may be YouTube Music in some cases - no ChannelID
59    pub author_id: Option<ArtistChannelID<'static>>,
60}
61#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
62#[non_exhaustive]
63pub struct LibraryArtist {
64    pub channel_id: ArtistChannelID<'static>,
65    pub artist: String,
66    pub byline: String, // e.g 16 songs or 17.8k subscribers
67}
68#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
69#[non_exhaustive]
70pub struct LibraryPodcast {
71    pub title: String,
72    pub channels: Vec<ParsedPodcastChannel>,
73    pub podcast_id: PodcastID<'static>,
74    pub thumbnails: Vec<Thumbnail>,
75    pub podcast_source: PodcastSource,
76}
77#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
78#[non_exhaustive]
79pub struct LibraryChannel {
80    pub title: String,
81    pub subscribers: String,
82    pub channel_id: PodcastChannelID<'static>,
83    pub thumbnails: Vec<Thumbnail>,
84}
85
86#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
87pub enum PodcastSource {
88    Rss,
89    YouTube,
90}
91
92impl ParseFromContinuable<GetLibraryArtistSubscriptionsQuery> for Vec<LibraryArtistSubscription> {
93    fn parse_from_continuable(
94        p: ProcessedResult<GetLibraryArtistSubscriptionsQuery>,
95    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
96        let json_crawler: JsonCrawlerOwned = p.into();
97        let music_shelf = json_crawler.navigate_pointer(concatcp!(
98            SINGLE_COLUMN_TAB,
99            SECTION_LIST_ITEM,
100            MUSIC_SHELF,
101        ))?;
102        parse_library_artist_subscriptions(music_shelf)
103    }
104    fn parse_continuation(
105        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryArtistSubscriptionsQuery>>,
106    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
107        let json_crawler: JsonCrawlerOwned = p.into();
108        let music_shelf = json_crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
109        parse_library_artist_subscriptions(music_shelf)
110    }
111}
112
113impl ParseFromContinuable<GetLibraryAlbumsQuery> for Vec<SearchResultAlbum> {
114    fn parse_from_continuable(
115        p: ProcessedResult<GetLibraryAlbumsQuery>,
116    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
117        let json_crawler: JsonCrawlerOwned = p.into();
118        let grid_renderer =
119            json_crawler.navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST_ITEM, GRID))?;
120        parse_library_albums(grid_renderer)
121    }
122    fn parse_continuation(
123        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryAlbumsQuery>>,
124    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
125        let json_crawler: JsonCrawlerOwned = p.into();
126        let grid_items = json_crawler.navigate_pointer(GRID_CONTINUATION)?;
127        parse_library_albums(grid_items)
128    }
129}
130
131impl ParseFromContinuable<GetLibrarySongsQuery> for Vec<TableListSong> {
132    fn parse_from_continuable(
133        p: ProcessedResult<GetLibrarySongsQuery>,
134    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
135        let json_crawler: JsonCrawlerOwned = p.into();
136        let music_shelf = json_crawler.navigate_pointer(concatcp!(
137            SINGLE_COLUMN_TAB,
138            SECTION_LIST_ITEM,
139            MUSIC_SHELF,
140        ))?;
141        parse_library_songs(music_shelf)
142    }
143    fn parse_continuation(
144        p: ProcessedResult<GetContinuationsQuery<'_, GetLibrarySongsQuery>>,
145    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
146        let json_crawler: JsonCrawlerOwned = p.into();
147        let music_shelf = json_crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
148        parse_library_songs(music_shelf)
149    }
150}
151
152impl ParseFromContinuable<GetLibraryArtistsQuery> for Vec<LibraryArtist> {
153    fn parse_from_continuable(
154        p: ProcessedResult<GetLibraryArtistsQuery>,
155    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
156        let json_crawler = p.into();
157        let maybe_music_shelf = process_library_contents_music_shelf(json_crawler);
158        if let Some(music_shelf) = maybe_music_shelf {
159            parse_content_list_artists(music_shelf)
160        } else {
161            Ok((Vec::new(), None))
162        }
163    }
164    fn parse_continuation(
165        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryArtistsQuery>>,
166    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
167        let json_crawler = JsonCrawlerOwned::from(p);
168        let music_shelf = json_crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
169        parse_content_list_artists(music_shelf)
170    }
171}
172
173impl ParseFromContinuable<GetLibraryPlaylistsQuery> for Vec<LibraryPlaylist> {
174    fn parse_from_continuable(
175        p: ProcessedResult<GetLibraryPlaylistsQuery>,
176    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
177        // TODO: Implement count and author fields
178        let json_crawler = p.into();
179        let maybe_grid_renderer = process_library_contents_grid(json_crawler);
180        if let Some(grid_renderer) = maybe_grid_renderer {
181            parse_library_playlists(grid_renderer)
182        } else {
183            Ok((vec![], None))
184        }
185    }
186    fn parse_continuation(
187        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryPlaylistsQuery>>,
188    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
189        let json_crawler: JsonCrawlerOwned = p.into();
190        let grid_renderer = json_crawler.navigate_pointer(GRID_CONTINUATION)?;
191        parse_library_playlists(grid_renderer)
192    }
193}
194
195impl ParseFromContinuable<GetLibraryPodcastsQuery> for Vec<LibraryPodcast> {
196    fn parse_from_continuable(
197        p: ProcessedResult<GetLibraryPodcastsQuery>,
198    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
199        let json_crawler: JsonCrawlerOwned = p.into();
200        let maybe_grid_renderer = process_library_contents_grid(json_crawler);
201        if let Some(grid_renderer) = maybe_grid_renderer {
202            parse_library_podcasts(grid_renderer)
203        } else {
204            Ok((vec![], None))
205        }
206    }
207    fn parse_continuation(
208        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryPodcastsQuery>>,
209    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
210        let json_crawler: JsonCrawlerOwned = p.into();
211        let grid_renderer = json_crawler.navigate_pointer(GRID_CONTINUATION)?;
212        parse_library_podcasts(grid_renderer)
213    }
214}
215
216impl ParseFromContinuable<GetLibraryChannelsQuery> for Vec<LibraryChannel> {
217    fn parse_from_continuable(
218        p: ProcessedResult<GetLibraryChannelsQuery>,
219    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
220        let json_crawler = p.into();
221        let maybe_music_shelf = process_library_contents_music_shelf(json_crawler);
222        if let Some(music_shelf) = maybe_music_shelf {
223            parse_content_list_channels(music_shelf)
224        } else {
225            Ok((Vec::new(), None))
226        }
227    }
228    fn parse_continuation(
229        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryChannelsQuery>>,
230    ) -> crate::Result<(Self, Option<ContinuationParams<'static>>)> {
231        let json_crawler = JsonCrawlerOwned::from(p);
232        let music_shelf = json_crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
233        parse_content_list_channels(music_shelf)
234    }
235}
236
237impl ParseFrom<EditSongLibraryStatusQuery<'_>> for Vec<ApiOutcome> {
238    fn parse_from(p: super::ProcessedResult<EditSongLibraryStatusQuery>) -> Result<Self> {
239        let json_crawler = JsonCrawlerOwned::from(p);
240        json_crawler
241            .navigate_pointer("/feedbackResponses")?
242            .try_into_iter()?
243            .map(|mut response| {
244                response
245                    .take_value_pointer::<bool>("/isProcessed")
246                    .map(|p| {
247                        if p {
248                            return ApiOutcome::Success;
249                        }
250                        ApiOutcome::Failure
251                    })
252            })
253            .rev()
254            .collect::<CrawlerResult<_>>()
255            .map_err(Into::into)
256    }
257}
258
259fn parse_library_albums(
260    mut grid_renderer: JsonCrawlerOwned,
261) -> Result<(Vec<SearchResultAlbum>, Option<ContinuationParams<'static>>)> {
262    let continuation_params = grid_renderer.take_value_pointer(CONTINUATION_PARAMS).ok();
263    let albums = grid_renderer
264        .navigate_pointer("/items")?
265        .try_into_iter()?
266        .map(parse_item_list_album)
267        .collect::<Result<_>>()?;
268    Ok((albums, continuation_params))
269}
270fn parse_library_songs(
271    mut music_shelf: JsonCrawlerOwned,
272) -> Result<(Vec<TableListSong>, Option<ContinuationParams<'static>>)> {
273    let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
274    let songs = music_shelf
275        .navigate_pointer("/contents")?
276        .try_into_iter()?
277        .map(|mut item| {
278            let Ok(mut data) = item.borrow_pointer(MRLIR) else {
279                return Ok(None);
280            };
281            let title = super::parse_flex_column_item(&mut data, 0, 0)?;
282            if title == "Shuffle all" {
283                return Ok(None);
284            }
285            Ok(Some(parse_table_list_song(title, data)?))
286        })
287        .filter_map(Result::transpose)
288        .collect::<Result<_>>()?;
289    Ok((songs, continuation_params))
290}
291fn parse_library_artist_subscriptions(
292    mut music_shelf: JsonCrawlerOwned,
293) -> Result<(
294    Vec<LibraryArtistSubscription>,
295    Option<ContinuationParams<'static>>,
296)> {
297    let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
298    let subscriptions = music_shelf
299        .navigate_pointer("/contents")?
300        .try_into_iter()?
301        .map(parse_content_list_artist_subscription)
302        .collect::<Result<_>>()?;
303    Ok((subscriptions, continuation_params))
304}
305
306fn parse_library_playlists(
307    mut grid_renderer: JsonCrawlerOwned,
308) -> Result<(Vec<LibraryPlaylist>, Option<ContinuationParams<'static>>)> {
309    let continuation_params = grid_renderer.take_value_pointer(CONTINUATION_PARAMS).ok();
310    let playlists = grid_renderer
311        .navigate_pointer("/items")?
312        .try_into_iter()?
313        // First result is just a link to create a new playlist.
314        .skip(1)
315        .filter_map(|item| parse_content_list_playlist(item).transpose())
316        .collect::<Result<_>>()?;
317    Ok((playlists, continuation_params))
318}
319fn parse_library_podcasts(
320    mut grid_renderer: impl JsonCrawler,
321) -> Result<(Vec<LibraryPodcast>, Option<ContinuationParams<'static>>)> {
322    let continuation_params = grid_renderer.take_value_pointer(CONTINUATION_PARAMS).ok();
323    let res = grid_renderer
324        .navigate_pointer("/items")?
325        .try_into_iter()?
326        // First result is just a link to create a new podcast.
327        .skip(1)
328        .filter_map(|item| parse_content_list_podcast(item).transpose())
329        .collect::<Result<_>>()?;
330    Ok((res, continuation_params))
331}
332
333// Consider returning ProcessedLibraryContents
334// TODO: Move to process
335fn process_library_contents_grid(mut json_crawler: JsonCrawlerOwned) -> Option<JsonCrawlerOwned> {
336    let section = json_crawler.borrow_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST));
337    // Assume empty library in this case.
338    if let Ok(section) = section {
339        if section.path_exists("/itemSectionRenderer") {
340            json_crawler
341                .navigate_pointer(concatcp!(ITEM_SECTION, GRID))
342                .ok()
343        } else {
344            json_crawler
345                .navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST_ITEM, GRID))
346                .ok()
347        }
348    } else {
349        None
350    }
351}
352// Consider returning ProcessedLibraryContents
353// TODO: Move to process
354fn process_library_contents_music_shelf(
355    mut json_crawler: JsonCrawlerOwned,
356) -> Option<JsonCrawlerOwned> {
357    let section = json_crawler.borrow_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST));
358    // Assume empty library in this case.
359    if let Ok(section) = section {
360        if section.path_exists("itemSectionRenderer") {
361            json_crawler
362                .navigate_pointer(concatcp!(ITEM_SECTION, MUSIC_SHELF))
363                .ok()
364        } else {
365            json_crawler
366                .navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST_ITEM, MUSIC_SHELF))
367                .ok()
368        }
369    } else {
370        None
371    }
372}
373
374fn parse_item_list_album(mut json_crawler: JsonCrawlerOwned) -> Result<SearchResultAlbum> {
375    let mut data = json_crawler.borrow_pointer("/musicTwoRowItemRenderer")?;
376    let browse_id = data.take_value_pointer(NAVIGATION_BROWSE_ID)?;
377    let thumbnails = data.take_value_pointer(THUMBNAIL_RENDERER)?;
378    let title = data.take_value_pointer(TITLE_TEXT)?;
379    let artist = data.take_value_pointer(SUBTITLE2)?;
380    let year = data.take_value_pointer(SUBTITLE3)?;
381    let album_type = data.take_value_pointer(SUBTITLE)?;
382    let explicit = if data.path_exists(SUBTITLE_BADGE_LABEL) {
383        Explicit::IsExplicit
384    } else {
385        Explicit::NotExplicit
386    };
387    Ok(SearchResultAlbum {
388        title,
389        artist,
390        year,
391        explicit,
392        album_id: browse_id,
393        album_type,
394        thumbnails,
395    })
396}
397
398fn parse_content_list_artist_subscription(
399    mut json_crawler: JsonCrawlerOwned,
400) -> Result<LibraryArtistSubscription> {
401    let mut data = json_crawler.borrow_pointer(MRLIR)?;
402    let channel_id = data.take_value_pointer(NAVIGATION_BROWSE_ID)?;
403    let name = parse_flex_column_item(&mut data, 0, 0)?;
404    let subscribers = parse_flex_column_item(&mut data, 1, 0)?;
405    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
406    Ok(LibraryArtistSubscription {
407        name,
408        subscribers,
409        channel_id,
410        thumbnails,
411    })
412}
413
414fn parse_content_list_artists(
415    mut json_crawler: JsonCrawlerOwned,
416) -> Result<(Vec<LibraryArtist>, Option<ContinuationParams<'static>>)> {
417    let continuation_params = json_crawler.take_value_pointer(CONTINUATION_PARAMS).ok();
418    let artists = json_crawler
419        .navigate_pointer("/contents")?
420        .try_iter_mut()?
421        .map(|item| {
422            let mut data = item.navigate_pointer(MRLIR)?;
423            let channel_id = data.take_value_pointer(NAVIGATION_BROWSE_ID)?;
424            let artist = parse_flex_column_item(&mut data, 0, 0)?;
425            let byline = parse_flex_column_item(&mut data, 1, 0)?;
426            Ok(LibraryArtist {
427                channel_id,
428                artist,
429                byline,
430            })
431        })
432        .collect::<Result<_>>()?;
433    Ok((artists, continuation_params))
434}
435
436fn parse_content_list_channels(
437    mut json_crawler: JsonCrawlerOwned,
438) -> Result<(Vec<LibraryChannel>, Option<ContinuationParams<'static>>)> {
439    let continuation_params = json_crawler.take_value_pointer(CONTINUATION_PARAMS).ok();
440    let artists = json_crawler
441        .navigate_pointer("/contents")?
442        .try_iter_mut()?
443        .map(|item| {
444            let mut data = item.navigate_pointer(MRLIR)?;
445            let channel_id = data.take_value_pointer(NAVIGATION_BROWSE_ID)?;
446            let title = parse_flex_column_item(&mut data, 0, 0)?;
447            let subscribers = parse_flex_column_item(&mut data, 1, 0)?;
448            let thumbnails = data.take_value_pointer(THUMBNAILS)?;
449            Ok(LibraryChannel {
450                title,
451                subscribers,
452                channel_id,
453                thumbnails,
454            })
455        })
456        .collect::<Result<_>>()?;
457    Ok((artists, continuation_params))
458}
459
460fn parse_table_list_song(title: String, mut data: JsonCrawlerBorrowed) -> Result<TableListSong> {
461    let video_id = data.take_value_pointer(concatcp!(
462        PLAY_BUTTON,
463        "/playNavigationEndpoint",
464        WATCH_VIDEO_ID
465    ))?;
466    let library_management =
467        parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
468    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
469    let artists = super::parse_song_artists(&mut data, 1)?;
470    let album = super::parse_song_album(&mut data, 2)?;
471    let duration = data
472        .borrow_pointer(fixed_column_item_pointer(0))?
473        .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
474    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
475    let is_available = data
476        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
477        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
478        .unwrap_or(true);
479
480    let explicit = if data.path_exists(BADGE_LABEL) {
481        Explicit::IsExplicit
482    } else {
483        Explicit::NotExplicit
484    };
485    let playlist_id = data.take_value_pointer(concatcp!(
486        MENU_ITEMS,
487        "/0/menuNavigationItemRenderer",
488        NAVIGATION_PLAYLIST_ID
489    ))?;
490    Ok(TableListSong {
491        video_id,
492        duration,
493        library_management,
494        title,
495        artists,
496        like_status,
497        thumbnails,
498        explicit,
499        album,
500        playlist_id,
501        is_available,
502    })
503}
504
505fn parse_content_list_playlist(item: JsonCrawlerOwned) -> Result<Option<LibraryPlaylist>> {
506    // TODO: Implement count and author fields
507    let mut mtrir = item.navigate_pointer(MTRIR)?;
508    let title: String = mtrir.take_value_pointer(TITLE_TEXT)?;
509    // There are some potential special playlist results. This is one
510    // way to filter them out.
511    // TODO: i18n or more robust method of filtering.
512    if title.eq_ignore_ascii_case("liked music") || title.eq_ignore_ascii_case("episodes for later")
513    {
514        return Ok(None);
515    }
516    let playlist_id: PlaylistID = mtrir
517        .borrow_pointer(concatcp!(TITLE, NAVIGATION_BROWSE_ID))?
518        // ytmusicapi uses range index [2:] here but doesn't seem to be required.
519        // Revisit later if we crash.
520        .take_value()?;
521    let thumbnails: Vec<Thumbnail> = mtrir.take_value_pointer(THUMBNAIL_RENDERER)?;
522    let mut subtitle = mtrir.navigate_pointer("/subtitle")?;
523    let tracks = subtitle.take_value_pointer("/runs/2/text")?;
524    let mut author_run = subtitle.navigate_pointer("/runs/0")?;
525    let author = author_run.take_value_pointer("/text")?;
526    let author_id = author_run.take_value_pointer(NAVIGATION_BROWSE_ID).ok();
527    Ok(Some(LibraryPlaylist {
528        playlist_id,
529        title,
530        thumbnails,
531        tracks,
532        author_id,
533        author,
534    }))
535}
536
537fn parse_content_list_podcast(item: impl JsonCrawler) -> Result<Option<LibraryPodcast>> {
538    let mut mtrir = item.navigate_pointer(MTRIR)?;
539    let title: String = mtrir.take_value_pointer(TITLE_TEXT)?;
540    // There are some potential non-podcast special playlist results. This is one
541    // way to filter them out.
542    // TODO: i18n or more robust method of filtering.
543    if title.eq_ignore_ascii_case("new episodes")
544        || title.eq_ignore_ascii_case("Episodes for Later")
545    {
546        return Ok(None);
547    }
548    let podcast_id: PodcastID = mtrir
549        .borrow_pointer(concatcp!(TITLE, NAVIGATION_BROWSE_ID))?
550        // ytmusicapi uses range index [2:] here but doesn't seem to be required.
551        // Revisit later if we crash.
552        .take_value()?;
553    let thumbnails: Vec<Thumbnail> = mtrir.take_value_pointer(THUMBNAIL_RENDERER)?;
554    let maybe_badge_icon = mtrir
555        .take_value_pointer::<YoutubeMusicBadgeRendererIcon>(SUBTITLE_BADGE_ICON)
556        .ok();
557    let podcast_source = match maybe_badge_icon {
558        Some(YoutubeMusicBadgeRendererIcon::Rss) => PodcastSource::Rss,
559        _ => PodcastSource::YouTube,
560    };
561    let channels = mtrir
562        .navigate_pointer("/subtitle/runs")?
563        .try_into_iter()?
564        .map(parse_podcast_channel)
565        .collect::<Result<Vec<_>>>()?;
566    Ok(Some(LibraryPodcast {
567        title,
568        thumbnails,
569        channels,
570        podcast_id,
571        podcast_source,
572    }))
573}
574
575#[cfg(test)]
576mod tests {
577    use crate::auth::BrowserToken;
578
579    #[tokio::test]
580    async fn test_library_playlists_dummy_json() {
581        parse_with_matching_continuation_test!(
582            "./test_json/get_library_playlists.json",
583            "./test_json/get_library_playlists_continuation_mock.json",
584            "./test_json/get_library_playlists_output.txt",
585            crate::query::GetLibraryPlaylistsQuery,
586            BrowserToken
587        );
588    }
589    #[tokio::test]
590    async fn test_get_library_artists_dummy_json() {
591        parse_with_matching_continuation_test!(
592            "./test_json/get_library_artists.json",
593            "./test_json/get_library_artists_continuation_mock.json",
594            "./test_json/get_library_artists_output.txt",
595            crate::query::GetLibraryArtistsQuery::default(),
596            BrowserToken
597        );
598    }
599    #[tokio::test]
600    async fn test_get_library_albums() {
601        parse_with_matching_continuation_test!(
602            "./test_json/get_library_albums_20240701.json",
603            "./test_json/get_library_albums_continuation_mock.json",
604            "./test_json/get_library_albums_20240701_output.txt",
605            crate::query::GetLibraryAlbumsQuery::default(),
606            BrowserToken
607        );
608    }
609    #[tokio::test]
610    async fn test_get_library_songs() {
611        parse_test!(
612            "./test_json/get_library_songs_20240701.json",
613            "./test_json/get_library_songs_20240701_output.txt",
614            crate::query::GetLibrarySongsQuery::default(),
615            BrowserToken
616        );
617    }
618    #[tokio::test]
619    async fn test_get_library_songs_continuation() {
620        parse_continuations_test!(
621            "./test_json/get_library_songs_continuation_20240910.json",
622            "./test_json/get_library_songs_continuation_20240910_output.txt",
623            crate::query::GetLibrarySongsQuery::default(),
624            BrowserToken
625        );
626    }
627    #[tokio::test]
628    async fn test_get_library_artist_subscriptions() {
629        parse_with_matching_continuation_test!(
630            "./test_json/get_library_artist_subscriptions_20240701.json",
631            "./test_json/get_library_artist_subscriptions_continuation_mock.json",
632            "./test_json/get_library_artist_subscriptions_20240701_output.txt",
633            crate::query::GetLibraryArtistSubscriptionsQuery::default(),
634            BrowserToken
635        );
636    }
637    #[tokio::test]
638    async fn test_get_library_podcasts() {
639        parse_with_matching_continuation_test!(
640            "./test_json/get_library_podcasts_20250626.json",
641            "./test_json/get_library_podcasts_continuation_20250626.json",
642            "./test_json/get_library_podcasts_20250626_output.txt",
643            crate::query::GetLibraryPodcastsQuery::default(),
644            BrowserToken
645        );
646    }
647    #[tokio::test]
648    async fn test_get_library_channels() {
649        parse_with_matching_continuation_test!(
650            "./test_json/get_library_channels_20250626.json",
651            "./test_json/get_library_channels_continuation_20250626.json",
652            "./test_json/get_library_channels_20250626_output.txt",
653            crate::query::GetLibraryChannelsQuery::default(),
654            BrowserToken
655        );
656    }
657    #[tokio::test]
658    async fn test_edit_song_library_status() {
659        // Note - same files as remove_histry_items
660        parse_test!(
661            "./test_json/remove_history_items_20240704.json",
662            "./test_json/remove_history_items_20240704_output.txt",
663            crate::query::EditSongLibraryStatusQuery::new_from_add_to_library_feedback_tokens(
664                Vec::new()
665            )
666            .with_remove_from_library_feedback_tokens(vec![]),
667            BrowserToken
668        );
669    }
670}