ytmapi_rs/parse/
album.rs

1use super::{
2    parse_flex_column_item, parse_library_management_items_from_menu, parse_song_artist, ParseFrom,
3    ParsedSongArtist, ProcessedResult,
4};
5use crate::common::{AlbumType, Explicit, LibraryManager, LibraryStatus, LikeStatus, VideoID};
6use crate::common::{PlaylistID, Thumbnail};
7use crate::nav_consts::*;
8use crate::process::fixed_column_item_pointer;
9use crate::query::*;
10use crate::Result;
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 description: Option<String>,
49    pub artists: Vec<ParsedSongArtist>,
50    pub year: String,
51    pub track_count_text: Option<String>,
52    pub duration: String,
53    pub audio_playlist_id: Option<PlaylistID<'static>>,
54    // TODO: better interface
55    pub tracks: Vec<AlbumSong>,
56    pub library_status: LibraryStatus,
57}
58
59impl<'a> ParseFrom<GetAlbumQuery<'a>> for GetAlbum {
60    fn parse_from(p: ProcessedResult<GetAlbumQuery<'a>>) -> crate::Result<Self> {
61        parse_album_query(p)
62    }
63}
64
65fn parse_album_track(json: &mut JsonCrawlerBorrowed) -> Result<Option<AlbumSong>> {
66    let mut data = json.borrow_pointer(MRLIR)?;
67    // A playlist item could be greyed out, and in this case we'll ignore the song
68    // from the list of tracks.
69    if let Ok("MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT") = data
70        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
71        .as_deref()
72    {
73        return Ok(None);
74    }
75    let title = super::parse_flex_column_item(&mut data, 0, 0)?;
76    let library_management =
77        parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
78    let video_id = data.take_value_pointer(concatcp!(
79        PLAY_BUTTON,
80        "/playNavigationEndpoint",
81        WATCH_VIDEO_ID
82    ))?;
83    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
84    let duration = data
85        .borrow_pointer(fixed_column_item_pointer(0))
86        .and_then(|mut i| {
87            i.take_value_pointer("/text/simpleText")
88                .or_else(|_| i.take_value_pointer("/text/runs/0/text"))
89        })?;
90    let plays = parse_flex_column_item(&mut data, 2, 0)?;
91    let track_no = data
92        .borrow_pointer(concatcp!("/index", RUN_TEXT))?
93        .take_and_parse_str()?;
94    let explicit = if data.path_exists(BADGE_LABEL) {
95        Explicit::IsExplicit
96    } else {
97        Explicit::NotExplicit
98    };
99    Ok(Some(AlbumSong {
100        video_id,
101        track_no,
102        duration,
103        plays,
104        library_management,
105        title,
106        like_status,
107        explicit,
108    }))
109}
110
111// NOTE: Similar code to get_playlist_2024
112fn parse_album_query(p: ProcessedResult<GetAlbumQuery>) -> Result<GetAlbum> {
113    let json_crawler = JsonCrawlerOwned::from(p);
114    let mut columns = json_crawler.navigate_pointer(TWO_COLUMN)?;
115    let mut header =
116        columns.borrow_pointer(concatcp!(TAB_CONTENT, SECTION_LIST_ITEM, RESPONSIVE_HEADER))?;
117    let title = header.take_value_pointer(TITLE_TEXT)?;
118    let category = header.take_value_pointer(SUBTITLE)?;
119    let year = header.take_value_pointer(SUBTITLE2)?;
120    let artists = header
121        .borrow_pointer("/straplineTextOne/runs")?
122        .try_into_iter()?
123        .step_by(2)
124        .map(|mut item| parse_song_artist(&mut item))
125        .collect::<Result<Vec<ParsedSongArtist>>>()?;
126    let description = header
127        .borrow_pointer(DESCRIPTION_SHELF_RUNS)
128        .and_then(|d| d.try_into_iter())
129        .ok()
130        .map(|r| {
131            r.map(|mut r| r.take_value_pointer::<String>("/text"))
132                .collect::<CrawlerResult<String>>()
133        })
134        .transpose()?;
135    // Thumbnails may not be present, refer to https://github.com/nick42d/youtui/issues/144
136    let thumbnails: Vec<Thumbnail> = header
137        .take_value_pointer(STRAPLINE_THUMBNAIL)
138        .unwrap_or_default();
139    let duration = header.take_value_pointer("/secondSubtitle/runs/2/text")?;
140    let track_count_text = header.take_value_pointer("/secondSubtitle/runs/0/text")?;
141    let mut buttons = header.borrow_pointer("/buttons")?;
142    // NOTE: Google is conducting an A/B rollout of renaming playlistId to
143    // watchPlaylistId, so we will try both. https://github.com/nick42d/youtui/issues/205
144    let audio_playlist_id = buttons
145        .try_iter_mut()?
146        .find_path("/musicPlayButtonRenderer")?
147        .take_value_pointers(&[
148            "/playNavigationEndpoint/watchEndpoint/playlistId",
149            "/playNavigationEndpoint/watchPlaylistEndpoint/playlistId",
150        ])?;
151    let library_status = buttons
152        .try_iter_mut()?
153        .find_path("/toggleButtonRenderer")?
154        .take_value_pointer("/defaultIcon/iconType")?;
155    let tracks = columns
156        .borrow_pointer(
157            "/secondaryContents/sectionListRenderer/contents/0/musicShelfRenderer/contents",
158        )?
159        .try_into_iter()?
160        .filter_map(|mut track| parse_album_track(&mut track).transpose())
161        .collect::<Result<Vec<AlbumSong>>>()?;
162    Ok(GetAlbum {
163        library_status,
164        title,
165        description,
166        thumbnails,
167        duration,
168        category,
169        track_count_text,
170        audio_playlist_id,
171        year,
172        tracks,
173        artists,
174    })
175}
176
177#[cfg(test)]
178mod tests {
179    use crate::{
180        auth::BrowserToken,
181        common::{AlbumID, YoutubeID},
182        parse::album::GetAlbumQuery,
183    };
184
185    #[tokio::test]
186    async fn test_get_album_query() {
187        parse_test!(
188            "./test_json/get_album_20240724.json",
189            "./test_json/get_album_20240724_output.txt",
190            GetAlbumQuery::new(AlbumID::from_raw("")),
191            BrowserToken
192        );
193    }
194    #[tokio::test]
195    async fn test_get_album_query_no_artist_thumbnail() {
196        parse_test!(
197            "./test_json/get_album_various_artists_no_thumbnail_20240818.json",
198            "./test_json/get_album_various_artists_no_thumbnail_20240818_output.txt",
199            GetAlbumQuery::new(AlbumID::from_raw("")),
200            BrowserToken
201        );
202    }
203}