ytmapi_rs/parse/
playlist.rs

1use super::{
2    DESCRIPTION_SHELF_RUNS, EpisodeDate, EpisodeDuration, ParseFrom, ParsedSongAlbum,
3    ParsedUploadArtist, ParsedUploadSongAlbum, ProcessedResult, STRAPLINE_TEXT, TITLE_TEXT,
4    TWO_COLUMN, fixed_column_item_pointer, flex_column_item_pointer, parse_flex_column_item,
5    parse_library_management_items_from_menu, parse_upload_song_album, parse_upload_song_artists,
6};
7use crate::common::{
8    ApiOutcome, ArtistChannelID, ContinuationParams, EpisodeID, Explicit, LibraryManager,
9    LikeStatus, PlaylistID, SetVideoID, Thumbnail, UploadEntityID, VideoID,
10};
11use crate::continuations::ParseFromContinuable;
12use crate::nav_consts::{
13    APPEND_CONTINUATION_ITEMS, BADGE_LABEL, CONTENT, CONTINUATION_RENDERER_COMMAND,
14    DELETION_ENTITY_ID, FACEPILE_AVATAR_URL, FACEPILE_TEXT, LIVE_BADGE_LABEL, MENU_ITEMS,
15    MENU_LIKE_STATUS, MRLIR, MUSIC_PLAYLIST_SHELF, NAVIGATION_BROWSE_ID, NAVIGATION_PLAYLIST_ID,
16    NAVIGATION_VIDEO_ID, NAVIGATION_VIDEO_TYPE, PLAY_BUTTON, PLAYLIST_PANEL_CONTINUATION, PPR,
17    RADIO_CONTINUATION_PARAMS, RESPONSIVE_HEADER, RUN_TEXT, SECOND_SUBTITLE_RUNS,
18    SECONDARY_SECTION_LIST_RENDERER, SECTION_LIST_ITEM, TAB_CONTENT, TEXT_RUN, TEXT_RUN_TEXT,
19    THUMBNAIL, THUMBNAILS, WATCH_NEXT_CONTENT, WATCH_VIDEO_ID,
20};
21use crate::query::playlist::{
22    CreatePlaylistType, GetPlaylistDetailsQuery, GetWatchPlaylistQueryID, PrivacyStatus,
23    SpecialisedQuery,
24};
25use crate::query::{
26    AddPlaylistItemsQuery, CreatePlaylistQuery, DeletePlaylistQuery, EditPlaylistQuery,
27    GetPlaylistTracksQuery, GetWatchPlaylistQuery, RemovePlaylistItemsQuery,
28};
29use crate::youtube_enums::YoutubeMusicVideoType;
30use crate::{Error, Result};
31use const_format::concatcp;
32use json_crawler::{JsonCrawler, JsonCrawlerIterator, JsonCrawlerOwned};
33use serde::{Deserialize, Serialize};
34
35#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
36#[non_exhaustive]
37pub struct GetPlaylistDetails {
38    pub id: PlaylistID<'static>,
39    // NOTE: Only present on personal (library) playlists??
40    // NOTE: May not be present on old version of API also.
41    pub privacy: Option<PrivacyStatus>,
42    pub title: String,
43    pub description: Option<String>,
44    pub author: String,
45    pub author_avatar_url: Option<String>,
46    pub year: String,
47    pub duration: String,
48    pub track_count_text: String,
49    // NOTE: Seem to be unable to distinguish when views is optional.
50    pub views: Option<String>,
51    pub thumbnails: Vec<Thumbnail>,
52}
53#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
54/// Provides a SetVideoID and VideoID for each video added to the playlist.
55// Intentionally not marked non_exhaustive - not expecting this to change.
56pub struct AddPlaylistItem {
57    pub video_id: VideoID<'static>,
58    pub set_video_id: SetVideoID<'static>,
59}
60#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
61#[non_exhaustive]
62pub struct WatchPlaylistTrack {
63    pub title: String,
64    pub author: String,
65    pub duration: String,
66    pub thumbnails: Vec<Thumbnail>,
67    pub video_id: VideoID<'static>,
68}
69
70#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
71#[non_exhaustive]
72// Could this alternatively be Result<Song>?
73// May need to be enum to track 'Not Available' case.
74pub struct PlaylistSong {
75    pub video_id: VideoID<'static>,
76    pub track_no: usize,
77    pub album: ParsedSongAlbum,
78    pub duration: String,
79    /// Some songs may not have library management features. There could be
80    /// various resons for this.
81    pub library_management: Option<LibraryManager>,
82    pub title: String,
83    pub artists: Vec<super::ParsedSongArtist>,
84    // TODO: Song like feedback tokens.
85    pub like_status: LikeStatus,
86    pub thumbnails: Vec<Thumbnail>,
87    pub explicit: Explicit,
88    pub is_available: bool,
89    /// Id of the playlist that will get created when pressing 'Start Radio'.
90    pub playlist_id: PlaylistID<'static>,
91}
92
93#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
94pub enum PlaylistItem {
95    Song(PlaylistSong),
96    Video(PlaylistVideo),
97    Episode(PlaylistEpisode),
98    UploadSong(PlaylistUploadSong),
99}
100
101#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
102#[non_exhaustive]
103pub struct PlaylistVideo {
104    pub video_id: VideoID<'static>,
105    pub track_no: usize,
106    pub duration: String,
107    pub title: String,
108    // Could be 'ParsedVideoChannel'
109    pub channel_name: String,
110    pub channel_id: ArtistChannelID<'static>,
111    // TODO: Song like feedback tokens.
112    pub like_status: LikeStatus,
113    pub thumbnails: Vec<Thumbnail>,
114    pub is_available: bool,
115    /// Id of the playlist that will get created when pressing 'Start Radio'.
116    pub playlist_id: PlaylistID<'static>,
117}
118
119#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
120#[non_exhaustive]
121pub struct PlaylistEpisode {
122    pub episode_id: EpisodeID<'static>,
123    pub track_no: usize,
124    pub date: EpisodeDate,
125    pub duration: EpisodeDuration,
126    pub title: String,
127    pub podcast_name: String,
128    pub podcast_id: PlaylistID<'static>,
129    // TODO: Song like feedback tokens.
130    pub like_status: LikeStatus,
131    pub thumbnails: Vec<Thumbnail>,
132    pub is_available: bool,
133}
134
135#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
136#[non_exhaustive]
137pub struct PlaylistUploadSong {
138    pub entity_id: UploadEntityID<'static>,
139    pub video_id: VideoID<'static>,
140    pub track_no: usize,
141    pub duration: String,
142    pub album: ParsedUploadSongAlbum,
143    pub title: String,
144    pub artists: Vec<ParsedUploadArtist>,
145    // TODO: Song like feedback tokens.
146    pub like_status: LikeStatus,
147    pub thumbnails: Vec<Thumbnail>,
148}
149
150impl<'a> ParseFrom<RemovePlaylistItemsQuery<'a>> for () {
151    fn parse_from(_: ProcessedResult<RemovePlaylistItemsQuery<'a>>) -> crate::Result<Self> {
152        Ok(())
153    }
154}
155impl<'a, C: CreatePlaylistType> ParseFrom<CreatePlaylistQuery<'a, C>> for PlaylistID<'static> {
156    fn parse_from(p: ProcessedResult<CreatePlaylistQuery<'a, C>>) -> crate::Result<Self> {
157        let mut json_crawler: JsonCrawlerOwned = p.into();
158        json_crawler
159            .take_value_pointer("/playlistId")
160            .map_err(Into::into)
161    }
162}
163impl<'a, T: SpecialisedQuery> ParseFrom<AddPlaylistItemsQuery<'a, T>> for Vec<AddPlaylistItem> {
164    fn parse_from(p: ProcessedResult<AddPlaylistItemsQuery<'a, T>>) -> crate::Result<Self> {
165        let mut json_crawler: JsonCrawlerOwned = p.into();
166        let status: ApiOutcome = json_crawler.borrow_pointer("/status")?.take_value()?;
167        if let ApiOutcome::Failure = status {
168            return Err(Error::status_failed());
169        }
170        json_crawler
171            .navigate_pointer("/playlistEditResults")?
172            .try_iter_mut()?
173            .map(|r| {
174                let mut r = r.navigate_pointer("/playlistEditVideoAddedResultData")?;
175                Ok(AddPlaylistItem {
176                    video_id: r.take_value_pointer("/videoId")?,
177                    set_video_id: r.take_value_pointer("/setVideoId")?,
178                })
179            })
180            .collect()
181    }
182}
183impl<'a> ParseFrom<EditPlaylistQuery<'a>> for ApiOutcome {
184    fn parse_from(p: ProcessedResult<EditPlaylistQuery<'a>>) -> crate::Result<Self> {
185        let json_crawler: JsonCrawlerOwned = p.into();
186        json_crawler
187            .navigate_pointer("/status")?
188            .take_value()
189            .map_err(Into::into)
190    }
191}
192impl<'a> ParseFrom<DeletePlaylistQuery<'a>> for () {
193    fn parse_from(_: ProcessedResult<DeletePlaylistQuery<'a>>) -> crate::Result<Self> {
194        Ok(())
195    }
196}
197
198impl<'a> ParseFrom<GetPlaylistDetailsQuery<'a>> for GetPlaylistDetails {
199    fn parse_from(p: ProcessedResult<GetPlaylistDetailsQuery<'a>>) -> crate::Result<Self> {
200        let json_crawler: JsonCrawlerOwned = p.into();
201        get_playlist_details(json_crawler)
202    }
203}
204
205impl<'a> ParseFromContinuable<GetPlaylistTracksQuery<'a>> for Vec<PlaylistItem> {
206    fn parse_from_continuable(
207        p: ProcessedResult<GetPlaylistTracksQuery<'a>>,
208    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
209        let json_crawler: JsonCrawlerOwned = p.into();
210        let music_playlist_shelf = json_crawler.navigate_pointer(concatcp!(
211            TWO_COLUMN,
212            SECONDARY_SECTION_LIST_RENDERER,
213            CONTENT,
214            MUSIC_PLAYLIST_SHELF,
215            "/contents"
216        ))?;
217        parse_playlist_items(music_playlist_shelf)
218    }
219    fn parse_continuation(
220        p: ProcessedResult<crate::query::GetContinuationsQuery<'_, GetPlaylistTracksQuery<'a>>>,
221    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
222        let json_crawler: JsonCrawlerOwned = p.into();
223        let continuation_items = json_crawler.navigate_pointer(APPEND_CONTINUATION_ITEMS)?;
224        parse_playlist_items(continuation_items)
225    }
226}
227
228impl<T: GetWatchPlaylistQueryID> ParseFromContinuable<GetWatchPlaylistQuery<T>>
229    for Vec<WatchPlaylistTrack>
230{
231    fn parse_from_continuable(
232        p: ProcessedResult<GetWatchPlaylistQuery<T>>,
233    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
234        let json_crawler: JsonCrawlerOwned = p.into();
235        let mut playlist_panel =
236            json_crawler.navigate_pointer(concatcp!(WATCH_NEXT_CONTENT, PPR))?;
237        let continuation_params = playlist_panel
238            .take_value_pointer(RADIO_CONTINUATION_PARAMS)
239            .ok();
240        let tracks = playlist_panel
241            .navigate_pointer("/contents")?
242            .try_into_iter()?
243            .map(parse_watch_playlist_track)
244            .collect::<Result<Vec<_>>>()?;
245        Ok((tracks, continuation_params))
246    }
247    fn parse_continuation(
248        p: ProcessedResult<crate::query::GetContinuationsQuery<'_, GetWatchPlaylistQuery<T>>>,
249    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
250        let json_crawler: JsonCrawlerOwned = p.into();
251        let mut playlist_panel = json_crawler.navigate_pointer(PLAYLIST_PANEL_CONTINUATION)?;
252        let continuation_params = playlist_panel
253            .take_value_pointer(RADIO_CONTINUATION_PARAMS)
254            .ok();
255        let tracks = playlist_panel
256            .navigate_pointer("/contents")?
257            .try_into_iter()?
258            .map(parse_watch_playlist_track)
259            .collect::<Result<Vec<_>>>()?;
260        Ok((tracks, continuation_params))
261    }
262}
263
264fn parse_watch_playlist_track(mut item: impl JsonCrawler) -> Result<WatchPlaylistTrack> {
265    let video_renderer_paths = [
266        "/playlistPanelVideoRenderer",
267        "/playlistPanelVideoWrapperRenderer/primaryRenderer/playlistPanelVideoRenderer",
268    ];
269    item.apply_function_at_paths(
270        &video_renderer_paths,
271        parse_watch_playlist_track_from_video_renderer,
272    )?
273}
274
275fn parse_watch_playlist_track_from_video_renderer<C: JsonCrawler>(
276    mut video_renderer: C::BorrowTo<'_>,
277) -> Result<WatchPlaylistTrack> {
278    let title = video_renderer.take_value_pointer(TITLE_TEXT)?;
279    let author = video_renderer.take_value_pointer(concatcp!("/shortBylineText", RUN_TEXT))?;
280    let duration = video_renderer.take_value_pointer(concatcp!("/lengthText", RUN_TEXT))?;
281    let video_id = video_renderer.take_value_pointer(NAVIGATION_VIDEO_ID)?;
282    let thumbnails = video_renderer.take_value_pointer(THUMBNAIL)?;
283    Ok(WatchPlaylistTrack {
284        title,
285        author,
286        duration,
287        thumbnails,
288        video_id,
289    })
290}
291
292pub(crate) fn parse_playlist_song(
293    title: String,
294    track_no: usize,
295    mut data: impl JsonCrawler,
296) -> Result<PlaylistSong> {
297    let video_id = data.take_value_pointer(concatcp!(
298        PLAY_BUTTON,
299        "/playNavigationEndpoint",
300        WATCH_VIDEO_ID
301    ))?;
302    let library_management =
303        parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
304    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
305    let artists = super::parse_song_artists(&mut data, 1)?;
306    // Some playlist types (Potentially just Featured Playlists) have a 'Plays'
307    // field between Artist and Album.
308    // TODO: Find a more efficient way, and potentially parse Featured Playlists
309    // differently.
310    let album_col_idx = if data.path_exists("/flexColumns/3") {
311        3
312    } else {
313        2
314    };
315    let album = super::parse_song_album(&mut data, album_col_idx)?;
316    let duration = data
317        .borrow_pointer(fixed_column_item_pointer(0))?
318        .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
319    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
320    let is_available = data
321        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
322        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
323        .unwrap_or(true);
324
325    let explicit = if data.path_exists(BADGE_LABEL) {
326        Explicit::IsExplicit
327    } else {
328        Explicit::NotExplicit
329    };
330    let playlist_id = data.take_value_pointer(concatcp!(
331        MENU_ITEMS,
332        "/0/menuNavigationItemRenderer",
333        NAVIGATION_PLAYLIST_ID
334    ))?;
335    Ok(PlaylistSong {
336        video_id,
337        track_no,
338        duration,
339        library_management,
340        title,
341        artists,
342        like_status,
343        thumbnails,
344        explicit,
345        album,
346        playlist_id,
347        is_available,
348    })
349}
350pub(crate) fn parse_playlist_upload_song(
351    title: String,
352    track_no: usize,
353    mut data: impl JsonCrawler,
354) -> Result<PlaylistUploadSong> {
355    let duration = data
356        .borrow_pointer(fixed_column_item_pointer(0))?
357        .take_value_pointer(TEXT_RUN_TEXT)?;
358    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
359    let video_id = data.take_value_pointer(concatcp!(
360        PLAY_BUTTON,
361        "/playNavigationEndpoint/watchEndpoint/videoId"
362    ))?;
363    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
364    let artists = parse_upload_song_artists(data.borrow_mut(), 1)?;
365    let album = parse_upload_song_album(data.borrow_mut(), 2)?;
366    let mut menu = data.navigate_pointer(MENU_ITEMS)?;
367    let entity_id = menu
368        .try_iter_mut()?
369        .find_path(DELETION_ENTITY_ID)?
370        .take_value()?;
371    Ok(PlaylistUploadSong {
372        entity_id,
373        video_id,
374        album,
375        duration,
376        like_status,
377        title,
378        artists,
379        thumbnails,
380        track_no,
381    })
382}
383
384pub(crate) fn parse_playlist_episode(
385    title: String,
386    track_no: usize,
387    mut data: impl JsonCrawler,
388) -> Result<PlaylistEpisode> {
389    let video_id = data.take_value_pointer(concatcp!(
390        PLAY_BUTTON,
391        "/playNavigationEndpoint",
392        WATCH_VIDEO_ID
393    ))?;
394    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
395    let is_live = data.path_exists(LIVE_BADGE_LABEL);
396    let (duration, date) = match is_live {
397        true => (EpisodeDuration::Live, EpisodeDate::Live),
398        false => {
399            let date = parse_flex_column_item(&mut data, 2, 0)?;
400            let duration =
401                data.borrow_pointer(fixed_column_item_pointer(0))
402                    .and_then(|mut i| {
403                        i.take_value_pointer("/text/simpleText")
404                            .or_else(|_| i.take_value_pointer("/text/runs/0/text"))
405                    })?;
406            (
407                EpisodeDuration::Recorded { duration },
408                EpisodeDate::Recorded { date },
409            )
410        }
411    };
412    let podcast_name = parse_flex_column_item(&mut data, 1, 0)?;
413    let podcast_id = data
414        .borrow_pointer(flex_column_item_pointer(1))?
415        .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
416    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
417    let is_available = data
418        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
419        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
420        .unwrap_or(true);
421    Ok(PlaylistEpisode {
422        episode_id: video_id,
423        duration,
424        title,
425        like_status,
426        thumbnails,
427        date,
428        podcast_name,
429        podcast_id,
430        is_available,
431        track_no,
432    })
433}
434pub(crate) fn parse_playlist_video(
435    title: String,
436    track_no: usize,
437    mut data: impl JsonCrawler,
438) -> Result<PlaylistVideo> {
439    let video_id = data.take_value_pointer(concatcp!(
440        PLAY_BUTTON,
441        "/playNavigationEndpoint",
442        WATCH_VIDEO_ID
443    ))?;
444    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
445    let channel_name = parse_flex_column_item(&mut data, 1, 0)?;
446    let channel_id = data
447        .borrow_pointer(flex_column_item_pointer(1))?
448        .take_value_pointer(concatcp!(TEXT_RUN, NAVIGATION_BROWSE_ID))?;
449    let duration = data
450        .borrow_pointer(fixed_column_item_pointer(0))?
451        .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
452    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
453    let is_available = data
454        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
455        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
456        .unwrap_or(true);
457
458    let playlist_id = data.take_value_pointer(concatcp!(
459        MENU_ITEMS,
460        "/0/menuNavigationItemRenderer",
461        NAVIGATION_PLAYLIST_ID
462    ))?;
463    Ok(PlaylistVideo {
464        video_id,
465        track_no,
466        duration,
467        title,
468        like_status,
469        thumbnails,
470        playlist_id,
471        is_available,
472        channel_name,
473        channel_id,
474    })
475}
476
477pub(crate) fn parse_playlist_item(
478    track_no: usize,
479    mut json: impl JsonCrawler,
480) -> Result<Option<PlaylistItem>> {
481    let Ok(mut data) = json.borrow_pointer(MRLIR) else {
482        return Ok(None);
483    };
484    let title = super::parse_flex_column_item(&mut data, 0, 0)?;
485    if title == "Song deleted" {
486        return Ok(None);
487    }
488    let video_type_path = concatcp!(
489        PLAY_BUTTON,
490        "/playNavigationEndpoint",
491        NAVIGATION_VIDEO_TYPE
492    );
493    let video_type: YoutubeMusicVideoType = data.take_value_pointer(video_type_path)?;
494    // TODO: Deserialize to enum
495    let item = match video_type {
496        YoutubeMusicVideoType::Ugc
497        | YoutubeMusicVideoType::Omv
498        | YoutubeMusicVideoType::Shoulder => Some(PlaylistItem::Video(parse_playlist_video(
499            title, track_no, data,
500        )?)),
501        YoutubeMusicVideoType::Atv => Some(PlaylistItem::Song(parse_playlist_song(
502            title, track_no, data,
503        )?)),
504        YoutubeMusicVideoType::Upload => Some(PlaylistItem::UploadSong(
505            parse_playlist_upload_song(title, track_no, data)?,
506        )),
507        YoutubeMusicVideoType::Episode => Some(PlaylistItem::Episode(parse_playlist_episode(
508            title, track_no, data,
509        )?)),
510    };
511    Ok(item)
512}
513//TODO: Menu entries
514pub(crate) fn parse_playlist_items<C>(
515    json: C,
516) -> Result<(Vec<PlaylistItem>, Option<ContinuationParams<'static>>)>
517where
518    C: JsonCrawler,
519    C::IntoIter: DoubleEndedIterator,
520{
521    let mut items = json.try_into_iter()?;
522    let mut last_item = items.next_back();
523    let continuation_params = last_item.as_mut().and_then(|ref mut last_item| {
524        last_item
525            .take_value_pointer(CONTINUATION_RENDERER_COMMAND)
526            .ok()
527    });
528    let items = items
529        .chain(last_item)
530        .enumerate()
531        .filter_map(|(idx, item)| parse_playlist_item(idx + 1, item).transpose())
532        .collect::<Result<_>>()?;
533    Ok((items, continuation_params))
534}
535
536// NOTE: Similar code to get_album_2024
537fn get_playlist_details(json_crawler: JsonCrawlerOwned) -> Result<GetPlaylistDetails> {
538    let mut columns = json_crawler.navigate_pointer(TWO_COLUMN)?;
539    let header =
540        columns.borrow_pointer(concatcp!(TAB_CONTENT, SECTION_LIST_ITEM, RESPONSIVE_HEADER));
541    // TODO: Utilise a crawler library function here.
542    let mut header = match header {
543        Ok(header) => header,
544        Err(_) => columns.borrow_pointer(concatcp!(
545            TAB_CONTENT,
546            SECTION_LIST_ITEM,
547            "/musicEditablePlaylistDetailHeaderRenderer/header",
548            RESPONSIVE_HEADER
549        ))?,
550    };
551    let title = header.take_value_pointer(TITLE_TEXT)?;
552    // STRAPLINE_TEXT to be deprecated in future.
553    let author = header.take_value_pointers(&[STRAPLINE_TEXT, FACEPILE_TEXT])?;
554    let thumbnails: Vec<Thumbnail> = header.take_value_pointer(THUMBNAILS)?;
555    let author_avatar_url: Option<String> = header.take_value_pointer(FACEPILE_AVATAR_URL).ok();
556    let description = header
557        .borrow_pointer(DESCRIPTION_SHELF_RUNS)
558        .and_then(|d| d.try_into_iter())
559        .ok()
560        .map(|r| {
561            r.map(|mut r| r.take_value_pointer::<String>("/text"))
562                .collect::<std::result::Result<String, _>>()
563        })
564        .transpose()?;
565    let mut subtitle = header.borrow_pointer("/subtitle/runs")?;
566    let subtitle_len = subtitle.try_iter_mut()?.len();
567    let privacy = if subtitle_len == 5 {
568        Some(subtitle.take_value_pointer("/2/text")?)
569    } else {
570        None
571    };
572    let year = subtitle.take_value_pointer(format!("/{}/text", subtitle_len.saturating_sub(1)))?;
573    let mut second_subtitle_runs = header.borrow_pointer(SECOND_SUBTITLE_RUNS)?;
574    let duration = second_subtitle_runs
575        .try_iter_mut()?
576        .try_last()?
577        .take_value_pointer("/text")?;
578    let track_count_text = second_subtitle_runs.try_expect(
579        "second subtitle runs should count at least 3 runs",
580        |second_subtitle_runs| {
581            second_subtitle_runs
582                .try_iter_mut()?
583                .rev()
584                .nth(2)
585                .map(|mut run| run.take_value_pointer("/text"))
586                .transpose()
587        },
588    )?;
589    let views = second_subtitle_runs
590        .try_iter_mut()?
591        .rev()
592        .nth(4)
593        .map(|mut item| item.take_value_pointer("/text"))
594        .transpose()?;
595    let id = header
596        .navigate_pointer("/buttons")?
597        .try_into_iter()?
598        .find_path("/musicPlayButtonRenderer")?
599        .take_value_pointer("/playNavigationEndpoint/watchEndpoint/playlistId")?;
600    Ok(GetPlaylistDetails {
601        id,
602        privacy,
603        title,
604        description,
605        author,
606        year,
607        duration,
608        track_count_text,
609        thumbnails,
610        views,
611        author_avatar_url,
612    })
613}
614
615#[cfg(test)]
616mod tests {
617    use crate::auth::BrowserToken;
618    use crate::common::{ApiOutcome, PlaylistID, VideoID, YoutubeID};
619    use crate::query::playlist::GetPlaylistDetailsQuery;
620    use crate::query::{
621        AddPlaylistItemsQuery, EditPlaylistQuery, GetPlaylistTracksQuery, GetWatchPlaylistQuery,
622    };
623    use crate::{Error, process_json};
624    use pretty_assertions::assert_eq;
625    use std::path::Path;
626
627    #[tokio::test]
628    async fn test_add_playlist_items_query_failure() {
629        let source_path = Path::new("./test_json/add_playlist_items_failure_20240626.json");
630        let source = tokio::fs::read_to_string(source_path)
631            .await
632            .expect("Expect file read to pass during tests");
633        // Blank query has no bearing on function
634        let query = AddPlaylistItemsQuery::new_from_playlist(
635            PlaylistID::from_raw(""),
636            PlaylistID::from_raw(""),
637        );
638        let output = process_json::<_, BrowserToken>(source, query);
639        let err: crate::Result<()> = Err(Error::status_failed());
640        assert_eq!(format!("{:?}", err), format!("{:?}", output));
641    }
642    #[tokio::test]
643    async fn test_add_playlist_items_query() {
644        parse_test!(
645            "./test_json/add_playlist_items_20240626.json",
646            "./test_json/add_playlist_items_20240626_output.txt",
647            AddPlaylistItemsQuery::new_from_playlist(
648                PlaylistID::from_raw(""),
649                PlaylistID::from_raw(""),
650            ),
651            BrowserToken
652        );
653    }
654    #[tokio::test]
655    async fn test_edit_playlist_title_query() {
656        parse_test_value!(
657            "./test_json/edit_playlist_title_20240626.json",
658            ApiOutcome::Success,
659            EditPlaylistQuery::new_title(PlaylistID::from_raw(""), ""),
660            BrowserToken
661        );
662    }
663    #[tokio::test]
664    async fn test_get_playlist_details_query_2024() {
665        parse_test!(
666            "./test_json/get_playlist_20240624.json",
667            "./test_json/get_playlist_details_20240624_output.txt",
668            GetPlaylistDetailsQuery::new(PlaylistID::from_raw("")),
669            BrowserToken
670        );
671    }
672    #[tokio::test]
673    // In 2025, playlist channel details were moved from strapline to facepile.
674    async fn test_get_playlist_details_query_2025() {
675        parse_test!(
676            "./test_json/get_playlist_20250604.json",
677            "./test_json/get_playlist_details_20250604_output.txt",
678            GetPlaylistDetailsQuery::new(PlaylistID::from_raw("")),
679            BrowserToken
680        );
681    }
682    #[tokio::test]
683    async fn test_get_playlist_details_query_2024_no_channel_thumbnail() {
684        parse_test!(
685            "./test_json/get_playlist_no_channel_thumbnail_20240818.json",
686            "./test_json/get_playlist_details_no_channel_thumbnail_20240818_output.txt",
687            GetPlaylistDetailsQuery::new(PlaylistID::from_raw("")),
688            BrowserToken
689        );
690    }
691    #[tokio::test]
692    async fn test_get_playlist_tracks_query() {
693        parse_with_matching_continuation_test!(
694            "./test_json/get_playlist_20250604.json",
695            "./test_json/get_playlist_continuation_20250604.json",
696            "./test_json/get_playlist_tracks_20250604_output.txt",
697            GetPlaylistTracksQuery::new(PlaylistID::from_raw("")),
698            BrowserToken
699        );
700    }
701    #[tokio::test]
702    async fn test_get_watch_playlist_query() {
703        parse_with_matching_continuation_test!(
704            "./test_json/get_watch_playlist_20250630.json",
705            "./test_json/get_watch_playlist_continuation_20250630.json",
706            "./test_json/get_watch_playlist_20250630_output.txt",
707            GetWatchPlaylistQuery::new_from_video_id(VideoID::from_raw("")),
708            BrowserToken
709        );
710    }
711}