Skip to main content

ytmapi_rs/parse/
album.rs

1use super::{
2    ParseFrom, ParsedSongArtist, ProcessedResult, fixed_column_item_pointer,
3    parse_flex_column_item, parse_library_management_items_from_menu, parse_song_artist,
4};
5use crate::Result;
6use crate::common::{
7    AlbumType, Explicit, LibraryManager, LibraryStatus, LikeStatus, PlaylistID, Thumbnail, VideoID,
8};
9use crate::nav_consts::*;
10use crate::query::*;
11use const_format::concatcp;
12use json_crawler::{
13    CrawlerResult, JsonCrawler, JsonCrawlerBorrowed, JsonCrawlerIterator, JsonCrawlerOwned,
14};
15use serde::{Deserialize, Serialize};
16
17/// In some contexts, dislike will also be classified as indifferent.
18#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
19pub enum InLikedSongs {
20    Liked,
21    Indifferent,
22}
23
24#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
25#[non_exhaustive]
26pub struct AlbumSong {
27    pub video_id: VideoID<'static>,
28    pub track_no: usize,
29    pub duration: String,
30    pub plays: String,
31    /// Library management fields are optional; if a album has already been
32    /// added to your library, you cannot add the individual songs.
33    // https://github.com/nick42d/youtui/issues/138
34    pub library_management: Option<LibraryManager>,
35    pub title: String,
36    pub like_status: LikeStatus,
37    pub explicit: Explicit,
38}
39
40// Is this similar to another struct?
41// XXX: Consider correct privacy
42#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
43#[non_exhaustive]
44pub struct GetAlbum {
45    pub title: String,
46    pub category: AlbumType,
47    pub thumbnails: Vec<Thumbnail>,
48    pub artist_thumbnails: Vec<Thumbnail>,
49    pub description: Option<String>,
50    pub artists: Vec<ParsedSongArtist>,
51    pub year: String,
52    pub track_count_text: Option<String>,
53    pub duration: String,
54    pub audio_playlist_id: Option<PlaylistID<'static>>,
55    // TODO: better interface
56    pub tracks: Vec<AlbumSong>,
57    pub library_status: LibraryStatus,
58}
59
60impl<'a> ParseFrom<GetAlbumQuery<'a>> for GetAlbum {
61    fn parse_from(p: ProcessedResult<GetAlbumQuery<'a>>) -> crate::Result<Self> {
62        parse_album_query(p)
63    }
64}
65
66fn parse_album_track(json: &mut JsonCrawlerBorrowed) -> Result<Option<AlbumSong>> {
67    let mut data = json.borrow_pointer(MRLIR)?;
68    // A playlist item could be greyed out, and in this case we'll ignore the song
69    // from the list of tracks.
70    if let Ok("MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT") = data
71        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
72        .as_deref()
73    {
74        return Ok(None);
75    }
76    let title = super::parse_flex_column_item(&mut data, 0, 0)?;
77    let library_management =
78        parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
79    let video_id = data.take_value_pointer(concatcp!(
80        PLAY_BUTTON,
81        "/playNavigationEndpoint",
82        WATCH_VIDEO_ID
83    ))?;
84    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
85    let duration = data
86        .borrow_pointer(fixed_column_item_pointer(0))
87        .and_then(|mut i| {
88            i.take_value_pointer("/text/simpleText")
89                .or_else(|_| i.take_value_pointer("/text/runs/0/text"))
90        })?;
91    let plays = parse_flex_column_item(&mut data, 2, 0)?;
92    let track_no = data
93        .borrow_pointer(concatcp!("/index", RUN_TEXT))?
94        .take_and_parse_str()?;
95    let explicit = if data.path_exists(BADGE_LABEL) {
96        Explicit::IsExplicit
97    } else {
98        Explicit::NotExplicit
99    };
100    Ok(Some(AlbumSong {
101        video_id,
102        track_no,
103        duration,
104        plays,
105        library_management,
106        title,
107        like_status,
108        explicit,
109    }))
110}
111
112// NOTE: Similar code to get_playlist_2024
113fn parse_album_query(p: ProcessedResult<GetAlbumQuery>) -> Result<GetAlbum> {
114    let json_crawler = JsonCrawlerOwned::from(p);
115    let mut columns = json_crawler.navigate_pointer(TWO_COLUMN)?;
116    let mut header =
117        columns.borrow_pointer(concatcp!(TAB_CONTENT, SECTION_LIST_ITEM, RESPONSIVE_HEADER))?;
118    let title = header.take_value_pointer(TITLE_TEXT)?;
119    let category = header.take_value_pointer(SUBTITLE)?;
120    let year = header.take_value_pointer(SUBTITLE2)?;
121    let artists = header
122        .borrow_pointer("/straplineTextOne/runs")?
123        .try_into_iter()?
124        .step_by(2)
125        .map(|mut item| parse_song_artist(&mut item))
126        .collect::<Result<Vec<ParsedSongArtist>>>()?;
127    let description = header
128        .borrow_pointer(DESCRIPTION_SHELF_RUNS)
129        .and_then(|d| d.try_into_iter())
130        .ok()
131        .map(|r| {
132            r.map(|mut r| r.take_value_pointer::<String>("/text"))
133                .collect::<CrawlerResult<String>>()
134        })
135        .transpose()?;
136    // artist thumbnails may not be present, refer to https://github.com/nick42d/youtui/issues/144
137    let artist_thumbnails = header
138        .take_value_pointer(STRAPLINE_THUMBNAIL)
139        .unwrap_or_default();
140    let thumbnails = header.take_value_pointer(THUMBNAILS)?;
141    let duration = header.take_value_pointer("/secondSubtitle/runs/2/text")?;
142    let track_count_text = header.take_value_pointer("/secondSubtitle/runs/0/text")?;
143    let mut buttons = header.borrow_pointer("/buttons")?;
144    // NOTE: Google is conducting an A/B rollout of renaming playlistId to
145    // watchPlaylistId, so we will try both. https://github.com/nick42d/youtui/issues/205
146    let audio_playlist_id = buttons
147        .try_iter_mut()?
148        .find_path("/musicPlayButtonRenderer")?
149        .take_value_pointers(&[
150            "/playNavigationEndpoint/watchEndpoint/playlistId",
151            "/playNavigationEndpoint/watchPlaylistEndpoint/playlistId",
152        ])?;
153    let library_status = buttons
154        .try_iter_mut()?
155        .find_path("/toggleButtonRenderer")?
156        .take_value_pointer("/defaultIcon/iconType")?;
157    let tracks = columns
158        .borrow_pointer(
159            "/secondaryContents/sectionListRenderer/contents/0/musicShelfRenderer/contents",
160        )?
161        .try_into_iter()?
162        .filter_map(|mut track| parse_album_track(&mut track).transpose())
163        .collect::<Result<Vec<AlbumSong>>>()?;
164    Ok(GetAlbum {
165        library_status,
166        title,
167        description,
168        artist_thumbnails,
169        duration,
170        category,
171        track_count_text,
172        audio_playlist_id,
173        year,
174        tracks,
175        artists,
176        thumbnails,
177    })
178}
179
180#[cfg(test)]
181mod tests {
182    use crate::auth::BrowserToken;
183    use crate::auth::noauth::NoAuthToken;
184    use crate::common::{AlbumID, YoutubeID};
185    use crate::parse::album::GetAlbumQuery;
186
187    #[tokio::test]
188    async fn test_get_album_query() {
189        parse_test!(
190            "./test_json/get_album_20240724.json",
191            "./test_json/get_album_20240724_output.txt",
192            GetAlbumQuery::new(AlbumID::from_raw("")),
193            BrowserToken
194        );
195    }
196    #[tokio::test]
197    async fn test_get_album_query_no_artist_thumbnail() {
198        parse_test!(
199            "./test_json/get_album_various_artists_no_thumbnail_20240818.json",
200            "./test_json/get_album_various_artists_no_thumbnail_20240818_output.txt",
201            GetAlbumQuery::new(AlbumID::from_raw("")),
202            BrowserToken
203        );
204    }
205    #[tokio::test]
206    async fn test_get_album_query_not_signed_in() {
207        parse_test!(
208            "./test_json/get_album_not_signed_in_20250611.json",
209            "./test_json/get_album_not_signed_in_20250611_output.txt",
210            GetAlbumQuery::new(AlbumID::from_raw("")),
211            NoAuthToken
212        );
213    }
214}