From 86d93377ac1acdcecd56534fcbf58cd59278c135 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 14:54:16 -0700 Subject: [PATCH] feat(network): Support for fetching Sonarr series history for a given series ID --- src/models/servarr_data/sonarr/sonarr_data.rs | 7 +- src/models/sonarr_models.rs | 2 + src/models/sonarr_models_tests.rs | 20 +- src/network/sonarr_network.rs | 57 ++- src/network/sonarr_network_tests.rs | 444 +++++++++++++++++- 5 files changed, 516 insertions(+), 14 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index d60b52e..c3434ce 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -58,7 +58,6 @@ impl Default for SonarrData { #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] pub enum ActiveSonarrBlock { - #[default] AddRootFolderPrompt, AddSeriesAlreadyInLibrary, AddSeriesConfirmPrompt, @@ -95,6 +94,8 @@ pub enum ActiveSonarrBlock { FilterHistoryError, FilterSeries, FilterSeriesError, + FilterSeriesHistory, + FilterSeriesHistoryError, History, HistoryDetails, HistorySortPrompt, @@ -116,11 +117,15 @@ pub enum ActiveSonarrBlock { SearchSeasonError, SearchSeries, SearchSeriesError, + SearchSeriesHistory, + SearchSeriesHistoryError, SeasonDetails, SeasonHistory, + #[default] Series, SeriesDetails, SeriesHistory, + SeriesHistorySortPrompt, SeriesSortPrompt, System, SystemLogs, diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 6b019ca..2fdae1a 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -349,6 +349,7 @@ pub enum SonarrSerdeable { SecurityConfig(SecurityConfig), SeriesVec(Vec), Series(Series), + SonarrHistoryItems(Vec), SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), @@ -382,6 +383,7 @@ serde_enum_from!( SecurityConfig(SecurityConfig), SeriesVec(Vec), Series(Series), + SonarrHistoryItems(Vec), SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 19676e8..81a2276 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -9,8 +9,8 @@ mod tests { }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryItem, SonarrHistoryWrapper, - SonarrSerdeable, SystemStatus, + IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryItem, SonarrSerdeable, + SystemStatus, }, Serdeable, }; @@ -169,18 +169,16 @@ mod tests { } #[test] - fn test_sonarr_serdeable_from_sonarr_history_wrapper() { - let history_wrapper = SonarrHistoryWrapper { - records: vec![SonarrHistoryItem { - id: 1, - ..SonarrHistoryItem::default() - }], - }; - let sonarr_serdeable: SonarrSerdeable = history_wrapper.clone().into(); + fn test_sonarr_serdeable_from_sonarr_history_items() { + let history_items = vec![SonarrHistoryItem { + id: 1, + ..SonarrHistoryItem::default() + }]; + let sonarr_serdeable: SonarrSerdeable = history_items.clone().into(); assert_eq!( sonarr_serdeable, - SonarrSerdeable::SonarrHistoryWrapper(history_wrapper) + SonarrSerdeable::SonarrHistoryItems(history_items) ); } diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index e7cd2e2..cc8325e 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -14,8 +14,9 @@ use crate::{ }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrHistoryWrapper, SonarrSerdeable, SystemStatus, + SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable, SystemStatus, }, + stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, network::RequestMethod, @@ -46,6 +47,7 @@ pub enum SonarrEvent { GetSeasonReleases(Option<(i64, i64)>), GetSecurityConfig, GetSeriesDetails(Option), + GetSeriesHistory(Option), GetStatus, HealthCheck, ListSeries, @@ -67,6 +69,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", + SonarrEvent::GetSeriesHistory(_) => "/history/series", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", @@ -145,6 +148,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_series_details(series_id) .await .map(SonarrSerdeable::from), + SonarrEvent::GetSeriesHistory(series_id) => self + .get_sonarr_series_history(series_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), SonarrEvent::HealthCheck => self .get_sonarr_healthcheck() @@ -726,6 +733,54 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_series_history( + &mut self, + series_id: Option, + ) -> Result> { + let (id, series_id_param) = self.extract_series_id(series_id).await; + info!("Fetching Sonarr series history for series with ID: {id}"); + let event = SonarrEvent::GetSeriesHistory(series_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(series_id_param), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut history_vec, mut app| { + if app.data.sonarr_data.series_history.is_none() { + app.data.sonarr_data.series_history = Some(StatefulTable::default()); + } + + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::SeriesHistorySortPrompt, _) + ) { + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .set_items(history_vec); + app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .apply_sorting_toggle(false); + } + }) + .await + } + async fn list_series(&mut self) -> Result> { info!("Fetching Sonarr library"); let event = SonarrEvent::ListSeries; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 50adb81..bd5ad47 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -155,6 +155,11 @@ mod test { assert_str_eq!(event.resource(), "/indexer"); } + #[rstest] + fn test_resource_history(#[values(SonarrEvent::GetSeriesHistory(None))] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/history/series"); + } + #[rstest] fn test_resource_release( #[values( @@ -999,7 +1004,6 @@ mod test { .to_lowercase() .cmp(&b.source_title.text.to_lowercase()) }; - let history_sort_option = SortOption { name: "Source Title", cmp_fn: Some(cmp_fn), @@ -2211,6 +2215,444 @@ mod test { } } + #[rstest] + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(None), + None, + Some("seriesId=1"), + ) + .await; + let mut series_history_table = StatefulTable { + sort_asc: true, + ..StatefulTable::default() + }; + if use_custom_sorting { + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + 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), + }; + series_history_table.sorting(vec![history_sort_option]); + } + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc.lock().await.data.sonarr_data.series_history = Some(series_history_table); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event_uses_provided_series_id() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(Some(2)), + None, + Some("seriesId=2"), + ) + .await; + app_arc.lock().await.data.sonarr_data.series_history = Some(StatefulTable { + sort_asc: true, + ..StatefulTable::default() + }); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event_empty_series_history_table() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(None), + None, + Some("seriesId=1"), + ) + .await; + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + let mut series_history_table = StatefulTable { + sort_asc: true, + ..StatefulTable::default() + }; + series_history_table.sorting(vec![history_sort_option]); + app_arc.lock().await.data.sonarr_data.series_history = Some(series_history_table); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::SeriesHistorySortPrompt.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .is_empty()); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + #[tokio::test] async fn test_handle_get_series_event_no_op_while_user_is_selecting_sort_options() { let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap();