diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 46a4d04..8b01276 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -599,6 +599,7 @@ impl Display for SonarrTaskName { #[serde(untagged)] #[allow(clippy::large_enum_variant)] pub enum SonarrSerdeable { + AddSeriesSearchResults(Vec), BlocklistResponse(BlocklistResponse), DownloadsResponse(DownloadsResponse), DiskSpaces(Vec), @@ -641,6 +642,7 @@ impl From<()> for SonarrSerdeable { serde_enum_from!( SonarrSerdeable { + AddSeriesSearchResults(Vec), BlocklistResponse(BlocklistResponse), DownloadsResponse(DownloadsResponse), DiskSpaces(Vec), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 9171342..87ac76d 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -10,9 +10,10 @@ mod tests { RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ - BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, SonarrHistoryEventType, - SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, + AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, + Episode, IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, + SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, + SonarrTaskName, SystemStatus, }, EnumDisplayStyle, Serdeable, }; @@ -327,6 +328,21 @@ mod tests { ); } + #[test] + fn test_sonarr_serdeable_from_add_series_search_results() { + let add_series_search_results = vec![AddSeriesSearchResult { + tvdb_id: 1, + ..AddSeriesSearchResult::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = add_series_search_results.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::AddSeriesSearchResults(add_series_search_results) + ); + } + #[test] fn test_sonarr_serdeable_from_blocklist_response() { let blocklist_response = BlocklistResponse { diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 5093477..1fb3c3b 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -750,33 +750,6 @@ mod test { } } - #[tokio::test] - async fn test_handle_start_radarr_task_event_uses_provided_task_name() { - let response = json!({ "test": "test"}); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "ApplicationCheckUpdate" - })), - Some(response.clone()), - None, - RadarrEvent::StartTask(None), - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Value(value) = network - .handle_radarr_event(RadarrEvent::StartTask(Some(RadarrTaskName::default()))) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(value, response); - } - } - #[tokio::test] async fn test_handle_search_new_movie_event_no_results() { let (async_server, app_arc, _server) = mock_servarr_api( @@ -862,6 +835,33 @@ mod test { ); } + #[tokio::test] + async fn test_handle_start_radarr_task_event_uses_provided_task_name() { + let response = json!({ "test": "test"}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "ApplicationCheckUpdate" + })), + Some(response.clone()), + None, + RadarrEvent::StartTask(None), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Value(value) = network + .handle_radarr_event(RadarrEvent::StartTask(Some(RadarrTaskName::default()))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(value, response); + } + } + #[tokio::test] async fn test_handle_test_radarr_indexer_event_error() { let indexer_details_json = json!({ diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 14001ad..ce767af 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1,7 +1,8 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use indoc::formatdoc; -use log::{debug, info}; +use log::{debug, info, warn}; use serde_json::{json, Value}; +use urlencoding::encode; use crate::{ models::{ @@ -75,6 +76,7 @@ pub enum SonarrEvent { HealthCheck, ListSeries, MarkHistoryItemAsFailed(i64), + SearchNewSeries(Option), StartTask(Option), TestIndexer(Option), TestAllIndexers, @@ -125,6 +127,7 @@ impl NetworkResource for SonarrEvent { | SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) | SonarrEvent::DeleteSeries(_) => "/series", + SonarrEvent::SearchNewSeries(_) => "/series/lookup", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::TestIndexer(_) => "/indexer/test", SonarrEvent::TestAllIndexers => "/indexer/testall", @@ -265,6 +268,10 @@ impl<'a, 'b> Network<'a, 'b> { .mark_sonarr_history_item_as_failed(history_item_id) .await .map(SonarrSerdeable::from), + SonarrEvent::SearchNewSeries(query) => self + .search_sonarr_series(query) + .await + .map(SonarrSerdeable::from), SonarrEvent::StartTask(task_name) => self .start_sonarr_task(task_name) .await @@ -1549,6 +1556,67 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn search_sonarr_series( + &mut self, + query: Option, + ) -> Result> { + info!("Searching for specific Sonarr series"); + let event = SonarrEvent::SearchNewSeries(None); + let search = if let Some(search_query) = query { + Ok(search_query.into()) + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .add_series_search + .clone() + .ok_or(anyhow!("Encountered a race condition")) + }; + + match search { + Ok(search_string) => { + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("term={}", encode(&search_string.text))), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |series_vec, mut app| { + if series_vec.is_empty() { + app.pop_and_push_navigation_stack( + ActiveSonarrBlock::AddSeriesEmptySearchResults.into(), + ); + } else if let Some(add_searched_seriess) = + app.data.sonarr_data.add_searched_series.as_mut() + { + add_searched_seriess.set_items(series_vec); + } else { + let mut add_searched_seriess = StatefulTable::default(); + add_searched_seriess.set_items(series_vec); + app.data.sonarr_data.add_searched_series = Some(add_searched_seriess); + } + }) + .await + } + Err(e) => { + warn!( + "Encountered a race condition: {e}\n \ + This is most likely caused by the user trying to navigate between modals rapidly. \ + Ignoring search request." + ); + Ok(Vec::default()) + } + } + } + async fn start_sonarr_task(&mut self, task: Option) -> Result { let event = SonarrEvent::StartTask(None); let task_name = if let Some(t_name) = task { diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 82349ee..5acfa32 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -5,7 +5,7 @@ mod test { use bimap::BiMap; use chrono::{DateTime, Utc}; use indoc::formatdoc; - use mockito::Matcher; + use mockito::{Matcher, Server}; use pretty_assertions::{assert_eq, assert_str_eq}; use reqwest::Client; use rstest::rstest; @@ -20,7 +20,7 @@ mod test { SeriesMonitor, }; - use crate::app::App; + use crate::app::{App, ServarrConfig}; use crate::models::radarr_models::IndexerTestResult; use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::sonarr::modals::{ @@ -251,6 +251,7 @@ mod test { #[case(SonarrEvent::GetTasks, "/system/task")] #[case(SonarrEvent::GetUpdates, "/update")] #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] + #[case(SonarrEvent::SearchNewSeries(None), "/series/lookup")] #[case(SonarrEvent::TestIndexer(None), "/indexer/test")] #[case(SonarrEvent::TestAllIndexers, "/indexer/testall")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { @@ -4392,6 +4393,189 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_search_new_series_event() { + let add_series_search_result_json = json!([{ + "tvdbId": 1234, + "title": "Test", + "status": "continuing", + "ended": false, + "overview": "New series blah blah blah", + "genres": ["cool", "family", "fun"], + "year": 2023, + "network": "Prime Video", + "runtime": 60, + "ratings": { "votes": 406744, "value": 8.4 }, + "statistics": { "seasonCount": 3 } + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(add_series_search_result_json), + None, + SonarrEvent::SearchNewSeries(None), + None, + Some("term=test%20term"), + ) + .await; + app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::AddSeriesSearchResults(add_series_search_results) = network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .items, + vec![add_series_search_result()] + ); + assert_eq!(add_series_search_results, vec![add_series_search_result()]); + } + } + + #[tokio::test] + async fn test_handle_search_new_series_event_uses_provided_query() { + let add_series_search_result_json = json!([{ + "tvdbId": 1234, + "title": "Test", + "status": "continuing", + "ended": false, + "overview": "New series blah blah blah", + "genres": ["cool", "family", "fun"], + "year": 2023, + "network": "Prime Video", + "runtime": 60, + "ratings": { "votes": 406744, "value": 8.4 }, + "statistics": { "seasonCount": 3 } + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(add_series_search_result_json), + None, + SonarrEvent::SearchNewSeries(None), + None, + Some("term=test%20term"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::AddSeriesSearchResults(add_series_search_results) = network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(Some("test term".into()))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(add_series_search_results, vec![add_series_search_result()]); + } + } + + #[tokio::test] + async fn test_handle_search_new_series_event_no_results() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([])), + None, + SonarrEvent::SearchNewSeries(None), + None, + Some("term=test%20term"), + ) + .await; + app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .is_none()); + assert_eq!( + app_arc.lock().await.get_current_route(), + &ActiveSonarrBlock::AddSeriesEmptySearchResults.into() + ); + } + + #[tokio::test] + async fn test_handle_search_new_series_event_no_panic_on_race_condition() { + let resource = format!( + "{}?term=test%20term", + SonarrEvent::SearchNewSeries(None).resource() + ); + let mut server = Server::new_async().await; + let mut async_server = server + .mock( + &RequestMethod::Get.to_string().to_uppercase(), + format!("/api/v3{resource}").as_str(), + ) + .match_header("X-Api-Key", "test1234"); + async_server = async_server.expect_at_most(0).create_async().await; + + let host = Some(server.host_with_port().split(':').collect::>()[0].to_owned()); + let port = Some( + server.host_with_port().split(':').collect::>()[1] + .parse() + .unwrap(), + ); + let mut app = App::default(); + let sonarr_config = ServarrConfig { + host, + port, + api_token: "test1234".to_owned(), + ..ServarrConfig::default() + }; + app.config.sonarr = Some(sonarr_config); + let app_arc = Arc::new(Mutex::new(app)); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::Series.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .is_none()); + assert_eq!( + app_arc.lock().await.get_current_route(), + &ActiveSonarrBlock::Series.into() + ); + } + #[tokio::test] async fn test_handle_start_sonarr_task_event() { let response = json!({ "test": "test"});