ytmapi_rs/parse/
playlists.rs

1use super::{
2    parse_playlist_items, ParseFrom, PlaylistItem, ProcessedResult, DESCRIPTION_SHELF_RUNS,
3    HEADER_DETAIL, STRAPLINE_TEXT, STRAPLINE_THUMBNAIL, SUBTITLE2, SUBTITLE3, THUMBNAIL_CROPPED,
4    TITLE_TEXT, TWO_COLUMN,
5};
6use crate::{
7    common::{ApiOutcome, PlaylistID, SetVideoID, Thumbnail, VideoID},
8    nav_consts::{
9        RESPONSIVE_HEADER, SECOND_SUBTITLE_RUNS, SECTION_LIST_ITEM, SINGLE_COLUMN_TAB, TAB_CONTENT,
10    },
11    query::{
12        AddPlaylistItemsQuery, CreatePlaylistQuery, CreatePlaylistType, DeletePlaylistQuery,
13        EditPlaylistQuery, GetPlaylistQuery, PrivacyStatus, RemovePlaylistItemsQuery,
14        SpecialisedQuery,
15    },
16    Error, Result,
17};
18use const_format::concatcp;
19use json_crawler::{JsonCrawler, JsonCrawlerIterator, JsonCrawlerOwned};
20use serde::{Deserialize, Serialize};
21
22#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
23#[non_exhaustive]
24pub struct GetPlaylist {
25    pub id: PlaylistID<'static>,
26    // NOTE: Only present on personal (library) playlists??
27    // NOTE: May not be present on old version of API also.
28    pub privacy: Option<PrivacyStatus>,
29    pub title: String,
30    pub description: Option<String>,
31    pub author: String,
32    pub year: String,
33    pub duration: String,
34    pub track_count_text: String,
35    // NOTE: Seem to be unable to distinguish when views is optional.
36    pub views: Option<String>,
37    pub thumbnails: Vec<Thumbnail>,
38    /// Not yet implemented
39    pub suggestions: Vec<()>,
40    /// Not yet implemented
41    pub related: Vec<()>,
42    pub tracks: Vec<PlaylistItem>,
43}
44#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
45/// Provides a SetVideoID and VideoID for each video added to the playlist.
46// Intentionally not marked non_exhaustive - not expecting this to change.
47pub struct AddPlaylistItem {
48    pub video_id: VideoID<'static>,
49    pub set_video_id: SetVideoID<'static>,
50}
51
52impl<'a> ParseFrom<RemovePlaylistItemsQuery<'a>> for () {
53    fn parse_from(_: ProcessedResult<RemovePlaylistItemsQuery<'a>>) -> crate::Result<Self> {
54        Ok(())
55    }
56}
57impl<'a, C: CreatePlaylistType> ParseFrom<CreatePlaylistQuery<'a, C>> for PlaylistID<'static> {
58    fn parse_from(p: ProcessedResult<CreatePlaylistQuery<'a, C>>) -> crate::Result<Self> {
59        let mut json_crawler: JsonCrawlerOwned = p.into();
60        json_crawler
61            .take_value_pointer("/playlistId")
62            .map_err(Into::into)
63    }
64}
65impl<'a, T: SpecialisedQuery> ParseFrom<AddPlaylistItemsQuery<'a, T>> for Vec<AddPlaylistItem> {
66    fn parse_from(p: ProcessedResult<AddPlaylistItemsQuery<'a, T>>) -> crate::Result<Self> {
67        let mut json_crawler: JsonCrawlerOwned = p.into();
68        let status: ApiOutcome = json_crawler.borrow_pointer("/status")?.take_value()?;
69        if let ApiOutcome::Failure = status {
70            return Err(Error::status_failed());
71        }
72        json_crawler
73            .navigate_pointer("/playlistEditResults")?
74            .try_iter_mut()?
75            .map(|r| {
76                let mut r = r.navigate_pointer("/playlistEditVideoAddedResultData")?;
77                Ok(AddPlaylistItem {
78                    video_id: r.take_value_pointer("/videoId")?,
79                    set_video_id: r.take_value_pointer("/setVideoId")?,
80                })
81            })
82            .collect()
83    }
84}
85impl<'a> ParseFrom<EditPlaylistQuery<'a>> for ApiOutcome {
86    fn parse_from(p: ProcessedResult<EditPlaylistQuery<'a>>) -> crate::Result<Self> {
87        let json_crawler: JsonCrawlerOwned = p.into();
88        json_crawler
89            .navigate_pointer("/status")?
90            .take_value()
91            .map_err(Into::into)
92    }
93}
94impl<'a> ParseFrom<DeletePlaylistQuery<'a>> for () {
95    fn parse_from(_: ProcessedResult<DeletePlaylistQuery<'a>>) -> crate::Result<Self> {
96        Ok(())
97    }
98}
99
100impl<'a> ParseFrom<GetPlaylistQuery<'a>> for GetPlaylist {
101    fn parse_from(p: ProcessedResult<GetPlaylistQuery<'a>>) -> crate::Result<Self> {
102        let json_crawler: JsonCrawlerOwned = p.into();
103        if json_crawler.path_exists("/header") {
104            get_playlist(json_crawler)
105        } else {
106            get_playlist_2024(json_crawler)
107        }
108    }
109}
110
111fn get_playlist(mut json_crawler: JsonCrawlerOwned) -> Result<GetPlaylist> {
112    let mut header = json_crawler.borrow_pointer(HEADER_DETAIL)?;
113    let title = header.take_value_pointer(TITLE_TEXT)?;
114    let privacy = None;
115    // TODO
116    let suggestions = Vec::new();
117    // TODO
118    let related = Vec::new();
119    // TODO
120    let description = None;
121    let author = header.take_value_pointer(SUBTITLE2)?;
122    let year = header.take_value_pointer(SUBTITLE3)?;
123    let thumbnails = header.take_value_pointer(THUMBNAIL_CROPPED)?;
124    let mut second_subtitle_runs = header.navigate_pointer(SECOND_SUBTITLE_RUNS)?;
125    let duration = second_subtitle_runs
126        .try_iter_mut()?
127        .try_last()?
128        .take_value_pointer("/text")?;
129    let track_count_text = second_subtitle_runs.try_expect(
130        "second subtitle runs should count at least 3 runs",
131        |second_subtitle_runs| {
132            second_subtitle_runs
133                .try_iter_mut()?
134                .rev()
135                .nth(2)
136                .map(|mut run| run.take_value_pointer("/text"))
137                .transpose()
138        },
139    )?;
140    let views = second_subtitle_runs
141        .try_iter_mut()?
142        .rev()
143        .nth(4)
144        .map(|mut item| item.take_value_pointer("/text"))
145        .transpose()?;
146
147    let mut results = json_crawler.borrow_pointer(concatcp!(
148        SINGLE_COLUMN_TAB,
149        SECTION_LIST_ITEM,
150        "/musicPlaylistShelfRenderer"
151    ))?;
152    let id = results.take_value_pointer("/playlistId")?;
153    let music_shelf = results.navigate_pointer("/contents")?;
154    let tracks = parse_playlist_items(music_shelf)?;
155    Ok(GetPlaylist {
156        id,
157        privacy,
158        title,
159        description,
160        author,
161        year,
162        duration,
163        track_count_text,
164        thumbnails,
165        suggestions,
166        related,
167        views,
168        tracks,
169    })
170}
171
172// NOTE: Similar code to get_album_2024
173fn get_playlist_2024(json_crawler: JsonCrawlerOwned) -> Result<GetPlaylist> {
174    let mut columns = json_crawler.navigate_pointer(TWO_COLUMN)?;
175    let header =
176        columns.borrow_pointer(concatcp!(TAB_CONTENT, SECTION_LIST_ITEM, RESPONSIVE_HEADER));
177    // TODO: Utilise a crawler library function here.
178    let mut header = match header {
179        Ok(header) => header,
180        Err(_) => columns.borrow_pointer(concatcp!(
181            TAB_CONTENT,
182            SECTION_LIST_ITEM,
183            "/musicEditablePlaylistDetailHeaderRenderer/header",
184            RESPONSIVE_HEADER
185        ))?,
186    };
187    // TODO
188    let suggestions = Vec::new();
189    // TODO
190    let related = Vec::new();
191    let title = header.take_value_pointer(TITLE_TEXT)?;
192    let author = header.take_value_pointer(STRAPLINE_TEXT)?;
193    // Thumbnails may not be present, refer to https://github.com/nick42d/youtui/issues/144
194    let thumbnails: Vec<Thumbnail> = header
195        .take_value_pointer(STRAPLINE_THUMBNAIL)
196        .unwrap_or_default();
197    let description = header
198        .borrow_pointer(DESCRIPTION_SHELF_RUNS)
199        .and_then(|d| d.try_into_iter())
200        .ok()
201        .map(|r| {
202            r.map(|mut r| r.take_value_pointer::<String>("/text"))
203                .collect::<std::result::Result<String, _>>()
204        })
205        .transpose()?;
206    let mut subtitle = header.borrow_pointer("/subtitle/runs")?;
207    let subtitle_len = subtitle.try_iter_mut()?.len();
208    let privacy = if subtitle_len == 5 {
209        Some(subtitle.take_value_pointer("/2/text")?)
210    } else {
211        None
212    };
213    let year = subtitle.take_value_pointer(format!("/{}/text", subtitle_len.saturating_sub(1)))?;
214    let mut second_subtitle_runs = header.borrow_pointer(SECOND_SUBTITLE_RUNS)?;
215    let duration = second_subtitle_runs
216        .try_iter_mut()?
217        .try_last()?
218        .take_value_pointer("/text")?;
219    let track_count_text = second_subtitle_runs.try_expect(
220        "second subtitle runs should count at least 3 runs",
221        |second_subtitle_runs| {
222            second_subtitle_runs
223                .try_iter_mut()?
224                .rev()
225                .nth(2)
226                .map(|mut run| run.take_value_pointer("/text"))
227                .transpose()
228        },
229    )?;
230    let views = second_subtitle_runs
231        .try_iter_mut()?
232        .rev()
233        .nth(4)
234        .map(|mut item| item.take_value_pointer("/text"))
235        .transpose()?;
236    let id = header
237        .navigate_pointer("/buttons")?
238        .try_into_iter()?
239        .find_path("/musicPlayButtonRenderer")?
240        .take_value_pointer("/playNavigationEndpoint/watchEndpoint/playlistId")?;
241    let music_shelf = columns.borrow_pointer(
242        "/secondaryContents/sectionListRenderer/contents/0/musicPlaylistShelfRenderer/contents",
243    )?;
244    let tracks = parse_playlist_items(music_shelf)?;
245    Ok(GetPlaylist {
246        id,
247        privacy,
248        title,
249        description,
250        author,
251        year,
252        duration,
253        track_count_text,
254        thumbnails,
255        suggestions,
256        related,
257        views,
258        tracks,
259    })
260}
261
262#[cfg(test)]
263mod tests {
264    use crate::{
265        auth::BrowserToken,
266        common::{ApiOutcome, PlaylistID, YoutubeID},
267        process_json,
268        query::{AddPlaylistItemsQuery, EditPlaylistQuery, GetPlaylistQuery},
269        Error,
270    };
271    use pretty_assertions::assert_eq;
272    use std::path::Path;
273
274    #[tokio::test]
275    async fn test_get_playlist_query() {
276        parse_test!(
277            "./test_json/get_playlist_20240617.json",
278            "./test_json/get_playlist_20240617_output.txt",
279            GetPlaylistQuery::new(PlaylistID::from_raw("")),
280            BrowserToken
281        );
282    }
283    #[tokio::test]
284    async fn test_add_playlist_items_query_failure() {
285        let source_path = Path::new("./test_json/add_playlist_items_failure_20240626.json");
286        let source = tokio::fs::read_to_string(source_path)
287            .await
288            .expect("Expect file read to pass during tests");
289        // Blank query has no bearing on function
290        let query = AddPlaylistItemsQuery::new_from_playlist(
291            PlaylistID::from_raw(""),
292            PlaylistID::from_raw(""),
293        );
294        let output = process_json::<_, BrowserToken>(source, query);
295        let err: crate::Result<()> = Err(Error::status_failed());
296        assert_eq!(format!("{:?}", err), format!("{:?}", output));
297    }
298    #[tokio::test]
299    async fn test_add_playlist_items_query() {
300        parse_test!(
301            "./test_json/add_playlist_items_20240626.json",
302            "./test_json/add_playlist_items_20240626_output.txt",
303            AddPlaylistItemsQuery::new_from_playlist(
304                PlaylistID::from_raw(""),
305                PlaylistID::from_raw(""),
306            ),
307            BrowserToken
308        );
309    }
310    #[tokio::test]
311    async fn test_edit_playlist_title_query() {
312        parse_test_value!(
313            "./test_json/edit_playlist_title_20240626.json",
314            ApiOutcome::Success,
315            EditPlaylistQuery::new_title(PlaylistID::from_raw(""), ""),
316            BrowserToken
317        );
318    }
319    #[tokio::test]
320    async fn test_get_playlist_query_2024() {
321        parse_test!(
322            "./test_json/get_playlist_20240624.json",
323            "./test_json/get_playlist_20240624_output.txt",
324            GetPlaylistQuery::new(PlaylistID::from_raw("")),
325            BrowserToken
326        );
327    }
328    #[tokio::test]
329    async fn test_get_playlist_query_2024_no_channel_thumbnail() {
330        parse_test!(
331            "./test_json/get_playlist_no_channel_thumbnail_20240818.json",
332            "./test_json/get_playlist_no_channel_thumbnail_20240818_output.txt",
333            GetPlaylistQuery::new(PlaylistID::from_raw("")),
334            BrowserToken
335        );
336    }
337}