ytmapi_rs/parse/
library.rs

1use super::{
2    parse_flex_column_item, parse_library_management_items_from_menu, ParseFrom, ProcessedResult,
3    SearchResultAlbum, TableListSong, BADGE_LABEL, CONTINUATION_PARAMS, GRID_CONTINUATION,
4    MENU_LIKE_STATUS, MUSIC_SHELF_CONTINUATION, SUBTITLE, SUBTITLE2, SUBTITLE3,
5    SUBTITLE_BADGE_LABEL, THUMBNAILS,
6};
7use crate::common::{
8    ApiOutcome, ArtistChannelID, ContinuationParams, Explicit, PlaylistID, Thumbnail,
9};
10use crate::continuations::Continuable;
11use crate::nav_consts::{
12    GRID, ITEM_SECTION, MENU_ITEMS, MRLIR, MTRIR, MUSIC_SHELF, NAVIGATION_BROWSE_ID,
13    NAVIGATION_PLAYLIST_ID, PLAY_BUTTON, SECTION_LIST, SECTION_LIST_ITEM, SINGLE_COLUMN_TAB,
14    THUMBNAIL_RENDERER, TITLE, TITLE_TEXT, WATCH_VIDEO_ID,
15};
16use crate::process::fixed_column_item_pointer;
17use crate::query::{
18    EditSongLibraryStatusQuery, GetContinuationsQuery, GetLibraryAlbumsQuery,
19    GetLibraryArtistSubscriptionsQuery, GetLibraryArtistsQuery, GetLibraryPlaylistsQuery,
20    GetLibrarySongsQuery,
21};
22use crate::Result;
23use const_format::concatcp;
24use json_crawler::{CrawlerResult, JsonCrawler, JsonCrawlerBorrowed, JsonCrawlerOwned};
25use serde::{Deserialize, Serialize};
26
27#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
28#[non_exhaustive]
29// Very similar to LibraryArtist struct
30pub struct GetLibraryArtistSubscription {
31    pub name: String,
32    pub subscribers: String,
33    pub channel_id: ArtistChannelID<'static>,
34    pub thumbnails: Vec<Thumbnail>,
35}
36
37#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
38// Very similar to LibraryArtist struct
39// Intentionally not marked non_exhaustive - not expected to change.
40pub struct GetLibrarySongs {
41    pub songs: Vec<TableListSong>,
42    pub continuation_params: Option<ContinuationParams<'static>>,
43}
44
45#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
46// Intentionally not marked non_exhaustive - not expected to change.
47pub struct GetLibraryArtistSubscriptions {
48    pub subscriptions: Vec<LibraryArtistSubscription>,
49    pub continuation_params: Option<ContinuationParams<'static>>,
50}
51
52#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
53// Intentionally not marked non_exhaustive - not expected to change.
54pub struct GetLibraryPlaylists {
55    pub playlists: Vec<LibraryPlaylist>,
56    pub continuation_params: Option<ContinuationParams<'static>>,
57}
58
59#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
60// Intentionally not marked non_exhaustive - not expected to change.
61pub struct GetLibraryArtists {
62    pub artists: Vec<LibraryArtist>,
63    pub continuation_params: Option<ContinuationParams<'static>>,
64}
65
66#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
67// Intentionally not marked non_exhaustive - not expected to change.
68pub struct GetLibraryAlbums {
69    pub albums: Vec<SearchResultAlbum>,
70    pub continuation_params: Option<ContinuationParams<'static>>,
71}
72
73#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
74#[non_exhaustive]
75// Very similar to LibraryArtist struct
76pub struct LibraryArtistSubscription {
77    pub name: String,
78    pub subscribers: String,
79    pub channel_id: ArtistChannelID<'static>,
80    pub thumbnails: Vec<Thumbnail>,
81}
82
83#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
84#[non_exhaustive]
85pub struct LibraryPlaylist {
86    pub playlist_id: PlaylistID<'static>,
87    pub title: String,
88    pub thumbnails: Vec<Thumbnail>,
89    pub count: Option<usize>,
90    pub description: Option<String>,
91    pub author: Option<String>,
92}
93#[derive(PartialEq, Debug, Clone, Deserialize, Serialize)]
94#[non_exhaustive]
95pub struct LibraryArtist {
96    pub channel_id: ArtistChannelID<'static>,
97    pub artist: String,
98    pub byline: String, // e.g 16 songs or 17.8k subscribers
99}
100
101impl ParseFrom<GetLibraryArtistSubscriptionsQuery> for GetLibraryArtistSubscriptions {
102    fn parse_from(p: ProcessedResult<GetLibraryArtistSubscriptionsQuery>) -> Result<Self> {
103        let json_crawler: JsonCrawlerOwned = p.into();
104        let music_shelf = json_crawler.navigate_pointer(concatcp!(
105            SINGLE_COLUMN_TAB,
106            SECTION_LIST_ITEM,
107            MUSIC_SHELF,
108        ))?;
109        parse_library_artist_subscriptions(music_shelf)
110    }
111}
112impl Continuable<GetLibraryArtistSubscriptionsQuery> for GetLibraryArtistSubscriptions {
113    fn take_continuation_params(&mut self) -> Option<ContinuationParams<'static>> {
114        self.continuation_params.take()
115    }
116    fn parse_continuation(
117        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryArtistSubscriptionsQuery>>,
118    ) -> Result<Self> {
119        let json_crawler: JsonCrawlerOwned = p.into();
120        let music_shelf = json_crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
121        parse_library_artist_subscriptions(music_shelf)
122    }
123}
124
125impl ParseFrom<GetLibraryAlbumsQuery> for GetLibraryAlbums {
126    fn parse_from(p: ProcessedResult<GetLibraryAlbumsQuery>) -> Result<Self> {
127        let json_crawler: JsonCrawlerOwned = p.into();
128        let grid_renderer =
129            json_crawler.navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST_ITEM, GRID))?;
130        parse_library_albums(grid_renderer)
131    }
132}
133impl Continuable<GetLibraryAlbumsQuery> for GetLibraryAlbums {
134    fn take_continuation_params(&mut self) -> Option<ContinuationParams<'static>> {
135        self.continuation_params.take()
136    }
137    fn parse_continuation(
138        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryAlbumsQuery>>,
139    ) -> Result<Self> {
140        let json_crawler: JsonCrawlerOwned = p.into();
141        let grid_items = json_crawler.navigate_pointer(GRID_CONTINUATION)?;
142        parse_library_albums(grid_items)
143    }
144}
145
146impl ParseFrom<GetLibrarySongsQuery> for GetLibrarySongs {
147    fn parse_from(p: ProcessedResult<GetLibrarySongsQuery>) -> Result<Self> {
148        let json_crawler: JsonCrawlerOwned = p.into();
149        let music_shelf = json_crawler.navigate_pointer(concatcp!(
150            SINGLE_COLUMN_TAB,
151            SECTION_LIST_ITEM,
152            MUSIC_SHELF,
153        ))?;
154        parse_library_songs(music_shelf)
155    }
156}
157
158impl Continuable<GetLibrarySongsQuery> for GetLibrarySongs {
159    fn take_continuation_params(&mut self) -> Option<ContinuationParams<'static>> {
160        self.continuation_params.take()
161    }
162    fn parse_continuation(
163        p: ProcessedResult<GetContinuationsQuery<GetLibrarySongsQuery>>,
164    ) -> Result<Self> {
165        let json_crawler: JsonCrawlerOwned = p.into();
166        let music_shelf = json_crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
167        parse_library_songs(music_shelf)
168    }
169}
170
171impl ParseFrom<GetLibraryArtistsQuery> for GetLibraryArtists {
172    fn parse_from(p: ProcessedResult<GetLibraryArtistsQuery>) -> Result<Self> {
173        let json_crawler = p.into();
174        let maybe_music_shelf = process_library_contents_music_shelf(json_crawler);
175        if let Some(music_shelf) = maybe_music_shelf {
176            parse_content_list_artists(music_shelf)
177        } else {
178            Ok(GetLibraryArtists {
179                artists: Vec::new(),
180                continuation_params: None,
181            })
182        }
183    }
184}
185impl Continuable<GetLibraryArtistsQuery> for GetLibraryArtists {
186    fn take_continuation_params(&mut self) -> Option<ContinuationParams<'static>> {
187        self.continuation_params.take()
188    }
189    fn parse_continuation(
190        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryArtistsQuery>>,
191    ) -> Result<Self> {
192        let json_crawler = JsonCrawlerOwned::from(p);
193        let music_shelf = json_crawler.navigate_pointer(MUSIC_SHELF_CONTINUATION)?;
194        parse_content_list_artists(music_shelf)
195    }
196}
197
198impl ParseFrom<GetLibraryPlaylistsQuery> for GetLibraryPlaylists {
199    fn parse_from(p: ProcessedResult<GetLibraryPlaylistsQuery>) -> Result<Self> {
200        // TODO: Implement count and author fields
201        let json_crawler = p.into();
202        let maybe_grid_renderer = process_library_contents_grid(json_crawler);
203        if let Some(grid_renderer) = maybe_grid_renderer {
204            parse_library_playlists(grid_renderer)
205        } else {
206            Ok(GetLibraryPlaylists {
207                playlists: vec![],
208                continuation_params: None,
209            })
210        }
211    }
212}
213impl Continuable<GetLibraryPlaylistsQuery> for GetLibraryPlaylists {
214    fn take_continuation_params(&mut self) -> Option<ContinuationParams<'static>> {
215        self.continuation_params.take()
216    }
217    fn parse_continuation(
218        p: ProcessedResult<GetContinuationsQuery<'_, GetLibraryPlaylistsQuery>>,
219    ) -> Result<Self> {
220        let json_crawler: JsonCrawlerOwned = p.into();
221        let grid_renderer = json_crawler.navigate_pointer(GRID_CONTINUATION)?;
222        parse_library_playlists(grid_renderer)
223    }
224}
225
226impl ParseFrom<EditSongLibraryStatusQuery<'_>> for Vec<ApiOutcome> {
227    fn parse_from(p: super::ProcessedResult<EditSongLibraryStatusQuery>) -> Result<Self> {
228        let json_crawler = JsonCrawlerOwned::from(p);
229        json_crawler
230            .navigate_pointer("/feedbackResponses")?
231            .try_into_iter()?
232            .map(|mut response| {
233                response
234                    .take_value_pointer::<bool>("/isProcessed")
235                    .map(|p| {
236                        if p {
237                            return ApiOutcome::Success;
238                        }
239                        ApiOutcome::Failure
240                    })
241            })
242            .rev()
243            .collect::<CrawlerResult<_>>()
244            .map_err(Into::into)
245    }
246}
247
248fn parse_library_albums(mut grid_renderer: JsonCrawlerOwned) -> Result<GetLibraryAlbums> {
249    let continuation_params = grid_renderer.take_value_pointer(CONTINUATION_PARAMS).ok();
250    let songs = grid_renderer
251        .navigate_pointer("/items")?
252        .try_into_iter()?
253        .map(parse_item_list_album)
254        .collect::<Result<_>>()?;
255    Ok(GetLibraryAlbums {
256        albums: songs,
257        continuation_params,
258    })
259}
260fn parse_library_songs(
261    mut music_shelf: JsonCrawlerOwned,
262) -> std::prelude::v1::Result<GetLibrarySongs, crate::Error> {
263    let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
264    let songs = music_shelf
265        .navigate_pointer("/contents")?
266        .try_into_iter()?
267        .map(|mut item| {
268            let Ok(mut data) = item.borrow_pointer(MRLIR) else {
269                return Ok(None);
270            };
271            let title = super::parse_flex_column_item(&mut data, 0, 0)?;
272            if title == "Shuffle all" {
273                return Ok(None);
274            }
275            Ok(Some(parse_table_list_song(title, data)?))
276        })
277        .filter_map(Result::transpose)
278        .collect::<Result<_>>()?;
279    Ok(GetLibrarySongs {
280        songs,
281        continuation_params,
282    })
283}
284fn parse_library_artist_subscriptions(
285    mut music_shelf: JsonCrawlerOwned,
286) -> Result<GetLibraryArtistSubscriptions> {
287    let continuation_params = music_shelf.take_value_pointer(CONTINUATION_PARAMS).ok();
288    let subscriptions = music_shelf
289        .navigate_pointer("/contents")?
290        .try_into_iter()?
291        .map(parse_content_list_artist_subscription)
292        .collect::<Result<_>>()?;
293    Ok(GetLibraryArtistSubscriptions {
294        subscriptions,
295        continuation_params,
296    })
297}
298
299fn parse_library_playlists(mut grid_renderer: JsonCrawlerOwned) -> Result<GetLibraryPlaylists> {
300    let continuation_params = grid_renderer.take_value_pointer(CONTINUATION_PARAMS).ok();
301    let playlists = grid_renderer
302        .navigate_pointer("/items")?
303        .try_into_iter()?
304        // First result is just a link to create a new playlist.
305        .skip(1)
306        .map(parse_content_list_playlist)
307        .collect::<Result<_>>()?;
308    Ok(GetLibraryPlaylists {
309        playlists,
310        continuation_params,
311    })
312}
313
314// Consider returning ProcessedLibraryContents
315// TODO: Move to process
316fn process_library_contents_grid(mut json_crawler: JsonCrawlerOwned) -> Option<JsonCrawlerOwned> {
317    let section = json_crawler.borrow_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST));
318    // Assume empty library in this case.
319    if let Ok(section) = section {
320        if section.path_exists("/itemSectionRenderer") {
321            json_crawler
322                .navigate_pointer(concatcp!(ITEM_SECTION, GRID))
323                .ok()
324        } else {
325            json_crawler
326                .navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST_ITEM, GRID))
327                .ok()
328        }
329    } else {
330        None
331    }
332}
333// Consider returning ProcessedLibraryContents
334// TODO: Move to process
335fn process_library_contents_music_shelf(
336    mut json_crawler: JsonCrawlerOwned,
337) -> Option<JsonCrawlerOwned> {
338    let section = json_crawler.borrow_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST));
339    // Assume empty library in this case.
340    if let Ok(section) = section {
341        if section.path_exists("itemSectionRenderer") {
342            json_crawler
343                .navigate_pointer(concatcp!(ITEM_SECTION, MUSIC_SHELF))
344                .ok()
345        } else {
346            json_crawler
347                .navigate_pointer(concatcp!(SINGLE_COLUMN_TAB, SECTION_LIST_ITEM, MUSIC_SHELF))
348                .ok()
349        }
350    } else {
351        None
352    }
353}
354
355fn parse_item_list_album(mut json_crawler: JsonCrawlerOwned) -> Result<SearchResultAlbum> {
356    let mut data = json_crawler.borrow_pointer("/musicTwoRowItemRenderer")?;
357    let browse_id = data.take_value_pointer(NAVIGATION_BROWSE_ID)?;
358    let thumbnails = data.take_value_pointer(THUMBNAIL_RENDERER)?;
359    let title = data.take_value_pointer(TITLE_TEXT)?;
360    let artist = data.take_value_pointer(SUBTITLE2)?;
361    let year = data.take_value_pointer(SUBTITLE3)?;
362    let album_type = data.take_value_pointer(SUBTITLE)?;
363    let explicit = if data.path_exists(SUBTITLE_BADGE_LABEL) {
364        Explicit::IsExplicit
365    } else {
366        Explicit::NotExplicit
367    };
368    Ok(SearchResultAlbum {
369        title,
370        artist,
371        year,
372        explicit,
373        album_id: browse_id,
374        album_type,
375        thumbnails,
376    })
377}
378
379fn parse_content_list_artist_subscription(
380    mut json_crawler: JsonCrawlerOwned,
381) -> Result<LibraryArtistSubscription> {
382    let mut data = json_crawler.borrow_pointer(MRLIR)?;
383    let channel_id = data.take_value_pointer(NAVIGATION_BROWSE_ID)?;
384    let name = parse_flex_column_item(&mut data, 0, 0)?;
385    let subscribers = parse_flex_column_item(&mut data, 1, 0)?;
386    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
387    Ok(LibraryArtistSubscription {
388        name,
389        subscribers,
390        channel_id,
391        thumbnails,
392    })
393}
394
395fn parse_content_list_artists(mut json_crawler: JsonCrawlerOwned) -> Result<GetLibraryArtists> {
396    let continuation_params = json_crawler.take_value_pointer(CONTINUATION_PARAMS).ok();
397    let songs = json_crawler
398        .navigate_pointer("/contents")?
399        .try_iter_mut()?
400        .map(|item| {
401            let mut data = item.navigate_pointer(MRLIR)?;
402            let channel_id = data.take_value_pointer(NAVIGATION_BROWSE_ID)?;
403            let artist = parse_flex_column_item(&mut data, 0, 0)?;
404            let byline = parse_flex_column_item(&mut data, 1, 0)?;
405            Ok(LibraryArtist {
406                channel_id,
407                artist,
408                byline,
409            })
410        })
411        .collect::<Result<_>>()?;
412    Ok(GetLibraryArtists {
413        artists: songs,
414        continuation_params,
415    })
416}
417
418fn parse_table_list_song(title: String, mut data: JsonCrawlerBorrowed) -> Result<TableListSong> {
419    let video_id = data.take_value_pointer(concatcp!(
420        PLAY_BUTTON,
421        "/playNavigationEndpoint",
422        WATCH_VIDEO_ID
423    ))?;
424    let library_management =
425        parse_library_management_items_from_menu(data.borrow_pointer(MENU_ITEMS)?)?;
426    let like_status = data.take_value_pointer(MENU_LIKE_STATUS)?;
427    let artists = super::parse_song_artists(&mut data, 1)?;
428    let album = super::parse_song_album(&mut data, 2)?;
429    let duration = data
430        .borrow_pointer(fixed_column_item_pointer(0))?
431        .take_value_pointers(&["/text/simpleText", "/text/runs/0/text"])?;
432    let thumbnails = data.take_value_pointer(THUMBNAILS)?;
433    let is_available = data
434        .take_value_pointer::<String>("/musicItemRendererDisplayPolicy")
435        .map(|m| m != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT")
436        .unwrap_or(true);
437
438    let explicit = if data.path_exists(BADGE_LABEL) {
439        Explicit::IsExplicit
440    } else {
441        Explicit::NotExplicit
442    };
443    let playlist_id = data.take_value_pointer(concatcp!(
444        MENU_ITEMS,
445        "/0/menuNavigationItemRenderer",
446        NAVIGATION_PLAYLIST_ID
447    ))?;
448    Ok(TableListSong {
449        video_id,
450        duration,
451        library_management,
452        title,
453        artists,
454        like_status,
455        thumbnails,
456        explicit,
457        album,
458        playlist_id,
459        is_available,
460    })
461}
462
463fn parse_content_list_playlist(item: JsonCrawlerOwned) -> Result<LibraryPlaylist> {
464    // TODO: Implement count and author fields
465    let mut mtrir = item.navigate_pointer(MTRIR)?;
466    let title = mtrir.take_value_pointer(TITLE_TEXT)?;
467    let playlist_id: PlaylistID = mtrir
468        .borrow_pointer(concatcp!(TITLE, NAVIGATION_BROWSE_ID))?
469        // ytmusicapi uses range index [2:] here but doesn't seem to be required.
470        // Revisit later if we crash.
471        .take_value()?;
472    let thumbnails: Vec<Thumbnail> = mtrir.take_value_pointer(THUMBNAIL_RENDERER)?;
473    let mut description = None;
474    let count = None;
475    let author = None;
476    if let Ok(mut subtitle) = mtrir.borrow_pointer("/subtitle") {
477        let runs = subtitle.borrow_pointer("/runs")?.try_into_iter()?;
478        // Extract description from runs.
479        // Collect the iterator of Result<String> into a single Result<String>
480        description = Some(
481            runs.map(|mut c| c.take_value_pointer::<String>("/text"))
482                .collect::<std::result::Result<String, _>>()?,
483        );
484    }
485    Ok(LibraryPlaylist {
486        description,
487        author,
488        playlist_id,
489        title,
490        thumbnails,
491        count,
492    })
493}
494
495#[cfg(test)]
496mod tests {
497    use crate::auth::BrowserToken;
498
499    // Consider if the parse function itself should be removed from impl.
500    #[tokio::test]
501    async fn test_library_playlists_dummy_json() {
502        parse_test!(
503            "./test_json/get_library_playlists.json",
504            "./test_json/get_library_playlists_output.txt",
505            crate::query::GetLibraryPlaylistsQuery,
506            BrowserToken
507        );
508    }
509    #[tokio::test]
510    async fn test_get_library_playlists_continuation() {
511        parse_continuations_test!(
512            "./test_json/get_library_playlists_continuation_mock.json",
513            "./test_json/get_library_playlists_output.txt",
514            crate::query::GetLibraryPlaylistsQuery,
515            BrowserToken
516        );
517    }
518    #[tokio::test]
519    async fn test_library_artists_dummy_json() {
520        parse_test!(
521            "./test_json/get_library_artists.json",
522            "./test_json/get_library_artists_output.txt",
523            crate::query::GetLibraryArtistsQuery::default(),
524            BrowserToken
525        );
526    }
527    #[tokio::test]
528    async fn test_get_library_artists_continuation() {
529        parse_continuations_test!(
530            "./test_json/get_library_artists_continuation_mock.json",
531            "./test_json/get_library_artists_output.txt",
532            crate::query::GetLibraryArtistsQuery::default(),
533            BrowserToken
534        );
535    }
536    #[tokio::test]
537    async fn test_get_library_albums() {
538        parse_test!(
539            "./test_json/get_library_albums_20240701.json",
540            "./test_json/get_library_albums_20240701_output.txt",
541            crate::query::GetLibraryAlbumsQuery::default(),
542            BrowserToken
543        );
544    }
545    #[tokio::test]
546    async fn test_get_library_albums_continuation() {
547        parse_continuations_test!(
548            "./test_json/get_library_albums_continuation_mock.json",
549            "./test_json/get_library_albums_20240701_output.txt",
550            crate::query::GetLibraryAlbumsQuery::default(),
551            BrowserToken
552        );
553    }
554    #[tokio::test]
555    async fn test_get_library_songs() {
556        parse_test!(
557            "./test_json/get_library_songs_20240701.json",
558            "./test_json/get_library_songs_20240701_output.txt",
559            crate::query::GetLibrarySongsQuery::default(),
560            BrowserToken
561        );
562    }
563    #[tokio::test]
564    async fn test_get_library_songs_continuation() {
565        parse_continuations_test!(
566            "./test_json/get_library_songs_continuation_20240910.json",
567            "./test_json/get_library_songs_continuation_20240910_output.txt",
568            crate::query::GetLibrarySongsQuery::default(),
569            BrowserToken
570        );
571    }
572    #[tokio::test]
573    async fn test_get_library_artist_subscriptions() {
574        parse_test!(
575            "./test_json/get_library_artist_subscriptions_20240701.json",
576            "./test_json/get_library_artist_subscriptions_20240701_output.txt",
577            crate::query::GetLibraryArtistSubscriptionsQuery::default(),
578            BrowserToken
579        );
580    }
581    #[tokio::test]
582    async fn test_get_library_artist_subscriptions_continuation() {
583        parse_continuations_test!(
584            "./test_json/get_library_artist_subscriptions_continuation_mock.json",
585            "./test_json/get_library_artist_subscriptions_20240701_output.txt",
586            crate::query::GetLibraryArtistSubscriptionsQuery::default(),
587            BrowserToken
588        );
589    }
590    #[tokio::test]
591    async fn test_edit_song_library_status() {
592        // Note - same files as remove_histry_items
593        parse_test!(
594            "./test_json/remove_history_items_20240704.json",
595            "./test_json/remove_history_items_20240704_output.txt",
596            crate::query::EditSongLibraryStatusQuery::new_from_add_to_library_feedback_tokens(
597                Vec::new()
598            )
599            .with_remove_from_library_feedback_tokens(vec![]),
600            BrowserToken
601        );
602    }
603}