Skip to main content

ytmapi_rs/parse/
artist.rs

1use super::search::SearchResultVideo;
2use super::{
3    ParseFrom, ParsedSongAlbum, ParsedSongArtist, ProcessedResult, Thumbnail,
4    parse_flex_column_item, parse_song_album, parse_song_artists,
5};
6use crate::Result;
7use crate::common::{
8    AlbumID, AlbumType, ArtistChannelID, BrowseParams, Explicit, LibraryManager, LibraryStatus,
9    LikeStatus, PlaylistID, VideoID,
10};
11use crate::nav_consts::*;
12use crate::query::*;
13use const_format::concatcp;
14use json_crawler::{JsonCrawler, JsonCrawlerIterator, JsonCrawlerOwned};
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[non_exhaustive]
19pub struct GetArtist {
20    pub description: Option<String>,
21    pub views: Option<String>,
22    pub name: String,
23    pub channel_id: ArtistChannelID<'static>,
24    pub shuffle_id: Option<String>,
25    pub radio_id: Option<String>,
26    pub subscribers: Option<String>,
27    pub subscribed: bool,
28    pub thumbnails: Vec<Thumbnail>,
29    pub top_releases: GetArtistTopReleases,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[non_exhaustive]
34pub struct GetArtistAlbumsAlbum {
35    pub title: String,
36    // TODO: Use type system
37    pub playlist_id: Option<String>,
38    // TODO: Use type system
39    pub browse_id: AlbumID<'static>,
40    pub category: Option<String>, // TODO change to enum
41    pub thumbnails: Vec<Thumbnail>,
42    pub year: Option<String>,
43}
44
45impl<'a> ParseFrom<GetArtistQuery<'a>> for GetArtist {
46    fn parse_from(p: ProcessedResult<GetArtistQuery<'a>>) -> crate::Result<Self> {
47        let mut json_crawler: JsonCrawlerOwned = p.into();
48        let mut results =
49            json_crawler.borrow_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST))?;
50        let mut maybe_description_shelf = results.try_iter_mut()?.find_path(DESCRIPTION_SHELF).ok();
51        let description = maybe_description_shelf
52            .as_mut()
53            .map(|description_shelf| description_shelf.take_value_pointer(DESCRIPTION))
54            .transpose()?;
55        let views = maybe_description_shelf.and_then(|mut description_shelf| {
56            description_shelf
57                .take_value_pointer(concatcp!("/subheader", RUN_TEXT))
58                .ok()
59        });
60        let top_releases = parse_artist_top_releases_from_section_list_contents(results)?;
61        let mut header = json_crawler.navigate_pointer("/header/musicImmersiveHeaderRenderer")?;
62        let name = header.take_value_pointer(TITLE_TEXT)?;
63        let shuffle_id = header
64            .take_value_pointer(concatcp!(
65                "/playButton/buttonRenderer",
66                NAVIGATION_PLAYLIST_ID
67            ))
68            .ok();
69        let radio_id = header
70            .take_value_pointer(concatcp!(
71                "/startRadioButton/buttonRenderer",
72                NAVIGATION_PLAYLIST_ID
73            ))
74            .ok();
75        let thumbnails = header.take_value_pointer(THUMBNAILS)?;
76        let mut subscription_button =
77            header.navigate_pointer("/subscriptionButton/subscribeButtonRenderer")?;
78        let channel_id = subscription_button.take_value_pointer("/channelId")?;
79        let subscribers = subscription_button
80            .take_value_pointer("/subscriberCountText/runs/0/text")
81            .ok();
82        let subscribed = subscription_button.take_value_pointer("/subscribed")?;
83        Ok(GetArtist {
84            views,
85            description,
86            name,
87            top_releases,
88            thumbnails,
89            subscribed,
90            radio_id,
91            channel_id,
92            shuffle_id,
93            subscribers,
94        })
95    }
96}
97
98impl ParseFrom<SubscribeArtistQuery<'_>> for () {
99    fn parse_from(p: ProcessedResult<SubscribeArtistQuery<'_>>) -> crate::Result<Self> {
100        let json_crawler: JsonCrawlerOwned = p.into();
101        // Basically, return an error if there is no 'successResponseText'
102        json_crawler
103            .navigate_pointer("/actions")?
104            .try_into_iter()?
105            .find_path("/addToToastAction")?
106            .navigate_pointer("/item/notificationTextRenderer/successResponseText")?;
107        Ok(())
108    }
109}
110impl ParseFrom<UnsubscribeArtistsQuery<'_>> for () {
111    fn parse_from(p: ProcessedResult<UnsubscribeArtistsQuery<'_>>) -> crate::Result<Self> {
112        let json_crawler: JsonCrawlerOwned = p.into();
113        // Basically, return an error if there is no 'successResponseText'
114        json_crawler
115            .navigate_pointer("/actions")?
116            .try_into_iter()?
117            .find_path("/updateSubscribeButtonAction")?
118            .navigate_pointer("/subscribed")?;
119        Ok(())
120    }
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
124#[non_exhaustive]
125pub struct GetArtistTopReleases {
126    pub songs: Option<GetArtistSongs>,
127    pub albums: Option<GetArtistAlbums>,
128    pub singles: Option<GetArtistAlbums>,
129    pub videos: Option<GetArtistVideos>,
130    pub related: Option<GetArtistRelated>,
131}
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
133#[non_exhaustive]
134pub struct GetArtistRelated {
135    pub results: Vec<RelatedResult>,
136}
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138#[non_exhaustive]
139pub struct GetArtistSongs {
140    pub results: Vec<ArtistSong>,
141    pub browse_id: PlaylistID<'static>,
142}
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
144#[non_exhaustive]
145pub struct ArtistSong {
146    pub video_id: VideoID<'static>,
147    pub plays: String,
148    pub album: ParsedSongAlbum,
149    pub artists: Vec<ParsedSongArtist>,
150    /// Library management fields are optional; if a album has already been
151    /// added to your library, you cannot add the individual songs.
152    // https://github.com/nick42d/youtui/issues/138
153    pub library_management: Option<LibraryManager>,
154    pub title: String,
155    pub like_status: LikeStatus,
156    pub explicit: Explicit,
157}
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159#[non_exhaustive]
160pub struct GetArtistVideos {
161    pub results: Vec<SearchResultVideo>,
162    pub browse_id: PlaylistID<'static>,
163}
164/// The Albums section of the Browse Artist page.
165/// The browse_id and params can be used to get the full list of artist's
166/// albums. If they aren't set, and results is not empty, you can assume that
167/// all albums are displayed here already.
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169#[non_exhaustive]
170pub struct GetArtistAlbums {
171    pub results: Vec<AlbumResult>,
172    // XXX: Unsure if AlbumID is correct here.
173    pub browse_id: Option<ArtistChannelID<'static>>,
174    pub params: Option<BrowseParams<'static>>,
175}
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177#[non_exhaustive]
178pub struct RelatedResult {
179    pub browse_id: ArtistChannelID<'static>,
180    pub title: String,
181    pub subscribers: String,
182}
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
184#[non_exhaustive]
185pub struct AlbumResult {
186    pub title: String,
187    #[deprecated = "Future deprecation see https://github.com/nick42d/youtui/issues/211"]
188    pub album_type: Option<AlbumType>,
189    pub year: String,
190    pub album_id: AlbumID<'static>,
191    pub library_status: LibraryStatus,
192    pub thumbnails: Vec<Thumbnail>,
193    pub explicit: Explicit,
194}
195
196#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
197#[non_exhaustive]
198// Could this alternatively be Result<Song>?
199// May need to be enum to track 'Not Available' case.
200// NOTE: Difference between this and PlaylistSong is no trackId.
201pub struct TableListSong {
202    pub video_id: VideoID<'static>,
203    pub album: ParsedSongAlbum,
204    pub duration: String,
205    /// Some songs may not have library management features. There could be
206    /// various resons for this.
207    pub library_management: Option<LibraryManager>,
208    pub title: String,
209    pub artists: Vec<super::ParsedSongArtist>,
210    // TODO: Song like feedback tokens.
211    pub like_status: LikeStatus,
212    pub thumbnails: Vec<Thumbnail>,
213    pub explicit: Explicit,
214    pub is_available: bool,
215    /// Id of the playlist that will get created when pressing 'Start Radio'.
216    pub playlist_id: PlaylistID<'static>,
217}
218
219// Should be at higher level in mod structure.
220#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
221enum ArtistTopReleaseCategory {
222    #[serde(alias = "albums")]
223    Albums,
224    #[serde(alias = "singles")]
225    Singles,
226    #[serde(alias = "videos")]
227    Videos,
228    #[serde(alias = "playlists")]
229    Playlists,
230    #[serde(alias = "fans might also like")]
231    Related,
232    #[serde(other)]
233    None,
234}
235
236fn parse_artist_song(mut json: impl JsonCrawler) -> Result<ArtistSong> {
237    let mut data = json.borrow_pointer(MRLIR)?;
238    let title = parse_flex_column_item(&mut data, 0, 0)?;
239    let plays = parse_flex_column_item(&mut data, 2, 0)?;
240    let artists = parse_song_artists(&mut data, 1)?;
241    let album = parse_song_album(&mut data, 3)?;
242    let video_id = data.take_value_pointer(PLAYLIST_ITEM_VIDEO_ID)?;
243    let explicit = if data.path_exists(BADGE_LABEL) {
244        Explicit::IsExplicit
245    } else {
246        Explicit::NotExplicit
247    };
248    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
249    let library_management =
250        parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
251    Ok(ArtistSong {
252        video_id,
253        plays,
254        album,
255        artists,
256        library_management,
257        title,
258        like_status,
259        explicit,
260    })
261}
262fn parse_artist_songs(mut json: impl JsonCrawler) -> Result<GetArtistSongs> {
263    // Unsure if this should be optional or not.
264    let browse_id = json.take_value_pointer(concatcp!(TITLE, NAVIGATION_BROWSE_ID))?;
265    let results = json
266        .borrow_pointer("/contents")?
267        .try_into_iter()?
268        .map(parse_artist_song)
269        .collect::<Result<Vec<ArtistSong>>>()?;
270    Ok(GetArtistSongs { results, browse_id })
271}
272// While this function gets improved, we'll allow this lint for the creation of
273// GetArtistTopReleases.
274#[allow(clippy::field_reassign_with_default)]
275fn parse_artist_top_releases_from_section_list_contents(
276    mut contents: impl JsonCrawler,
277) -> Result<GetArtistTopReleases> {
278    let mut top_releases = GetArtistTopReleases::default();
279    top_releases.songs = contents
280        .borrow_pointer(concatcp!("/0", MUSIC_SHELF))
281        .ok()
282        .map(parse_artist_songs)
283        .transpose()?;
284    // TODO: Check if Carousel Title is in list of categories.
285    // TODO: Actually pass these variables in the return
286    // XXX: Looks to be two loops over results here.
287    // XXX: if there are multiple results for each category we only want to look at
288    // the first one.
289    for mut r in contents
290        .try_iter_mut()
291        .into_iter()
292        .flatten()
293        .filter_map(|r| r.navigate_pointer("/musicCarouselShelfRenderer").ok())
294    {
295        // XXX: Should this only be on the first result per category?
296        let category = r.take_value_pointer(concatcp!(CAROUSEL_TITLE, "/text"))?;
297        // Likely optional, need to confirm.
298        // XXX: Errors here
299        let browse_id: Option<ArtistChannelID> = r
300            .take_value_pointer(concatcp!(CAROUSEL_TITLE, NAVIGATION_BROWSE_ID))
301            .ok();
302        // XXX should only be mandatory for albums, singles, playlists
303        // as a result leaving as optional for now.
304        let params = r
305            .take_value_pointer(concatcp!(
306                CAROUSEL_TITLE,
307                "/navigationEndpoint/browseEndpoint/params"
308            ))
309            .ok();
310        // TODO: finish other categories
311        match category {
312            ArtistTopReleaseCategory::Related => (),
313            ArtistTopReleaseCategory::Videos => (),
314            ArtistTopReleaseCategory::Singles => (),
315            ArtistTopReleaseCategory::Albums => {
316                let mut results = Vec::new();
317                for i in r.navigate_pointer("/contents")?.try_iter_mut()? {
318                    results.push(parse_album_from_mtrir(i.navigate_pointer(MTRIR)?)?);
319                }
320                let albums = GetArtistAlbums {
321                    browse_id,
322                    params,
323                    results,
324                };
325                top_releases.albums = Some(albums);
326            }
327            ArtistTopReleaseCategory::Playlists => (),
328            ArtistTopReleaseCategory::None => (),
329        }
330    }
331    Ok(top_releases)
332}
333
334/// Google A/B change pending
335pub(crate) fn parse_album_from_mtrir(mut navigator: impl JsonCrawler) -> Result<AlbumResult> {
336    let title = navigator.take_value_pointer(TITLE_TEXT)?;
337
338    let (year, album_type) = match navigator.take_value_pointer(SUBTITLE2) {
339        Ok(subtitle2) => {
340            // See https://github.com/nick42d/youtui/issues/211
341            ab_warn!();
342            (subtitle2, navigator.take_value_pointer(SUBTITLE)?)
343        }
344        Err(_) => (navigator.take_value_pointer(SUBTITLE)?, None),
345    };
346
347    let album_id = navigator.take_value_pointer(concatcp!(TITLE, NAVIGATION_BROWSE_ID))?;
348    let thumbnails = navigator.take_value_pointer(THUMBNAIL_RENDERER)?;
349    let explicit = if navigator.path_exists(concatcp!(SUBTITLE_BADGE_LABEL)) {
350        Explicit::IsExplicit
351    } else {
352        Explicit::NotExplicit
353    };
354    let mut library_menu = navigator
355        .borrow_pointer(MENU_ITEMS)?
356        .try_into_iter()?
357        .find_path("/toggleMenuServiceItemRenderer")?;
358    let library_status = library_menu.take_value_pointer("/defaultIcon/iconType")?;
359    Ok(AlbumResult {
360        title,
361        album_type,
362        year,
363        album_id,
364        library_status,
365        thumbnails,
366        explicit,
367    })
368}
369
370pub(crate) fn parse_library_management_items_from_menu(
371    menu: impl JsonCrawler,
372) -> Result<Option<LibraryManager>> {
373    let Some((status, add_to_library_token, remove_from_library_token)) = menu
374        .try_into_iter()?
375        .filter_map(|menu_item| {
376            menu_item
377                .navigate_pointer("/toggleMenuServiceItemRenderer")
378                .ok()
379        })
380        .filter_map(|mut toggle_menu| {
381            let Ok(status) = toggle_menu.take_value_pointer("/defaultIcon/iconType") else {
382                // In this case the toggle_menu is not the right type, e.g might be Pin to
383                // Listen Again.
384                //
385                // e.g: https://github.com/nick42d/youtui/issues/193
386                return None;
387            };
388            if let Ok("Sign in") = toggle_menu
389                .take_value_pointer::<String>(DEFAULT_ENDPOINT_MODAL_TEXT)
390                .as_deref()
391            {
392                // In this case you are not signed in as so there are no add/remove from library
393                // tokens.
394                // NOTE: Since this is known at compile time, could specialise the
395                // ParseFrom and return a hard error when signed in.
396                return None;
397            }
398            let (add_to_library_token, remove_from_library_token) = match status {
399                LibraryStatus::InLibrary => (
400                    toggle_menu.take_value_pointer(TOGGLED_ENDPOINT),
401                    toggle_menu.take_value_pointer(DEFAULT_ENDPOINT),
402                ),
403                LibraryStatus::NotInLibrary => (
404                    toggle_menu.take_value_pointer(DEFAULT_ENDPOINT),
405                    toggle_menu.take_value_pointer(TOGGLED_ENDPOINT),
406                ),
407            };
408            Some((status, add_to_library_token, remove_from_library_token))
409        })
410        .next()
411    else {
412        // In this case there is no toggle_menu, so returning None is not an error.
413        return Ok(None);
414    };
415    Ok(Some(LibraryManager {
416        status,
417        add_to_library_token: add_to_library_token?,
418        remove_from_library_token: remove_from_library_token?,
419    }))
420}
421
422impl<'a> ParseFrom<GetArtistAlbumsQuery<'a>> for Vec<GetArtistAlbumsAlbum> {
423    fn parse_from(p: ProcessedResult<GetArtistAlbumsQuery<'a>>) -> crate::Result<Self> {
424        let json_crawler: JsonCrawlerOwned = p.into();
425        let mut albums = Vec::new();
426        let mut json_crawler = json_crawler.navigate_pointer(concatcp!(
427            SINGLE_COLUMN_TAB,
428            SECTION_LIST_ITEM,
429            GRID_ITEMS
430        ))?;
431        for mut r in json_crawler
432            .borrow_mut()
433            .try_into_iter()?
434            .flat_map(|i| i.navigate_pointer(MTRIR))
435        {
436            let browse_id = r.take_value_pointer(concatcp!(TITLE, NAVIGATION_BROWSE_ID))?;
437            let playlist_id = r.take_value_pointer(MENU_PLAYLIST_ID).ok();
438            let title = r.take_value_pointer(TITLE_TEXT)?;
439            let thumbnails = r.take_value_pointer(THUMBNAIL_RENDERER)?;
440            // TODO: category
441            let category = r.take_value_pointer(SUBTITLE).ok();
442            albums.push(GetArtistAlbumsAlbum {
443                browse_id,
444                year: None,
445                title,
446                category,
447                thumbnails,
448                playlist_id,
449            });
450        }
451        Ok(albums)
452    }
453}
454#[cfg(test)]
455mod tests {
456    use crate::auth::BrowserToken;
457    use crate::common::{ArtistChannelID, BrowseParams, YoutubeID};
458    use crate::query::GetArtistAlbumsQuery;
459
460    #[tokio::test]
461    async fn test_get_artist_albums_query() {
462        parse_test!(
463            // Radiohead's albums.
464            "./test_json/browse_artist_albums.json",
465            "./test_json/browse_artist_albums_output.txt",
466            GetArtistAlbumsQuery::new(ArtistChannelID::from_raw(""), BrowseParams::from_raw("")),
467            BrowserToken
468        );
469    }
470
471    // Old as of https://github.com/nick42d/youtui/issues/211
472    #[tokio::test]
473    async fn test_get_artist_old_1() {
474        parse_test!(
475            "./test_json/get_artist_20240705.json",
476            "./test_json/get_artist_20240705_output.txt",
477            crate::query::GetArtistQuery::new(ArtistChannelID::from_raw("")),
478            BrowserToken
479        );
480    }
481
482    #[tokio::test]
483    async fn test_get_artist() {
484        parse_test!(
485            "./test_json/get_artist_20250310.json",
486            "./test_json/get_artist_20250310_output.txt",
487            crate::query::GetArtistQuery::new(ArtistChannelID::from_raw("")),
488            BrowserToken
489        );
490    }
491    #[tokio::test]
492    async fn test_subscribe_artists() {
493        parse_test_value!(
494            "./test_json/subscribe_artist_20250704.json",
495            (),
496            crate::query::SubscribeArtistQuery::new(ArtistChannelID::from_raw("")),
497            BrowserToken
498        );
499    }
500    #[tokio::test]
501    async fn test_unsubscribe_artists() {
502        parse_test_value!(
503            "./test_json/unsubscribe_artists_20250704.json",
504            (),
505            crate::query::UnsubscribeArtistsQuery::new([]),
506            BrowserToken
507        );
508    }
509}