Skip to main content

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