ytmapi_rs/parse/
upload.rs

1use super::{
2    DELETION_ENTITY_ID, HEADER_DETAIL, ParseFrom, SECOND_SUBTITLE_RUNS, SUBTITLE,
3    fixed_column_item_pointer, flex_column_item_pointer,
4};
5use crate::Result;
6use crate::common::{
7    AlbumType, LikeStatus, Thumbnail, UploadAlbumID, UploadArtistID, UploadEntityID, VideoID,
8};
9use crate::continuations::ParseFromContinuable;
10use crate::nav_consts::{
11    CONTINUATION_PARAMS, GRID, GRID_CONTINUATION, INDEX_TEXT, MENU_ITEMS, MENU_LIKE_STATUS, MRLIR,
12    MUSIC_SHELF, MUSIC_SHELF_CONTINUATION, NAVIGATION_BROWSE_ID, PLAY_BUTTON, SECTION_LIST_ITEM,
13    SINGLE_COLUMN_TAB, SINGLE_COLUMN_TABS, SUBTITLE2, SUBTITLE3, TAB_RENDERER, TEXT_RUN_TEXT,
14    THUMBNAIL_ANIMATED_ICON, THUMBNAIL_BADGE_ICON, THUMBNAIL_CROPPED, THUMBNAIL_RENDERER,
15    THUMBNAILS, TITLE_TEXT, WATCH_VIDEO_ID,
16};
17use crate::parse::{parse_fixed_column_item, parse_flex_column_item};
18use crate::query::{
19    DeleteUploadEntityQuery, GetLibraryUploadAlbumQuery, GetLibraryUploadAlbumsQuery,
20    GetLibraryUploadArtistQuery, GetLibraryUploadArtistsQuery, GetLibraryUploadSongsQuery,
21};
22use crate::youtube_enums::{YoutubeMusicAnimatedIcon, YoutubeMusicBadgeRendererIcon};
23use const_format::concatcp;
24use json_crawler::{JsonCrawler, JsonCrawlerIterator, JsonCrawlerOwned};
25use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28// Intentionally not marked non_exhaustive - not expecting this to change.
29pub struct ParsedUploadArtist {
30    pub name: String,
31    pub id: Option<UploadArtistID<'static>>,
32}
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34// Intentionally not marked non_exhaustive - not expecting this to change.
35pub struct ParsedUploadSongAlbum {
36    pub name: String,
37    pub id: UploadAlbumID<'static>,
38}
39
40#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
41#[non_exhaustive]
42// May need to be enum to track 'Not Available' case.
43pub struct TableListUploadSong {
44    pub entity_id: UploadEntityID<'static>,
45    pub video_id: VideoID<'static>,
46    pub album: Option<ParsedUploadSongAlbum>,
47    pub duration: String,
48    pub like_status: LikeStatus,
49    pub title: String,
50    pub artists: Vec<ParsedUploadArtist>,
51    pub thumbnails: Vec<Thumbnail>,
52}
53
54#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
55#[non_exhaustive]
56pub struct UploadAlbum {
57    pub title: String,
58    /// Artist name or year, if year field is empty.
59    pub subtitle: Option<String>,
60    pub year: Option<String>,
61    pub entity_id: UploadEntityID<'static>,
62    pub album_id: UploadAlbumID<'static>,
63    pub thumbnails: Vec<Thumbnail>,
64}
65
66#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
67#[non_exhaustive]
68pub struct UploadArtist {
69    pub artist_name: String,
70    pub song_count: String,
71    pub artist_id: UploadArtistID<'static>,
72    pub thumbnails: Vec<Thumbnail>,
73}
74
75#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
76#[non_exhaustive]
77pub struct GetLibraryUploadAlbum {
78    pub title: String,
79    pub artist_name: String,
80    pub album_type: AlbumType,
81    pub song_count: String,
82    pub duration: String,
83    pub entity_id: UploadEntityID<'static>,
84    pub songs: Vec<GetLibraryUploadAlbumSong>,
85    pub thumbnails: Vec<Thumbnail>,
86}
87
88#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
89#[non_exhaustive]
90// May need to be enum to track 'Not Available' case.
91pub struct GetLibraryUploadAlbumSong {
92    pub title: String,
93    pub track_no: i64,
94    pub entity_id: UploadEntityID<'static>,
95    pub video_id: VideoID<'static>,
96    pub album: ParsedUploadSongAlbum,
97    pub duration: String,
98    pub like_status: LikeStatus,
99}
100
101impl ParseFromContinuable<GetLibraryUploadSongsQuery> for Vec<TableListUploadSong> {
102    fn parse_from_continuable(
103        p: super::ProcessedResult<GetLibraryUploadSongsQuery>,
104    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
105        let crawler: JsonCrawlerOwned = p.into();
106        let mut music_shelf = get_uploads_tab(crawler)?.navigate_pointer(concatcp!(
107            TAB_RENDERER,
108            SECTION_LIST_ITEM,
109            MUSIC_SHELF,
110        ))?;
111        let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
112        let songs = parse_table_list_upload_songs(music_shelf)?;
113        Ok((songs, continuation_params))
114    }
115    fn parse_continuation(
116        p: super::ProcessedResult<
117            crate::query::GetContinuationsQuery<'_, GetLibraryUploadSongsQuery>,
118        >,
119    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
120        let crawler: JsonCrawlerOwned = p.into();
121        let mut music_shelf = crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
122        let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
123        let songs = parse_table_list_upload_songs(music_shelf)?;
124        Ok((songs, continuation_params))
125    }
126}
127impl ParseFromContinuable<GetLibraryUploadArtistsQuery> for Vec<UploadArtist> {
128    fn parse_from_continuable(
129        p: super::ProcessedResult<GetLibraryUploadArtistsQuery>,
130    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
131        let crawler: JsonCrawlerOwned = p.into();
132        let mut music_shelf = get_uploads_tab(crawler)?.navigate_pointer(concatcp!(
133            TAB_RENDERER,
134            SECTION_LIST_ITEM,
135            MUSIC_SHELF,
136        ))?;
137        let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
138        let res = music_shelf
139            .navigate_pointer("/contents")?
140            .try_into_iter()?
141            .map(parse_item_list_upload_artist)
142            .collect::<Result<Vec<_>>>()?;
143        Ok((res, continuation_params))
144    }
145    fn parse_continuation(
146        p: super::ProcessedResult<
147            crate::query::GetContinuationsQuery<'_, GetLibraryUploadArtistsQuery>,
148        >,
149    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
150        let crawler: JsonCrawlerOwned = p.into();
151        let mut music_shelf = crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
152        let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
153        let res = music_shelf
154            .navigate_pointer("/contents")?
155            .try_into_iter()?
156            .map(parse_item_list_upload_artist)
157            .collect::<Result<Vec<_>>>()?;
158        Ok((res, continuation_params))
159    }
160}
161impl ParseFromContinuable<GetLibraryUploadArtistQuery<'_>> for Vec<TableListUploadSong> {
162    fn parse_from_continuable(
163        p: super::ProcessedResult<GetLibraryUploadArtistQuery>,
164    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
165        let crawler: JsonCrawlerOwned = p.into();
166        let mut music_shelf = get_uploads_tab(crawler)?.navigate_pointer(concatcp!(
167            TAB_RENDERER,
168            SECTION_LIST_ITEM,
169            MUSIC_SHELF,
170        ))?;
171        let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
172        let songs = parse_table_list_upload_songs(music_shelf)?;
173        Ok((songs, continuation_params))
174    }
175    fn parse_continuation(
176        p: super::ProcessedResult<
177            crate::query::GetContinuationsQuery<'_, GetLibraryUploadArtistQuery>,
178        >,
179    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
180        let crawler: JsonCrawlerOwned = p.into();
181        let mut music_shelf = crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
182        let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
183        let songs = parse_table_list_upload_songs(music_shelf)?;
184        Ok((songs, continuation_params))
185    }
186}
187impl ParseFromContinuable<GetLibraryUploadAlbumsQuery> for Vec<UploadAlbum> {
188    fn parse_from_continuable(
189        p: super::ProcessedResult<GetLibraryUploadAlbumsQuery>,
190    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
191        let crawler: JsonCrawlerOwned = p.into();
192        let mut grid_renderer = get_uploads_tab(crawler)?.navigate_pointer(concatcp!(
193            TAB_RENDERER,
194            SECTION_LIST_ITEM,
195            GRID
196        ))?;
197        let continuation_params = grid_renderer.take_value_pointer(CONTINUATION_PARAMS).ok();
198        let res = grid_renderer
199            .navigate_pointer("/items")?
200            .try_into_iter()?
201            .map(parse_item_list_upload_album)
202            .collect::<Result<Vec<_>>>()?;
203        Ok((res, continuation_params))
204    }
205    fn parse_continuation(
206        p: super::ProcessedResult<
207            crate::query::GetContinuationsQuery<'_, GetLibraryUploadAlbumsQuery>,
208        >,
209    ) -> crate::Result<(Self, Option<crate::common::ContinuationParams<'static>>)> {
210        let crawler: JsonCrawlerOwned = p.into();
211        let mut grid_renderer = crawler.navigate_pointer(GRID_CONTINUATION)?;
212        let continuation_params = grid_renderer.take_value_pointer(CONTINUATION_PARAMS).ok();
213        let res = grid_renderer
214            .navigate_pointer("/items")?
215            .try_into_iter()?
216            .map(parse_item_list_upload_album)
217            .collect::<Result<Vec<_>>>()?;
218        Ok((res, continuation_params))
219    }
220}
221impl ParseFrom<GetLibraryUploadAlbumQuery<'_>> for GetLibraryUploadAlbum {
222    fn parse_from(
223        p: super::ProcessedResult<GetLibraryUploadAlbumQuery<'_>>,
224    ) -> crate::Result<Self> {
225        fn parse_playlist_upload_song(
226            mut json_crawler: JsonCrawlerOwned,
227        ) -> Result<GetLibraryUploadAlbumSong> {
228            let mut data = json_crawler.borrow_pointer(MRLIR)?;
229            let title = parse_flex_column_item(&mut data.borrow_mut(), 0, 0)?;
230            let album = parse_upload_song_album(data.borrow_mut(), 2)?;
231            let duration = parse_fixed_column_item(&mut data.borrow_mut(), 0)?;
232            let track_no = data.borrow_pointer(INDEX_TEXT)?.take_and_parse_str()?;
233            let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
234            let video_id = data.take_value_pointer(concatcp!(
235                PLAY_BUTTON,
236                "/playNavigationEndpoint",
237                WATCH_VIDEO_ID
238            ))?;
239            let entity_id = data
240                .borrow_pointer(MENU_ITEMS)?
241                .try_iter_mut()?
242                .find_path(DELETION_ENTITY_ID)?
243                .take_value()?;
244            Ok(GetLibraryUploadAlbumSong {
245                title,
246                track_no,
247                entity_id,
248                video_id,
249                album,
250                duration,
251                like_status,
252            })
253        }
254        let mut crawler: JsonCrawlerOwned = p.into();
255        let mut header = crawler.borrow_pointer(HEADER_DETAIL)?;
256        let title = header.take_value_pointer(TITLE_TEXT)?;
257        let album_type = header.take_value_pointer(SUBTITLE)?;
258        let artist_name = header.take_value_pointer(SUBTITLE2)?;
259        let song_count = header.take_value_pointer(concatcp!(SECOND_SUBTITLE_RUNS, "/0/text"))?;
260        let duration = header.take_value_pointer(concatcp!(SECOND_SUBTITLE_RUNS, "/2/text"))?;
261        let thumbnails = header.take_value_pointer(THUMBNAIL_CROPPED)?;
262        let entity_id = header
263            .navigate_pointer(MENU_ITEMS)?
264            .try_into_iter()?
265            .find_path(DELETION_ENTITY_ID)?
266            .take_value()?;
267        let songs = crawler
268            .navigate_pointer(concatcp!(
269                SINGLE_COLUMN_TAB,
270                SECTION_LIST_ITEM,
271                MUSIC_SHELF,
272                "/contents"
273            ))?
274            .try_into_iter()?
275            .map(parse_playlist_upload_song)
276            .collect::<Result<Vec<_>>>()?;
277        Ok(GetLibraryUploadAlbum {
278            title,
279            artist_name,
280            album_type,
281            song_count,
282            duration,
283            entity_id,
284            songs,
285            thumbnails,
286        })
287    }
288}
289impl<'a> ParseFrom<DeleteUploadEntityQuery<'a>> for () {
290    fn parse_from(p: super::ProcessedResult<DeleteUploadEntityQuery<'a>>) -> crate::Result<Self> {
291        let crawler: JsonCrawlerOwned = p.into();
292        // Passing an invalid entity ID with will throw a 400 error which
293        // is caught by AuthToken.
294        // NOTE: Passing the same entity id for deletion multiple times
295        crawler
296            .navigate_pointer("/actions")?
297            .try_into_iter()?
298            .find_path("/addToToastAction")
299            .map(|_| ())
300            .map_err(Into::into)
301    }
302}
303pub(crate) fn parse_upload_song_artists(
304    data: impl JsonCrawler,
305    col_idx: usize,
306) -> Result<Vec<ParsedUploadArtist>> {
307    data.navigate_pointer(format!("{}/text/runs", flex_column_item_pointer(col_idx)))?
308        .try_into_iter()?
309        .step_by(2)
310        .map(|item| parse_upload_song_artist(item))
311        .collect()
312}
313fn parse_upload_song_artist(mut data: impl JsonCrawler) -> Result<ParsedUploadArtist> {
314    Ok(ParsedUploadArtist {
315        name: data.take_value_pointer("/text")?,
316        id: data.take_value_pointer(NAVIGATION_BROWSE_ID).ok(),
317    })
318}
319pub(crate) fn parse_upload_song_album(
320    mut data: impl JsonCrawler,
321    col_idx: usize,
322) -> Result<ParsedUploadSongAlbum> {
323    Ok(ParsedUploadSongAlbum {
324        name: parse_flex_column_item(&mut data, col_idx, 0)?,
325        id: data.take_value_pointer(format!(
326            "{}/text/runs/0{NAVIGATION_BROWSE_ID}",
327            flex_column_item_pointer(col_idx)
328        ))?,
329    })
330}
331fn parse_table_list_upload_songs(
332    music_shelf: impl JsonCrawler,
333) -> crate::Result<Vec<TableListUploadSong>> {
334    let contents = music_shelf.navigate_pointer("/contents")?;
335    contents
336        .try_into_iter()?
337        .map(|mut item| {
338            let Ok(mut data) = item.borrow_pointer(MRLIR) else {
339                return Ok(None);
340            };
341            // Handle list item is "Shuffle all"
342            let badge_icon =
343                data.take_value_pointer::<YoutubeMusicBadgeRendererIcon>(THUMBNAIL_BADGE_ICON);
344            if badge_icon.is_ok() {
345                return Ok(None);
346            }
347            // Handle list item is "x song(s) processing..."
348            let animated_icon =
349                data.take_value_pointer::<YoutubeMusicAnimatedIcon>(THUMBNAIL_ANIMATED_ICON);
350            if animated_icon.is_ok() {
351                return Ok(None);
352            }
353            Ok(Some(parse_table_list_upload_song(data.borrow_mut())?))
354        })
355        .filter_map(Result::transpose)
356        .collect()
357}
358pub(crate) fn parse_table_list_upload_song(
359    mut crawler: impl JsonCrawler,
360) -> Result<TableListUploadSong> {
361    let title: String = parse_flex_column_item(&mut crawler, 0, 0)?;
362    let duration =
363        crawler.take_value_pointer(format!("{}{TEXT_RUN_TEXT}", fixed_column_item_pointer(0)))?;
364    let like_status = crawler.take_value_pointer(MENU_LIKE_STATUS)?;
365    let video_id = crawler.take_value_pointer(concatcp!(
366        PLAY_BUTTON,
367        "/playNavigationEndpoint/watchEndpoint/videoId"
368    ))?;
369    let thumbnails = crawler.take_value_pointer(THUMBNAILS)?;
370    // An uploaded song may not have artists metadata
371    let artists = parse_upload_song_artists(crawler.borrow_mut(), 1).unwrap_or_default();
372    // An uploaded song may not have aalbum metadata
373    let album = parse_upload_song_album(crawler.borrow_mut(), 2).ok();
374    let entity_id = crawler
375        .navigate_pointer(MENU_ITEMS)?
376        .try_into_iter()?
377        .find_path(DELETION_ENTITY_ID)?
378        .take_value()?;
379    Ok(TableListUploadSong {
380        entity_id,
381        video_id,
382        album,
383        duration,
384        like_status,
385        title,
386        artists,
387        thumbnails,
388    })
389}
390fn parse_item_list_upload_artist(mut item: impl JsonCrawler) -> Result<UploadArtist> {
391    let mut data = item.borrow_pointer(MRLIR)?;
392    let artist_name = parse_flex_column_item(&mut data.borrow_mut(), 0, 0)?;
393    let songs = parse_flex_column_item(&mut data.borrow_mut(), 1, 0)?;
394    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
395    let artist_id = data.take_value_pointer(NAVIGATION_BROWSE_ID)?;
396    Ok(UploadArtist {
397        thumbnails,
398        artist_name,
399        song_count: songs,
400        artist_id,
401    })
402}
403fn parse_item_list_upload_album(mut json_crawler: impl JsonCrawler) -> Result<UploadAlbum> {
404    let mut data = json_crawler.borrow_pointer("/musicTwoRowItemRenderer")?;
405    let album_id = data.take_value_pointer(NAVIGATION_BROWSE_ID)?;
406    let thumbnails = data.take_value_pointer(THUMBNAIL_RENDERER)?;
407    let title = data.take_value_pointer(TITLE_TEXT)?;
408    let subtitle = data.take_value_pointer(SUBTITLE2).ok();
409    let year = data.take_value_pointer(SUBTITLE3).ok();
410    let entity_id = data
411        .borrow_pointer(MENU_ITEMS)?
412        .try_iter_mut()?
413        .find_path(DELETION_ENTITY_ID)?
414        .take_value()?;
415    Ok(UploadAlbum {
416        title,
417        year,
418        thumbnails,
419        subtitle,
420        entity_id,
421        album_id,
422    })
423}
424
425fn get_uploads_tab(json: JsonCrawlerOwned) -> Result<JsonCrawlerOwned> {
426    let tabs_path = concatcp!(SINGLE_COLUMN_TABS);
427    json.navigate_pointer(tabs_path)?
428        .try_into_iter()?
429        .try_last()
430        .map_err(Into::into)
431}
432
433#[cfg(test)]
434mod tests {
435    use crate::auth::BrowserToken;
436    use crate::common::{UploadAlbumID, UploadArtistID, UploadEntityID, YoutubeID};
437    #[tokio::test]
438    async fn test_get_library_upload_songs() {
439        parse_with_matching_continuation_test!(
440            "./test_json/get_library_upload_songs_20240712.json",
441            "./test_json/get_library_upload_songs_continuation_20240712.json",
442            "./test_json/get_library_upload_songs_20240712_output.txt",
443            crate::query::GetLibraryUploadSongsQuery::default(),
444            BrowserToken
445        );
446    }
447    #[tokio::test]
448    async fn test_get_library_upload_albums() {
449        parse_with_matching_continuation_test!(
450            "./test_json/get_library_upload_albums_20240712.json",
451            "./test_json/get_library_upload_albums_continuation_20240712.json",
452            "./test_json/get_library_upload_albums_20240712_output.txt",
453            crate::query::GetLibraryUploadAlbumsQuery::default(),
454            BrowserToken
455        );
456    }
457    #[tokio::test]
458    async fn test_get_library_upload_artists() {
459        parse_with_matching_continuation_test!(
460            "./test_json/get_library_upload_artists_20240712.json",
461            "./test_json/get_library_upload_artists_continuation_20240712.json",
462            "./test_json/get_library_upload_artists_20240712_output.txt",
463            crate::query::GetLibraryUploadArtistsQuery::default(),
464            BrowserToken
465        );
466    }
467    #[tokio::test]
468    async fn test_get_library_upload_artist() {
469        parse_with_matching_continuation_test!(
470            "./test_json/get_library_upload_artist_20240712.json",
471            "./test_json/get_library_upload_artist_continuation_20240712.json",
472            "./test_json/get_library_upload_artist_20240712_output.txt",
473            crate::query::GetLibraryUploadArtistQuery::new(UploadArtistID::from_raw("")),
474            BrowserToken
475        );
476    }
477    #[tokio::test]
478    async fn test_get_library_upload_album() {
479        parse_test!(
480            "./test_json/get_library_upload_album_20240712.json",
481            "./test_json/get_library_upload_album_20240712_output.txt",
482            crate::query::GetLibraryUploadAlbumQuery::new(UploadAlbumID::from_raw("")),
483            BrowserToken
484        );
485    }
486    #[tokio::test]
487    async fn test_delete_upload_entity() {
488        parse_test_value!(
489            "./test_json/delete_upload_entity_20240715.json",
490            (),
491            crate::query::DeleteUploadEntityQuery::new(UploadEntityID::from_raw("")),
492            BrowserToken
493        );
494    }
495}