diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index f08ab32..4c631f6 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -6,8 +6,8 @@ use crate::app::context_clues::{ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; use crate::models::servarr_data::lidarr::lidarr_data::{ - ADD_ARTIST_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, - EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, + ADD_ARTIST_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, + ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, }; #[cfg(test)] @@ -92,6 +92,55 @@ pub static MANUAL_ARTIST_SEARCH_CONTEXT_CLUES: [ContextClue; 7] = [ (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; +pub static ALBUM_DETAILS_CONTEXT_CLUES: [ContextClue; 7] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + ( + DEFAULT_KEYBINDINGS.toggle_monitoring, + DEFAULT_KEYBINDINGS.toggle_monitoring.desc, + ), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), + (DEFAULT_KEYBINDINGS.submit, "episode details"), + (DEFAULT_KEYBINDINGS.delete, "delete episode"), +]; + +pub static ALBUM_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), +]; + +pub static MANUAL_ALBUM_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + pub(in crate::app) struct LidarrContextClueProvider; impl ContextClueProvider for LidarrContextClueProvider { @@ -106,6 +155,14 @@ impl ContextClueProvider for LidarrContextClueProvider { .lidarr_data .artist_info_tabs .get_active_route_contextual_help(), + _ if ALBUM_DETAILS_BLOCKS.contains(&active_lidarr_block) => app + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .album_details_tabs + .get_active_route_contextual_help(), ActiveLidarrBlock::AddArtistSearchInput | ActiveLidarrBlock::AddArtistEmptySearchResults | ActiveLidarrBlock::TestAllIndexers diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 83bd55b..313f053 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -7,14 +7,16 @@ mod tests { }; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::lidarr::lidarr_context_clues::{ - ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ARTIST_DETAILS_CONTEXT_CLUES, - ARTIST_HISTORY_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, + ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ALBUM_DETAILS_CONTEXT_CLUES, + ALBUM_HISTORY_CONTEXT_CLUES, ARTIST_DETAILS_CONTEXT_CLUES, ARTIST_HISTORY_CONTEXT_CLUES, + ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, MANUAL_ALBUM_SEARCH_CONTEXT_CLUES, MANUAL_ARTIST_SEARCH_CONTEXT_CLUES, }; use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ROOT_FOLDER_BLOCKS, ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, LidarrData, }; + use crate::models::servarr_data::lidarr::modals::AlbumDetailsModal; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use rstest::rstest; @@ -236,6 +238,123 @@ mod tests { assert_none!(manual_artist_search_context_clues_iter.next()); } + #[test] + fn test_album_details_context_clues() { + let mut album_details_context_clues_iter = ALBUM_DETAILS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + album_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + album_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.toggle_monitoring, + DEFAULT_KEYBINDINGS.toggle_monitoring.desc + ) + ); + assert_some_eq_x!( + album_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + album_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + album_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_some_eq_x!( + album_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "episode details") + ); + assert_some_eq_x!( + album_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.delete, "delete episode") + ); + assert_none!(album_details_context_clues_iter.next()); + } + + #[test] + fn test_album_history_context_clues() { + let mut album_history_context_clues_iter = ALBUM_HISTORY_CONTEXT_CLUES.iter(); + assert_some_eq_x!( + album_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + album_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + album_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + album_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + album_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + album_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + album_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter/close") + ); + assert_none!(album_history_context_clues_iter.next()); + } + + #[test] + fn test_manual_album_search_context_clues() { + let mut manual_album_search_context_clues_iter = MANUAL_ALBUM_SEARCH_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + manual_album_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + manual_album_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + manual_album_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + manual_album_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + manual_album_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(manual_album_search_context_clues_iter.next()); + } + #[rstest] #[case(0, ActiveLidarrBlock::ArtistDetails, &ARTIST_DETAILS_CONTEXT_CLUES)] #[case(1, ActiveLidarrBlock::ArtistHistory, &ARTIST_HISTORY_CONTEXT_CLUES)] @@ -255,6 +374,30 @@ mod tests { assert_some_eq_x!(context_clues, expected_context_clues); } + #[rstest] + #[case(0, ActiveLidarrBlock::AlbumDetails, &ALBUM_DETAILS_CONTEXT_CLUES)] + #[case(1, ActiveLidarrBlock::AlbumHistory, &ALBUM_HISTORY_CONTEXT_CLUES)] + #[case(2, ActiveLidarrBlock::ManualAlbumSearch, &MANUAL_ALBUM_SEARCH_CONTEXT_CLUES)] + fn test_lidarr_context_clue_provider_album_details_tabs( + #[case] index: usize, + #[case] active_lidarr_block: ActiveLidarrBlock, + #[case] expected_context_clues: &[ContextClue], + ) { + let mut app = App::test_default(); + let mut album_details_modal = AlbumDetailsModal::default(); + album_details_modal.album_details_tabs.set_index(index); + let lidarr_data = LidarrData { + album_details_modal: Some(album_details_modal), + ..LidarrData::default() + }; + app.data.lidarr_data = lidarr_data; + app.push_navigation_stack(active_lidarr_block.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, expected_context_clues); + } + #[test] fn test_lidarr_context_clue_provider_artists_block() { let mut app = App::test_default(); @@ -362,7 +505,7 @@ mod tests { } #[test] - fn test_sonarr_context_clue_provider_system_tasks_clues() { + fn test_lidarr_context_clue_provider_system_tasks_clues() { let mut app = App::test_default(); app.push_navigation_stack(ActiveLidarrBlock::SystemTasks.into()); diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs index 2cd9a71..7be1432 100644 --- a/src/app/lidarr/lidarr_tests.rs +++ b/src/app/lidarr/lidarr_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::app::App; - use crate::models::lidarr_models::{Artist, LidarrRelease}; + use crate::models::lidarr_models::{Album, Artist, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_models::Indexer; use crate::network::NetworkEvent; @@ -120,6 +120,42 @@ mod tests { assert_eq!(app.tick_count, 0); } + #[tokio::test] + async fn test_dispatch_by_album_details_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.lidarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.lidarr_data.artists.set_items(vec![Artist { + id: 1, + ..Artist::default() + }]); + app.data.lidarr_data.albums.set_items(vec![Album { + id: 1, + ..Album::default() + }]); + + app + .dispatch_by_lidarr_block(&ActiveLidarrBlock::AlbumDetails) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::GetTracks(1, 1).into() + ); + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::GetTrackFiles(1).into() + ); + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::GetDownloads(500).into() + ); + assert!(!app.data.lidarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + #[tokio::test] async fn test_dispatch_by_downloads_block() { let (tx, mut rx) = mpsc::channel::(500); diff --git a/src/app/lidarr/mod.rs b/src/app/lidarr/mod.rs index 294848d..dc4b1ea 100644 --- a/src/app/lidarr/mod.rs +++ b/src/app/lidarr/mod.rs @@ -54,6 +54,19 @@ impl App<'_> { .await; } } + ActiveLidarrBlock::AlbumDetails => { + let artist_id = self.extract_artist_id().await; + let album_id = self.extract_album_id().await; + self + .dispatch_network_event(LidarrEvent::GetTracks(artist_id, album_id).into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetTrackFiles(album_id).into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetDownloads(500).into()) + .await; + } ActiveLidarrBlock::AddArtistSearchResults => { self .dispatch_network_event( @@ -134,6 +147,10 @@ impl App<'_> { self.data.lidarr_data.artists.current_selection().id } + async fn extract_album_id(&self) -> i64 { + self.data.lidarr_data.albums.current_selection().id + } + async fn extract_lidarr_indexer_id(&self) -> i64 { self.data.lidarr_data.indexers.current_selection().id } diff --git a/src/cli/lidarr/delete_command_handler.rs b/src/cli/lidarr/delete_command_handler.rs index a0bf22a..ef6db42 100644 --- a/src/cli/lidarr/delete_command_handler.rs +++ b/src/cli/lidarr/delete_command_handler.rs @@ -28,6 +28,11 @@ pub enum LidarrDeleteCommand { #[arg(long, help = "Add a list exclusion for this album")] add_list_exclusion: bool, }, + #[command(about = "Delete the specified track file from disk")] + TrackFile { + #[arg(long, help = "The ID of the track file to delete", required = true)] + track_file_id: i64, + }, #[command(about = "Delete an artist from your Lidarr library")] Artist { #[arg(long, help = "The ID of the artist to delete", required = true)] @@ -102,6 +107,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrDeleteCommand> for LidarrDeleteComm .await?; serde_json::to_string_pretty(&resp)? } + LidarrDeleteCommand::TrackFile { track_file_id } => { + let resp = self + .network + .handle_network_event(LidarrEvent::DeleteTrackFile(track_file_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrDeleteCommand::Artist { artist_id, delete_files_from_disk, diff --git a/src/cli/lidarr/delete_command_handler_tests.rs b/src/cli/lidarr/delete_command_handler_tests.rs index 02dae9c..41cb8cf 100644 --- a/src/cli/lidarr/delete_command_handler_tests.rs +++ b/src/cli/lidarr/delete_command_handler_tests.rs @@ -86,6 +86,40 @@ mod tests { assert_eq!(delete_command, expected_args); } + #[test] + fn test_delete_track_file_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "track-file"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_track_file_success() { + let expected_args = LidarrDeleteCommand::TrackFile { track_file_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "lidarr", + "delete", + "track-file", + "--track-file-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + #[test] fn test_delete_artist_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "artist"]); @@ -327,6 +361,32 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_delete_track_file_command() { + let expected_track_file_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::DeleteTrackFile(expected_track_file_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_track_file_command = LidarrDeleteCommand::TrackFile { track_file_id: 1 }; + + let result = + LidarrDeleteCommandHandler::with(&app_arc, delete_track_file_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_delete_artist_command() { let expected_delete_artist_params = DeleteParams { diff --git a/src/cli/lidarr/list_command_handler.rs b/src/cli/lidarr/list_command_handler.rs index f7cde81..1e9ac6f 100644 --- a/src/cli/lidarr/list_command_handler.rs +++ b/src/cli/lidarr/list_command_handler.rs @@ -27,6 +27,23 @@ pub enum LidarrListCommand { )] artist_id: i64, }, + #[command( + about = "Fetch all history events for the given album corresponding to the artist with the given ID." + )] + AlbumHistory { + #[arg( + long, + help = "The Lidarr artist ID of the artist whose history you wish to fetch and list", + required = true + )] + artist_id: i64, + #[arg( + long, + help = "The Lidarr album ID to fetch history events for", + required = true + )] + album_id: i64, + }, #[command(about = "Fetch all history events for the artist with the given ID")] ArtistHistory { #[arg( @@ -72,6 +89,32 @@ pub enum LidarrListCommand { Tags, #[command(about = "List all Lidarr tasks")] Tasks, + #[command( + about = "List the tracks for the album that corresponds to the artist with the given ID" + )] + Tracks { + #[arg( + long, + help = "The Lidarr artist ID of the artist whose tracks you wish to fetch", + required = true + )] + artist_id: i64, + #[arg( + long, + help = "The Lidarr album ID whose tracks you wish to fetch", + required = true + )] + album_id: i64, + }, + #[command(about = "List the track files for the album with the given ID")] + TrackFiles { + #[arg( + long, + help = "The Lidarr ID of the album whose track files you wish to fetch", + required = true + )] + album_id: i64, + }, #[command(about = "List all Lidarr updates")] Updates, } @@ -110,6 +153,16 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + LidarrListCommand::AlbumHistory { + artist_id, + album_id, + } => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetAlbumHistory(artist_id, album_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrListCommand::ArtistHistory { artist_id } => { let resp = self .network @@ -204,6 +257,23 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + LidarrListCommand::Tracks { + artist_id, + album_id, + } => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetTracks(artist_id, album_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + LidarrListCommand::TrackFiles { album_id } => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetTrackFiles(album_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrListCommand::Updates => { let resp = self .network diff --git a/src/cli/lidarr/list_command_handler_tests.rs b/src/cli/lidarr/list_command_handler_tests.rs index 5585811..76944f7 100644 --- a/src/cli/lidarr/list_command_handler_tests.rs +++ b/src/cli/lidarr/list_command_handler_tests.rs @@ -69,6 +69,58 @@ mod tests { assert_eq!(album_command, expected_args); } + #[test] + fn test_album_history_requires_artist_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "list", + "album-history", + "--album-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_album_history_requires_album_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "list", + "album-history", + "--artist-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_album_history_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "list", + "album-history", + "--artist-id", + "1", + "--album-id", + "1", + ]); + + assert_ok!(&result); + } + #[test] fn test_list_artist_history_requires_artist_id() { let result = @@ -172,6 +224,101 @@ mod tests { }; assert_eq!(logs_command, expected_args); } + + #[test] + fn test_list_tracks_requires_artist_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "list", + "tracks", + "--album-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_tracks_requires_album_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "list", + "tracks", + "--artist-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_tracks_success() { + let expected_args = LidarrListCommand::Tracks { + artist_id: 1, + album_id: 1, + }; + let result = Cli::try_parse_from([ + "managarr", + "lidarr", + "list", + "tracks", + "--artist-id", + "1", + "--album-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::List(tracks_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(tracks_command, expected_args); + } + + #[test] + fn test_list_track_files_requires_album_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "track-files"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_track_files_success() { + let expected_args = LidarrListCommand::TrackFiles { album_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "lidarr", + "list", + "track-files", + "--album-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::List(track_files_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(track_files_command, expected_args); + } } mod handler { @@ -248,6 +395,36 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_list_album_history_command() { + let expected_artist_id = 1; + let expected_album_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::GetAlbumHistory(expected_artist_id, expected_album_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_album_history_command = LidarrListCommand::AlbumHistory { + artist_id: 1, + album_id: 1, + }; + + let result = + LidarrListCommandHandler::with(&app_arc, list_album_history_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_list_artist_history_command() { let expected_artist_id = 1; @@ -353,5 +530,60 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_handle_list_tracks_command() { + let expected_artist_id = 1; + let expected_album_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::GetTracks(expected_artist_id, expected_album_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_tracks_command = LidarrListCommand::Tracks { + artist_id: 1, + album_id: 1, + }; + + let result = LidarrListCommandHandler::with(&app_arc, list_tracks_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_track_files_command() { + let expected_album_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::GetTrackFiles(expected_album_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_track_files_command = LidarrListCommand::TrackFiles { album_id: 1 }; + + let result = + LidarrListCommandHandler::with(&app_arc, list_track_files_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/cli/lidarr/manual_search_command_handler.rs b/src/cli/lidarr/manual_search_command_handler.rs index 72cae84..6e24ece 100644 --- a/src/cli/lidarr/manual_search_command_handler.rs +++ b/src/cli/lidarr/manual_search_command_handler.rs @@ -17,6 +17,19 @@ mod manual_search_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum LidarrManualSearchCommand { + #[command( + about = "Trigger a manual search of releases for the given album corresponding to the artist with the given ID" + )] + Album { + #[arg( + long, + help = "The Lidarr ID of the artist whose releases you wish to fetch and list", + required = true + )] + artist_id: i64, + #[arg(long, help = "The Lidarr album ID to search for", required = true)] + album_id: i64, + }, #[command( about = "Trigger a manual search of discography releases for the given artist corresponding to the artist with the given ID." )] @@ -59,6 +72,27 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrManualSearchCommand> async fn handle(self) -> Result { let result = match self.command { + LidarrManualSearchCommand::Album { + artist_id, + album_id, + } => { + println!("Searching for album releases. This may take a minute..."); + match self + .network + .handle_network_event(LidarrEvent::GetAlbumReleases(artist_id, album_id).into()) + .await + { + Ok(Serdeable::Lidarr(LidarrSerdeable::Releases(releases_vec))) => { + let albums_vec: Vec = releases_vec + .into_iter() + .filter(|release| !release.discography) + .collect(); + serde_json::to_string_pretty(&albums_vec)? + } + Err(e) => return Err(e), + _ => serde_json::to_string_pretty(&json!({"message": "Failed to parse response"}))?, + } + } LidarrManualSearchCommand::Discography { artist_id } => { println!("Searching for artist discography releases. This may take a minute..."); match self diff --git a/src/cli/lidarr/manual_search_command_handler_tests.rs b/src/cli/lidarr/manual_search_command_handler_tests.rs index bfa29a3..bf89049 100644 --- a/src/cli/lidarr/manual_search_command_handler_tests.rs +++ b/src/cli/lidarr/manual_search_command_handler_tests.rs @@ -23,6 +23,58 @@ mod tests { use clap::error::ErrorKind; use pretty_assertions::assert_eq; + #[test] + fn test_manual_album_search_requires_artist_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "manual-search", + "album", + "--album-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_manual_album_search_requires_album_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "manual-search", + "album", + "--artist-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_manual_album_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "manual-search", + "album", + "--artist-id", + "1", + "--album-id", + "1", + ]); + + assert_ok!(&result); + } + #[test] fn test_manual_discography_search_requires_artist_id() { let result = @@ -65,6 +117,39 @@ mod tests { use std::sync::Arc; use tokio::sync::Mutex; + #[tokio::test] + async fn test_manual_album_search_command() { + let expected_artist_id = 1; + let expected_album_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::GetAlbumReleases(expected_artist_id, expected_album_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let manual_album_search_command = LidarrManualSearchCommand::Album { + artist_id: 1, + album_id: 1, + }; + + let result = LidarrManualSearchCommandHandler::with( + &app_arc, + manual_album_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_manual_discography_search_command() { let expected_artist_id = 1; diff --git a/src/cli/lidarr/trigger_automatic_search_command_handler.rs b/src/cli/lidarr/trigger_automatic_search_command_handler.rs index ae19b82..dc882de 100644 --- a/src/cli/lidarr/trigger_automatic_search_command_handler.rs +++ b/src/cli/lidarr/trigger_automatic_search_command_handler.rs @@ -18,6 +18,15 @@ mod trigger_automatic_search_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum LidarrTriggerAutomaticSearchCommand { + #[command(about = "Trigger an automatic search for the album with the specified ID")] + Album { + #[arg( + long, + help = "The Lidarr ID of the album you want to trigger an automatic search for", + required = true + )] + album_id: i64, + }, #[command(about = "Trigger an automatic search for the artist with the specified ID")] Artist { #[arg( @@ -58,6 +67,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrTriggerAutomaticSearchCommand> async fn handle(self) -> Result { let result = match self.command { + LidarrTriggerAutomaticSearchCommand::Album { album_id } => { + let resp = self + .network + .handle_network_event(LidarrEvent::TriggerAutomaticAlbumSearch(album_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrTriggerAutomaticSearchCommand::Artist { artist_id } => { let resp = self .network diff --git a/src/cli/lidarr/trigger_automatic_search_command_handler_tests.rs b/src/cli/lidarr/trigger_automatic_search_command_handler_tests.rs index 35a537a..a3b1d4d 100644 --- a/src/cli/lidarr/trigger_automatic_search_command_handler_tests.rs +++ b/src/cli/lidarr/trigger_automatic_search_command_handler_tests.rs @@ -28,6 +28,36 @@ mod tests { use clap::error::ErrorKind; use pretty_assertions::assert_eq; + #[test] + fn test_trigger_automatic_album_search_requires_album_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "trigger-automatic-search", + "album", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_album_search_with_album_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "trigger-automatic-search", + "album", + "--album-id", + "1", + ]); + + assert_ok!(&result); + } + #[test] fn test_trigger_automatic_artist_search_requires_artist_id() { let result = Cli::command().try_get_matches_from([ @@ -75,6 +105,35 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent}, }; + #[tokio::test] + async fn test_handle_trigger_automatic_album_search_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::TriggerAutomaticAlbumSearch(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let trigger_automatic_search_command = + LidarrTriggerAutomaticSearchCommand::Album { album_id: 1 }; + + let result = LidarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_trigger_automatic_artist_search_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index a85d4ce..a8aaed8 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -256,6 +256,8 @@ pub struct LidarrCommandBody { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub artist_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub album_ids: Option>, } #[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)] @@ -495,6 +497,67 @@ pub struct LidarrReleaseDownloadBody { pub indexer_id: i64, } +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TrackFile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub path: String, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub quality: QualityWrapper, + pub date_added: DateTime, + pub media_info: Option, + pub audio_tags: Option, +} + +#[derive(Serialize, Deserialize, Derivative, Debug, Clone, PartialEq, Eq)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +pub struct MediaInfo { + pub audio_bitrate: Option, + #[serde(deserialize_with = "super::from_i64")] + pub audio_channels: i64, + pub audio_codec: Option, + pub audio_bits: Option, + pub audio_sample_rate: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AudioTags { + pub title: String, + pub artist_title: String, + pub album_title: String, + #[serde(deserialize_with = "super::from_i64")] + pub disc_number: i64, + #[serde(deserialize_with = "super::from_i64")] + pub disc_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub year: i64, + pub duration: String, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Track { + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub artist_id: i64, + pub foreign_track_id: String, + #[serde(deserialize_with = "super::from_i64")] + pub track_file_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub album_id: i64, + pub explicit: bool, + pub track_number: String, + pub title: String, + #[serde(deserialize_with = "super::from_i64")] + pub duration: i64, + pub has_file: bool, + pub ratings: Ratings, +} + impl From for Serdeable { fn from(value: LidarrSerdeable) -> Serdeable { Serdeable::Lidarr(value) @@ -527,6 +590,9 @@ serde_enum_from!( Tag(Tag), Tags(Vec), Tasks(Vec), + Track(Track), + Tracks(Vec), + TrackFiles(Vec), Updates(Vec), Value(Value), } diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 00cef17..068f4d1 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -5,9 +5,10 @@ mod tests { use serde_json::json; use crate::models::lidarr_models::{ - AddArtistSearchResult, Album, DownloadRecord, DownloadStatus, DownloadsResponse, + AddArtistSearchResult, Album, AudioTags, DownloadRecord, DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, LidarrRelease, LidarrTask, - Member, MetadataProfile, MonitorType, NewItemMonitorType, SystemStatus, + MediaInfo, Member, MetadataProfile, MonitorType, NewItemMonitorType, SystemStatus, Track, + TrackFile, }; use crate::models::servarr_models::{ DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, @@ -577,6 +578,50 @@ mod tests { assert_eq!(lidarr_serdeable, LidarrSerdeable::Updates(updates)); } + #[test] + fn test_lidarr_serdeable_from_track_file() { + let track_files = vec![TrackFile { + id: 1, + media_info: Some(MediaInfo { + audio_channels: 2, + ..MediaInfo::default() + }), + audio_tags: Some(AudioTags { + disc_number: 1, + ..AudioTags::default() + }), + ..TrackFile::default() + }]; + + let lidarr_serdeable: LidarrSerdeable = track_files.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::TrackFiles(track_files)); + } + + #[test] + fn test_lidarr_serdeable_from_track() { + let track = Track { + id: 1, + ..Track::default() + }; + + let lidarr_serdeable: LidarrSerdeable = track.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::Track(track)); + } + + #[test] + fn test_lidarr_serdeable_from_tracks() { + let tracks = vec![Track { + id: 1, + ..Track::default() + }]; + + let lidarr_serdeable: LidarrSerdeable = tracks.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::Tracks(tracks)); + } + #[test] fn test_artist_status_display() { assert_str_eq!(ArtistStatus::Continuing.to_string(), "continuing"); diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 4689ef9..7a8d63a 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,6 +1,6 @@ use serde_json::Number; -use super::modals::{AddArtistModal, AddRootFolderModal, EditArtistModal}; +use super::modals::{AddArtistModal, AddRootFolderModal, AlbumDetailsModal, EditArtistModal}; use crate::app::context_clues::{ DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, @@ -39,6 +39,7 @@ use { crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ torrent_release, usenet_release, }, + crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{track, track_file}, crate::network::servarr_test_utils::diskspace, crate::network::servarr_test_utils::indexer_test_result, crate::network::servarr_test_utils::queued_event, @@ -58,6 +59,7 @@ pub struct LidarrData<'a> { pub add_root_folder_modal: Option, pub add_searched_artists: Option>, pub albums: StatefulTable, + pub album_details_modal: Option, pub artist_history: StatefulTable, pub artist_info_tabs: TabState, pub artists: StatefulTable, @@ -143,6 +145,7 @@ impl<'a> Default for LidarrData<'a> { add_root_folder_modal: None, add_searched_artists: None, albums: StatefulTable::default(), + album_details_modal: None, artist_history: StatefulTable::default(), artists: StatefulTable::default(), delete_files: false, @@ -289,6 +292,27 @@ impl LidarrData<'_> { .metadata_profile_list .set_items(vec![metadata_profile().name]); + let mut album_details_modal = AlbumDetailsModal::default(); + album_details_modal.tracks.set_items(vec![track()]); + album_details_modal.tracks.search = Some("album search".into()); + album_details_modal + .track_files + .set_items(vec![track_file()]); + album_details_modal + .album_history + .set_items(vec![lidarr_history_item()]); + album_details_modal.album_history.search = Some("album history search".into()); + album_details_modal.album_history.filter = Some("album history filter".into()); + album_details_modal + .album_history + .sorting(vec![sort_option!(id)]); + album_details_modal + .album_releases + .set_items(vec![torrent_release(), usenet_release()]); + album_details_modal + .album_releases + .sorting(vec![sort_option!(indexer_id)]); + let edit_indexer_modal = EditIndexerModal { name: "DrunkenSlug".into(), enable_rss: Some(true), @@ -305,6 +329,7 @@ impl LidarrData<'_> { indexer_test_all_results.set_items(vec![indexer_test_result()]); let mut lidarr_data = LidarrData { + album_details_modal: Some(album_details_modal), delete_files: true, disk_space_vec: vec![diskspace()], quality_profile_map: quality_profile_map(), @@ -376,9 +401,6 @@ pub enum ActiveLidarrBlock { ArtistHistoryDetails, ArtistHistorySortPrompt, ArtistsSortPrompt, - ManualArtistSearch, - ManualArtistSearchConfirmPrompt, - ManualArtistSearchSortPrompt, AddArtistAlreadyInLibrary, AddArtistConfirmPrompt, AddArtistEmptySearchResults, @@ -400,7 +422,12 @@ pub enum ActiveLidarrBlock { AddRootFolderSelectQualityProfile, AddRootFolderSelectMetadataProfile, AddRootFolderTagsInput, + AlbumDetails, + AlbumHistory, + AlbumHistoryDetails, + AlbumHistorySortPrompt, AllIndexerSettingsPrompt, + AutomaticallySearchAlbumPrompt, AutomaticallySearchArtistPrompt, DeleteAlbumPrompt, DeleteAlbumConfirmPrompt, @@ -410,6 +437,7 @@ pub enum ActiveLidarrBlock { DeleteArtistConfirmPrompt, DeleteArtistToggleDeleteFile, DeleteArtistToggleAddListExclusion, + DeleteTrackFilePrompt, DeleteDownloadPrompt, DeleteRootFolderPrompt, Downloads, @@ -433,6 +461,8 @@ pub enum ActiveLidarrBlock { EditIndexerPriorityInput, EditIndexerTagsInput, DeleteIndexerPrompt, + FilterAlbumHistory, + FilterAlbumHistoryError, FilterArtists, FilterArtistsError, FilterHistory, @@ -448,9 +478,17 @@ pub enum ActiveLidarrBlock { IndexerSettingsMinimumAgeInput, IndexerSettingsRetentionInput, IndexerSettingsRssSyncIntervalInput, + ManualAlbumSearch, + ManualAlbumSearchConfirmPrompt, + ManualAlbumSearchSortPrompt, + ManualArtistSearch, + ManualArtistSearchConfirmPrompt, + ManualArtistSearchSortPrompt, TestAllIndexers, TestIndexer, RootFolders, + SearchAlbumHistory, + SearchAlbumHistoryError, SearchAlbums, SearchAlbumsError, SearchArtists, @@ -459,6 +497,8 @@ pub enum ActiveLidarrBlock { SearchHistoryError, SearchArtistHistory, SearchArtistHistoryError, + SearchTracks, + SearchTracksError, System, SystemLogs, SystemQueuedEvents, @@ -498,6 +538,24 @@ pub static ARTIST_DETAILS_BLOCKS: [ActiveLidarrBlock; 15] = [ ActiveLidarrBlock::UpdateAndScanArtistPrompt, ]; +pub static ALBUM_DETAILS_BLOCKS: [ActiveLidarrBlock; 15] = [ + ActiveLidarrBlock::AlbumDetails, + ActiveLidarrBlock::AlbumHistory, + ActiveLidarrBlock::SearchTracks, + ActiveLidarrBlock::SearchTracksError, + ActiveLidarrBlock::AutomaticallySearchAlbumPrompt, + ActiveLidarrBlock::SearchAlbumHistory, + ActiveLidarrBlock::SearchAlbumHistoryError, + ActiveLidarrBlock::FilterAlbumHistory, + ActiveLidarrBlock::FilterAlbumHistoryError, + ActiveLidarrBlock::AlbumHistorySortPrompt, + ActiveLidarrBlock::AlbumHistoryDetails, + ActiveLidarrBlock::ManualAlbumSearch, + ActiveLidarrBlock::ManualAlbumSearchConfirmPrompt, + ActiveLidarrBlock::ManualAlbumSearchSortPrompt, + ActiveLidarrBlock::DeleteTrackFilePrompt, +]; + pub static DOWNLOADS_BLOCKS: [ActiveLidarrBlock; 3] = [ ActiveLidarrBlock::Downloads, ActiveLidarrBlock::DeleteDownloadPrompt, diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index 75bf863..8558282 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -10,9 +10,9 @@ mod tests { }; use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ARTIST_DETAILS_BLOCKS, - DELETE_ALBUM_BLOCKS, DELETE_ALBUM_SELECTION_BLOCKS, DELETE_ARTIST_BLOCKS, - DELETE_ARTIST_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_ARTIST_BLOCKS, + ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, + ARTIST_DETAILS_BLOCKS, DELETE_ALBUM_BLOCKS, DELETE_ALBUM_SELECTION_BLOCKS, + DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, ROOT_FOLDERS_BLOCKS, SYSTEM_DETAILS_BLOCKS, @@ -145,6 +145,7 @@ mod tests { assert!(!lidarr_data.add_import_list_exclusion); assert_none!(lidarr_data.add_searched_artists); assert_is_empty!(lidarr_data.albums); + assert_none!(lidarr_data.album_details_modal); assert_is_empty!(lidarr_data.artists); assert_is_empty!(lidarr_data.artist_history); assert!(!lidarr_data.delete_files); @@ -304,6 +305,26 @@ mod tests { assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::UpdateAndScanArtistPrompt)); } + #[test] + fn test_album_details_blocks_contents() { + assert_eq!(ALBUM_DETAILS_BLOCKS.len(), 15); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::AlbumDetails)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::AlbumHistory)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::SearchTracks)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::SearchTracksError)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::AutomaticallySearchAlbumPrompt)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::SearchAlbumHistory)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::SearchAlbumHistoryError)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::FilterAlbumHistory)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::FilterAlbumHistoryError)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::AlbumHistorySortPrompt)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::AlbumHistoryDetails)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::ManualAlbumSearch)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::ManualAlbumSearchConfirmPrompt)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::ManualAlbumSearchSortPrompt)); + assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::DeleteTrackFilePrompt)); + } + #[test] fn test_downloads_blocks_contains_expected_blocks() { assert_eq!(DOWNLOADS_BLOCKS.len(), 3); diff --git a/src/models/servarr_data/lidarr/modals.rs b/src/models/servarr_data/lidarr/modals.rs index 9453045..bb1a4f9 100644 --- a/src/models/servarr_data/lidarr/modals.rs +++ b/src/models/servarr_data/lidarr/modals.rs @@ -1,14 +1,18 @@ -use strum::IntoEnumIterator; - -use super::lidarr_data::LidarrData; +use super::lidarr_data::{ActiveLidarrBlock, LidarrData}; +use crate::app::lidarr::lidarr_context_clues::{ + ALBUM_DETAILS_CONTEXT_CLUES, ALBUM_HISTORY_CONTEXT_CLUES, MANUAL_ALBUM_SEARCH_CONTEXT_CLUES, +}; +use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease, Track, TrackFile}; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_models::Indexer; +use crate::models::stateful_table::StatefulTable; use crate::models::{ - HorizontallyScrollableText, + HorizontallyScrollableText, TabRoute, TabState, lidarr_models::{MonitorType, NewItemMonitorType}, servarr_models::RootFolder, stateful_list::StatefulList, }; +use strum::IntoEnumIterator; #[cfg(test)] #[path = "modals_tests.rs"] @@ -217,3 +221,45 @@ impl From<&LidarrData<'_>> for AddRootFolderModal { add_root_folder_modal } } + +#[cfg_attr(test, derive(Debug))] +pub struct AlbumDetailsModal { + pub tracks: StatefulTable, + pub track_files: StatefulTable, + // pub track_details_modal: Option, + pub album_history: StatefulTable, + pub album_releases: StatefulTable, + pub album_details_tabs: TabState, +} + +impl Default for AlbumDetailsModal { + fn default() -> AlbumDetailsModal { + AlbumDetailsModal { + tracks: StatefulTable::default(), + // TODO episode_details_modal: None, + track_files: StatefulTable::default(), + album_releases: StatefulTable::default(), + album_history: StatefulTable::default(), + album_details_tabs: TabState::new(vec![ + TabRoute { + title: "Tracks".to_string(), + route: ActiveLidarrBlock::AlbumDetails.into(), + contextual_help: Some(&ALBUM_DETAILS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "History".to_string(), + route: ActiveLidarrBlock::AlbumHistory.into(), + contextual_help: Some(&ALBUM_HISTORY_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Manual Search".to_string(), + route: ActiveLidarrBlock::ManualAlbumSearch.into(), + contextual_help: Some(&MANUAL_ALBUM_SEARCH_CONTEXT_CLUES), + config: None, + }, + ]), + } + } +} diff --git a/src/models/servarr_data/lidarr/modals_tests.rs b/src/models/servarr_data/lidarr/modals_tests.rs index 279c63f..dd92e40 100644 --- a/src/models/servarr_data/lidarr/modals_tests.rs +++ b/src/models/servarr_data/lidarr/modals_tests.rs @@ -1,8 +1,13 @@ #[cfg(test)] mod tests { + use crate::app::lidarr::lidarr_context_clues::{ + ALBUM_DETAILS_CONTEXT_CLUES, ALBUM_HISTORY_CONTEXT_CLUES, MANUAL_ALBUM_SEARCH_CONTEXT_CLUES, + }; use crate::models::lidarr_models::{Artist, MonitorType, NewItemMonitorType}; - use crate::models::servarr_data::lidarr::lidarr_data::LidarrData; - use crate::models::servarr_data::lidarr::modals::{AddArtistModal, EditArtistModal}; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}; + use crate::models::servarr_data::lidarr::modals::{ + AddArtistModal, AlbumDetailsModal, EditArtistModal, + }; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_models::{Indexer, IndexerField, RootFolder}; use bimap::BiMap; @@ -208,4 +213,80 @@ mod tests { assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); assert!(edit_indexer_modal.seed_ratio.text.is_empty()); } + + #[test] + fn test_album_details_modal_default() { + let album_details_modal = AlbumDetailsModal::default(); + + assert!(album_details_modal.tracks.is_empty()); + // assert!(album_details_modal.track_details_modal.is_none()); + assert!(album_details_modal.track_files.is_empty()); + assert!(album_details_modal.album_releases.is_empty()); + assert!(album_details_modal.album_history.is_empty()); + + assert_eq!(album_details_modal.album_details_tabs.tabs.len(), 3); + + assert_str_eq!( + album_details_modal.album_details_tabs.tabs[0].title, + "Tracks" + ); + assert_eq!( + album_details_modal.album_details_tabs.tabs[0].route, + ActiveLidarrBlock::AlbumDetails.into() + ); + assert!( + album_details_modal.album_details_tabs.tabs[0] + .contextual_help + .is_some() + ); + assert_eq!( + album_details_modal.album_details_tabs.tabs[0] + .contextual_help + .unwrap(), + &ALBUM_DETAILS_CONTEXT_CLUES + ); + assert_eq!(album_details_modal.album_details_tabs.tabs[0].config, None); + + assert_str_eq!( + album_details_modal.album_details_tabs.tabs[1].title, + "History" + ); + assert_eq!( + album_details_modal.album_details_tabs.tabs[1].route, + ActiveLidarrBlock::AlbumHistory.into() + ); + assert!( + album_details_modal.album_details_tabs.tabs[1] + .contextual_help + .is_some() + ); + assert_eq!( + album_details_modal.album_details_tabs.tabs[1] + .contextual_help + .unwrap(), + &ALBUM_HISTORY_CONTEXT_CLUES + ); + assert_eq!(album_details_modal.album_details_tabs.tabs[1].config, None); + + assert_str_eq!( + album_details_modal.album_details_tabs.tabs[2].title, + "Manual Search" + ); + assert_eq!( + album_details_modal.album_details_tabs.tabs[2].route, + ActiveLidarrBlock::ManualAlbumSearch.into() + ); + assert!( + album_details_modal.album_details_tabs.tabs[2] + .contextual_help + .is_some() + ); + assert_eq!( + album_details_modal.album_details_tabs.tabs[2] + .contextual_help + .unwrap(), + &MANUAL_ALBUM_SEARCH_CONTEXT_CLUES + ); + assert_eq!(album_details_modal.album_details_tabs.tabs[2].config, None); + } } diff --git a/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs b/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs index e36ff6e..13e3ecc 100644 --- a/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs +++ b/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs @@ -1,11 +1,18 @@ #[cfg(test)] mod tests { - use crate::models::lidarr_models::{Album, DeleteParams, LidarrSerdeable}; + use crate::models::lidarr_models::{ + Album, DeleteParams, LidarrHistoryItem, LidarrRelease, LidarrSerdeable, + }; + use crate::models::servarr_data::lidarr::modals::AlbumDetailsModal; + use crate::models::stateful_table::SortOption; use crate::network::lidarr_network::LidarrEvent; - use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::ALBUM_JSON; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ + ALBUM_JSON, lidarr_history_item, torrent_release, + }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use mockito::Matcher; use pretty_assertions::assert_eq; + use rstest::rstest; use serde_json::{Value, json}; #[tokio::test] @@ -128,4 +135,397 @@ mod tests { assert_eq!(album, expected_album); } + + #[rstest] + #[tokio::test] + async fn test_handle_get_lidarr_album_history_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z album", + "albumId": 1007, + "artistId": 1007, + "quality": { "quality": { "name": "Lossless" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3", + "importedPath": "/nfs/music/Something/Album 1/Cool.mp3" + } + }, + { + "id": 456, + "sourceTitle": "An Album", + "albumId": 2001, + "artistId": 2001, + "quality": { "quality": { "name": "Lossless" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3", + "importedPath": "/nfs/music/Something/Album 1/Cool.mp3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + LidarrHistoryItem { + id: 123, + artist_id: 1007, + album_id: 1007, + source_title: "z album".into(), + ..lidarr_history_item() + }, + LidarrHistoryItem { + id: 456, + artist_id: 2001, + album_id: 2001, + source_title: "An Album".into(), + ..lidarr_history_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("artistId=1&albumId=1") + .build_for(LidarrEvent::GetAlbumHistory(1, 1)) + .await; + app.lock().await.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default()); + if use_custom_sorting { + let cmp_fn = |a: &LidarrHistoryItem, b: &LidarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .lidarr_data + .album_details_modal + .as_mut() + .unwrap() + .album_history + .sorting(vec![history_sort_option]); + } + app + .lock() + .await + .data + .lidarr_data + .album_details_modal + .as_mut() + .unwrap() + .album_history + .sort_asc = true; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let LidarrSerdeable::LidarrHistoryItems(history) = network + .handle_lidarr_event(LidarrEvent::GetAlbumHistory(1, 1)) + .await + .unwrap() + else { + panic!("Expected LidarrHistoryItems") + }; + mock.assert_async().await; + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .album_history + .items, + expected_history_items + ); + assert!( + app + .lock() + .await + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .album_history + .sort_asc + ); + assert_eq!(history, response); + } + + #[tokio::test] + async fn test_handle_get_lidarr_album_history_event_empty_album_details_modal() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z album", + "albumId": 1007, + "artistId": 1007, + "quality": { "quality": { "name": "Lossless" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3", + "importedPath": "/nfs/music/Something/Album 1/Cool.mp3" + } + }, + { + "id": 456, + "sourceTitle": "An Album", + "albumId": 2001, + "artistId": 2001, + "quality": { "quality": { "name": "Lossless" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3", + "importedPath": "/nfs/music/Something/Album 1/Cool.mp3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + LidarrHistoryItem { + id: 123, + artist_id: 1007, + album_id: 1007, + source_title: "z album".into(), + ..lidarr_history_item() + }, + LidarrHistoryItem { + id: 456, + artist_id: 2001, + album_id: 2001, + source_title: "An Album".into(), + ..lidarr_history_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("artistId=1&albumId=1") + .build_for(LidarrEvent::GetAlbumHistory(1, 1)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let LidarrSerdeable::LidarrHistoryItems(history) = network + .handle_lidarr_event(LidarrEvent::GetAlbumHistory(1, 1)) + .await + .unwrap() + else { + panic!("Expected LidarrHistoryItems") + }; + mock.assert_async().await; + let app = app.lock().await; + assert_some!(&app.data.lidarr_data.album_details_modal); + assert_eq!( + app + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .album_history + .items, + expected_history_items + ); + assert!( + !app + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .album_history + .sort_asc + ); + assert_eq!(history, response); + } + + #[tokio::test] + async fn test_handle_get_album_releases_event() { + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "artistName": "Alex", + "albumTitle": "Something", + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "quality": { "quality": { "name": "Lossless" }}, + "discography": true + }, + { + "guid": "4567", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "artistName": "Alex", + "albumTitle": "Something", + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "quality": { "quality": { "name": "Lossless" }}, + } + ]); + let expected_filtered_lidarr_release = LidarrRelease { + guid: "4567".to_owned(), + ..torrent_release() + }; + let expected_raw_lidarr_releases = vec![ + LidarrRelease { + discography: true, + ..torrent_release() + }, + LidarrRelease { + guid: "4567".to_owned(), + ..torrent_release() + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(release_json) + .query("artistId=1&albumId=1") + .build_for(LidarrEvent::GetAlbumReleases(1, 1)) + .await; + app.lock().await.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default()); + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let LidarrSerdeable::Releases(releases_vec) = network + .handle_lidarr_event(LidarrEvent::GetAlbumReleases(1, 1)) + .await + .unwrap() + else { + panic!("Expected Releases") + }; + + mock.assert_async().await; + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .album_releases + .items, + vec![expected_filtered_lidarr_release] + ); + assert_eq!(releases_vec, expected_raw_lidarr_releases); + } + + #[tokio::test] + async fn test_handle_get_album_releases_event_empty_album_details_modal() { + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "artistName": "Alex", + "albumTitle": "Something", + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "quality": { "quality": { "name": "Lossless" }}, + "discography": true + }, + { + "guid": "4567", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "artistName": "Alex", + "albumTitle": "Something", + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "quality": { "quality": { "name": "Lossless" }}, + } + ]); + let expected_lidarr_release = LidarrRelease { + guid: "4567".to_owned(), + ..torrent_release() + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(release_json) + .query("artistId=1&albumId=1") + .build_for(LidarrEvent::GetAlbumReleases(1, 1)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert_ok!( + network + .handle_lidarr_event(LidarrEvent::GetAlbumReleases(1, 1)) + .await + ); + + mock.assert_async().await; + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .album_releases + .items, + vec![expected_lidarr_release] + ); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_album_search_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "AlbumSearch", + "albumIds": [1] + })) + .returns(json!({})) + .build_for(LidarrEvent::TriggerAutomaticAlbumSearch(1)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::TriggerAutomaticAlbumSearch(1)) + .await + .is_ok() + ); + + mock.assert_async().await; + } } diff --git a/src/network/lidarr_network/library/albums/mod.rs b/src/network/lidarr_network/library/albums/mod.rs index b8becdd..6a5f202 100644 --- a/src/network/lidarr_network/library/albums/mod.rs +++ b/src/network/lidarr_network/library/albums/mod.rs @@ -1,4 +1,7 @@ -use crate::models::lidarr_models::{Album, DeleteParams}; +use crate::models::lidarr_models::{ + Album, DeleteParams, LidarrCommandBody, LidarrHistoryItem, LidarrRelease, +}; +use crate::models::servarr_data::lidarr::modals::AlbumDetailsModal; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; @@ -57,6 +60,88 @@ impl Network<'_, '_> { .await } + pub(in crate::network::lidarr_network) async fn get_lidarr_album_history( + &mut self, + artist_id: i64, + album_id: i64, + ) -> Result> { + let event = LidarrEvent::GetAlbumHistory(artist_id, album_id); + info!("Fetching history for artist with ID: {artist_id} and album with ID: {album_id}"); + + let params = format!("artistId={artist_id}&albumId={album_id}"); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), Vec>(request_props, |history_items, mut app| { + if app.data.lidarr_data.album_details_modal.is_none() { + app.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default()); + } + + let mut history_vec = history_items; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app + .data + .lidarr_data + .album_details_modal + .as_mut() + .unwrap() + .album_history + .set_items(history_vec); + app + .data + .lidarr_data + .album_details_modal + .as_mut() + .unwrap() + .album_history + .apply_sorting_toggle(false); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_album_releases( + &mut self, + artist_id: i64, + album_id: i64, + ) -> Result> { + let event = LidarrEvent::GetAlbumReleases(artist_id, album_id); + info!("Fetching releases for artist with ID: {artist_id} and album with ID: {album_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("artistId={artist_id}&albumId={album_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + if app.data.lidarr_data.album_details_modal.is_none() { + app.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default()); + } + + let album_releases_vec = release_vec + .into_iter() + .filter(|release| !release.discography) + .collect(); + + app + .data + .lidarr_data + .album_details_modal + .as_mut() + .unwrap() + .album_releases + .set_items(album_releases_vec); + }) + .await + } + pub(in crate::network::lidarr_network) async fn delete_album( &mut self, delete_album_params: DeleteParams, @@ -150,4 +235,26 @@ impl Network<'_, '_> { } } } + + pub(in crate::network::lidarr_network) async fn trigger_automatic_album_search( + &mut self, + album_id: i64, + ) -> Result { + let event = LidarrEvent::TriggerAutomaticAlbumSearch(album_id); + info!("Searching indexers for album with ID: {album_id}"); + + let body = LidarrCommandBody { + name: "AlbumSearch".to_owned(), + album_ids: Some(vec![album_id]), + ..LidarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } } diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index 6adafa1..f20624f 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -7,6 +7,7 @@ use serde_json::Value; mod albums; mod artists; +mod tracks; #[cfg(test)] #[path = "lidarr_library_network_tests.rs"] diff --git a/src/network/lidarr_network/library/tracks/lidarr_tracks_network_tests.rs b/src/network/lidarr_network/library/tracks/lidarr_tracks_network_tests.rs new file mode 100644 index 0000000..57ee399 --- /dev/null +++ b/src/network/lidarr_network/library/tracks/lidarr_tracks_network_tests.rs @@ -0,0 +1,173 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::LidarrSerdeable; + use crate::models::servarr_data::lidarr::modals::AlbumDetailsModal; + use crate::network::lidarr_network::LidarrEvent; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{track, track_file}; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_delete_lidarr_track_file_event() { + let (async_server, app_arc, _server) = MockServarrApi::delete() + .path("/1") + .build_for(LidarrEvent::DeleteTrackFile(1)) + .await; + app_arc.lock().await.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default()); + app_arc.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app_arc); + + assert!( + network + .handle_lidarr_event(LidarrEvent::DeleteTrackFile(1)) + .await + .is_ok() + ); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_tracks_event() { + let expected_tracks = vec![track()]; + let (mock, app, _server) = MockServarrApi::get() + .query("artistId=1&albumId=1") + .returns(json!([track()])) + .build_for(LidarrEvent::GetTracks(1, 1)) + .await; + app.lock().await.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default()); + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetTracks(1, 1)) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::Tracks(tracks) = result.unwrap() else { + panic!("Expected Tracks variant") + }; + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .tracks + .items, + expected_tracks + ); + assert_eq!(tracks, expected_tracks); + } + + #[tokio::test] + async fn test_handle_get_tracks_event_empty_album_details_modal() { + let expected_tracks = vec![track()]; + let (mock, app, _server) = MockServarrApi::get() + .query("artistId=1&albumId=1") + .returns(json!([track()])) + .build_for(LidarrEvent::GetTracks(1, 1)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetTracks(1, 1)) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::Tracks(tracks) = result.unwrap() else { + panic!("Expected Tracks variant") + }; + + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .tracks + .items, + expected_tracks + ); + assert_eq!(tracks, expected_tracks); + } + + #[tokio::test] + async fn test_handle_get_track_files_event() { + let (async_server, app_arc, _server) = MockServarrApi::get() + .returns(json!([track_file()])) + .query("albumId=1") + .build_for(LidarrEvent::GetTrackFiles(1)) + .await; + app_arc.lock().await.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default()); + app_arc.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app_arc); + + let LidarrSerdeable::TrackFiles(track_files) = network + .handle_lidarr_event(LidarrEvent::GetTrackFiles(1)) + .await + .unwrap() + else { + panic!("Expected TrackFiles") + }; + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .track_files + .items, + vec![track_file()] + ); + assert_eq!(track_files, vec![track_file()]); + } + + #[tokio::test] + async fn test_handle_get_track_files_event_empty_album_details_modal() { + let (async_server, app_arc, _server) = MockServarrApi::get() + .returns(json!([track_file()])) + .query("albumId=1") + .build_for(LidarrEvent::GetTrackFiles(1)) + .await; + app_arc.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app_arc); + + let LidarrSerdeable::TrackFiles(track_files) = network + .handle_lidarr_event(LidarrEvent::GetTrackFiles(1)) + .await + .unwrap() + else { + panic!("Expected TrackFiles") + }; + async_server.assert_async().await; + let app = app_arc.lock().await; + assert_some!(&app.data.lidarr_data.album_details_modal); + assert_eq!( + app + .data + .lidarr_data + .album_details_modal + .as_ref() + .unwrap() + .track_files + .items, + vec![track_file()] + ); + assert_eq!(track_files, vec![track_file()]); + } +} diff --git a/src/network/lidarr_network/library/tracks/mod.rs b/src/network/lidarr_network/library/tracks/mod.rs new file mode 100644 index 0000000..7026a7c --- /dev/null +++ b/src/network/lidarr_network/library/tracks/mod.rs @@ -0,0 +1,106 @@ +use crate::models::lidarr_models::{Track, TrackFile}; +use crate::models::servarr_data::lidarr::modals::AlbumDetailsModal; +use crate::network::lidarr_network::LidarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; + +#[cfg(test)] +#[path = "lidarr_tracks_network_tests.rs"] +mod lidarr_tracks_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn delete_lidarr_track_file( + &mut self, + track_file_id: i64, + ) -> Result<()> { + let event = LidarrEvent::DeleteTrackFile(track_file_id); + info!("Deleting Lidarr track file for track file with id: {track_file_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{track_file_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::lidarr_network) async fn get_tracks( + &mut self, + artist_id: i64, + album_id: i64, + ) -> Result> { + let event = LidarrEvent::GetTracks(artist_id, album_id); + info!("Fetching tracks for Lidarr artist with ID: {artist_id} and album with ID: {album_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("artistId={artist_id}&albumId={album_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut track_vec, mut app| { + track_vec.sort_by(|a, b| a.id.cmp(&b.id)); + if app.data.lidarr_data.album_details_modal.is_none() { + app.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default()); + } + + app + .data + .lidarr_data + .album_details_modal + .as_mut() + .unwrap() + .tracks + .set_items(track_vec); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_track_files( + &mut self, + album_id: i64, + ) -> Result> { + let event = LidarrEvent::GetTrackFiles(album_id); + info!("Fetching tracks files for Lidarr album with ID: {album_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("albumId={album_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |track_file_vec, mut app| { + if app.data.lidarr_data.album_details_modal.is_none() { + app.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default()); + } + + app + .data + .lidarr_data + .album_details_modal + .as_mut() + .unwrap() + .track_files + .set_items(track_file_vec); + }) + .await + } +} diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index 4f4c97f..9c87566 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -3,9 +3,10 @@ pub mod test_utils { use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus, - DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, LidarrHistoryData, - LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, LidarrRelease, LidarrTask, - LidarrTaskName, Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, + AudioTags, DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, + LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, + LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, MetadataProfile, + NewItemMonitorType, Ratings, SystemStatus, Track, TrackFile, }; use crate::models::servarr_models::IndexerSettings; use crate::models::servarr_models::{ @@ -424,4 +425,54 @@ pub mod test_utils { quality: quality_wrapper(), } } + + pub fn audio_tags() -> AudioTags { + AudioTags { + title: "When I Get There".to_string(), + artist_title: "P!nk".to_string(), + album_title: "Trustfall".to_string(), + disc_number: 1, + disc_count: 1, + year: 2023, + duration: "00:03:20.1802267".to_string(), + } + } + + pub fn media_info() -> MediaInfo { + MediaInfo { + audio_bitrate: Some("1563 kbps".to_owned()), + audio_channels: 2, + audio_codec: Some("FLAC".to_owned()), + audio_bits: Some("24bit".to_owned()), + audio_sample_rate: Some("44.1kHz".to_owned()), + } + } + + pub fn track_file() -> TrackFile { + TrackFile { + id: 1, + path: "/music/P!nk/TRUSTFALL/01 - When I Get There.flac".to_string(), + size: 39216378, + quality: quality_wrapper(), + date_added: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()), + media_info: Some(media_info()), + audio_tags: Some(audio_tags()), + } + } + + pub fn track() -> Track { + Track { + id: 1, + artist_id: 1, + foreign_track_id: "test-foreign-track-id".to_string(), + track_file_id: 1, + album_id: 1, + explicit: false, + track_number: "1".to_string(), + title: "Test title".to_string(), + duration: 200173, + has_file: false, + ratings: ratings(), + } + } } diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index 80dcea6..fa9a126 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -61,8 +61,11 @@ mod tests { } #[rstest] - fn test_resource_history(#[values(LidarrEvent::GetHistory(0))] event: LidarrEvent) { - assert_str_eq!(event.resource(), "/history"); + fn test_resource_artist_history( + #[values(LidarrEvent::GetArtistHistory(0), LidarrEvent::GetAlbumHistory(0, 0))] + event: LidarrEvent, + ) { + assert_str_eq!(event.resource(), "/history/artist"); } #[rstest] @@ -89,6 +92,7 @@ mod tests { #[values( LidarrEvent::UpdateAllArtists, LidarrEvent::TriggerAutomaticArtistSearch(0), + LidarrEvent::TriggerAutomaticAlbumSearch(0), LidarrEvent::UpdateAndScanArtist(0), LidarrEvent::UpdateDownloads, LidarrEvent::GetQueuedEvents, @@ -128,13 +132,21 @@ mod tests { fn test_resource_release( #[values( LidarrEvent::GetDiscographyReleases(0), - LidarrEvent::DownloadRelease(Default::default()) + LidarrEvent::DownloadRelease(Default::default()), + LidarrEvent::GetAlbumReleases(0, 0) )] event: LidarrEvent, ) { assert_str_eq!(event.resource(), "/release"); } + #[rstest] + fn test_resource_track_file( + #[values(LidarrEvent::DeleteTrackFile(0), LidarrEvent::GetTrackFiles(0))] event: LidarrEvent, + ) { + assert_str_eq!(event.resource(), "/trackfile"); + } + #[rstest] #[case(LidarrEvent::GetDiskSpace, "/diskspace")] #[case(LidarrEvent::GetMetadataProfiles, "/metadataprofile")] @@ -146,9 +158,10 @@ mod tests { #[case(LidarrEvent::GetUpdates, "/update")] #[case(LidarrEvent::HealthCheck, "/health")] #[case(LidarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] - #[case(LidarrEvent::GetArtistHistory(0), "/history/artist")] + #[case(LidarrEvent::GetHistory(0), "/history")] #[case(LidarrEvent::TestIndexer(0), "/indexer/test")] #[case(LidarrEvent::TestAllIndexers, "/indexer/testall")] + #[case(LidarrEvent::GetTracks(0, 0), "/track")] fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) { assert_str_eq!(event.resource(), expected_uri); } diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index ab28cde..12dce26 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -35,12 +35,15 @@ pub enum LidarrEvent { DeleteIndexer(i64), DeleteRootFolder(i64), DeleteTag(i64), + DeleteTrackFile(i64), DownloadRelease(LidarrReleaseDownloadBody), EditArtist(EditArtistParams), EditAllIndexerSettings(IndexerSettings), EditIndexer(EditIndexerParams), GetAlbums(i64), GetAlbumDetails(i64), + GetAlbumHistory(i64, i64), + GetAlbumReleases(i64, i64), GetArtistHistory(i64), GetAllIndexerSettings, GetArtistDetails(i64), @@ -58,6 +61,8 @@ pub enum LidarrEvent { GetRootFolders, GetSecurityConfig, GetStatus, + GetTracks(i64, i64), + GetTrackFiles(i64), GetUpdates, GetTags, GetTasks, @@ -70,6 +75,7 @@ pub enum LidarrEvent { ToggleAlbumMonitoring(i64), ToggleArtistMonitoring(i64), TriggerAutomaticArtistSearch(i64), + TriggerAutomaticAlbumSearch(i64), UpdateAllArtists, UpdateAndScanArtist(i64), UpdateDownloads, @@ -79,6 +85,7 @@ impl NetworkResource for LidarrEvent { fn resource(&self) -> &'static str { match &self { LidarrEvent::AddTag(_) | LidarrEvent::DeleteTag(_) | LidarrEvent::GetTags => "/tag", + LidarrEvent::DeleteTrackFile(_) | LidarrEvent::GetTrackFiles(_) => "/trackfile", LidarrEvent::GetAllIndexerSettings | LidarrEvent::EditAllIndexerSettings(_) => { "/config/indexer" } @@ -92,13 +99,15 @@ impl NetworkResource for LidarrEvent { | LidarrEvent::ToggleAlbumMonitoring(_) | LidarrEvent::GetAlbumDetails(_) | LidarrEvent::DeleteAlbum(_) => "/album", - LidarrEvent::GetArtistHistory(_) => "/history/artist", + LidarrEvent::GetArtistHistory(_) | LidarrEvent::GetAlbumHistory(_, _) => "/history/artist", LidarrEvent::GetLogs(_) => "/log", LidarrEvent::GetDiskSpace => "/diskspace", LidarrEvent::GetDownloads(_) | LidarrEvent::DeleteDownload(_) => "/queue", LidarrEvent::GetHistory(_) => "/history", LidarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", - LidarrEvent::GetDiscographyReleases(_) | LidarrEvent::DownloadRelease(_) => "/release", + LidarrEvent::GetDiscographyReleases(_) + | LidarrEvent::DownloadRelease(_) + | LidarrEvent::GetAlbumReleases(_, _) => "/release", LidarrEvent::GetHostConfig | LidarrEvent::GetSecurityConfig => "/config/host", LidarrEvent::GetIndexers | LidarrEvent::DeleteIndexer(_) | LidarrEvent::EditIndexer(_) => { "/indexer" @@ -108,7 +117,8 @@ impl NetworkResource for LidarrEvent { | LidarrEvent::UpdateAndScanArtist(_) | LidarrEvent::UpdateDownloads | LidarrEvent::GetQueuedEvents - | LidarrEvent::StartTask(_) => "/command", + | LidarrEvent::StartTask(_) + | LidarrEvent::TriggerAutomaticAlbumSearch(_) => "/command", LidarrEvent::GetMetadataProfiles => "/metadataprofile", LidarrEvent::GetQualityProfiles => "/qualityprofile", LidarrEvent::GetRootFolders @@ -118,6 +128,7 @@ impl NetworkResource for LidarrEvent { LidarrEvent::TestAllIndexers => "/indexer/testall", LidarrEvent::GetStatus => "/system/status", LidarrEvent::GetTasks => "/system/task", + LidarrEvent::GetTracks(_, _) => "/track", LidarrEvent::GetUpdates => "/update", LidarrEvent::HealthCheck => "/health", LidarrEvent::SearchNewArtist(_) => "/artist/lookup", @@ -152,6 +163,10 @@ impl Network<'_, '_> { .delete_lidarr_download(download_id) .await .map(LidarrSerdeable::from), + LidarrEvent::DeleteTrackFile(track_file_id) => self + .delete_lidarr_track_file(track_file_id) + .await + .map(LidarrSerdeable::from), LidarrEvent::EditAllIndexerSettings(params) => self .edit_all_lidarr_indexer_settings(params) .await @@ -191,6 +206,14 @@ impl Network<'_, '_> { .get_album_details(album_id) .await .map(LidarrSerdeable::from), + LidarrEvent::GetAlbumHistory(artist_id, album_id) => self + .get_lidarr_album_history(artist_id, album_id) + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetAlbumReleases(artist_id, album_id) => self + .get_album_releases(artist_id, album_id) + .await + .map(LidarrSerdeable::from), LidarrEvent::GetDiscographyReleases(artist_id) => self .get_artist_discography_releases(artist_id) .await @@ -244,6 +267,14 @@ impl Network<'_, '_> { LidarrEvent::GetStatus => self.get_lidarr_status().await.map(LidarrSerdeable::from), LidarrEvent::GetTags => self.get_lidarr_tags().await.map(LidarrSerdeable::from), LidarrEvent::GetTasks => self.get_lidarr_tasks().await.map(LidarrSerdeable::from), + LidarrEvent::GetTracks(artist_id, album_id) => self + .get_tracks(artist_id, album_id) + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetTrackFiles(album_id) => self + .get_track_files(album_id) + .await + .map(LidarrSerdeable::from), LidarrEvent::GetUpdates => self.get_lidarr_updates().await.map(LidarrSerdeable::from), LidarrEvent::HealthCheck => self .get_lidarr_healthcheck() @@ -269,6 +300,10 @@ impl Network<'_, '_> { .trigger_automatic_artist_search(artist_id) .await .map(LidarrSerdeable::from), + LidarrEvent::TriggerAutomaticAlbumSearch(album_id) => self + .trigger_automatic_album_search(album_id) + .await + .map(LidarrSerdeable::from), LidarrEvent::UpdateAllArtists => self.update_all_artists().await.map(LidarrSerdeable::from), LidarrEvent::UpdateAndScanArtist(artist_id) => self .update_and_scan_artist(artist_id)