From 6da1ae93ef68a58a1573e9ff0424dfcb2ee93d5e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 14:06:44 -0700 Subject: [PATCH] feat(network): Support to fetch all Sonarr history events --- src/models/servarr_data/sonarr/sonarr_data.rs | 3 +- src/models/sonarr_models.rs | 2 + src/models/sonarr_models_tests.rs | 19 +- src/network/sonarr_network.rs | 35 +- src/network/sonarr_network_tests.rs | 338 +++++++++++++++++- 5 files changed, 392 insertions(+), 5 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 99ee413..d60b52e 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -6,7 +6,6 @@ use crate::models::{ servarr_models::{Indexer, QueueEvent}, sonarr_models::{ BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, - SonarrHistoryWrapper, }, stateful_list::StatefulList, stateful_table::StatefulTable, @@ -22,7 +21,7 @@ mod sonarr_data_tests; pub struct SonarrData { pub blocklist: StatefulTable, pub downloads: StatefulTable, - pub history: StatefulTable, + pub history: StatefulTable, pub indexers: StatefulTable, pub indexer_settings: Option, pub logs: StatefulList, diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 07ebf9d..6b019ca 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), + SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), @@ -381,6 +382,7 @@ serde_enum_from!( SecurityConfig(SecurityConfig), SeriesVec(Vec), Series(Series), + SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 2fbf265..19676e8 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -9,7 +9,8 @@ mod tests { }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, + IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryItem, SonarrHistoryWrapper, + SonarrSerdeable, SystemStatus, }, Serdeable, }; @@ -167,6 +168,22 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Series(series)); } + #[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(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::SonarrHistoryWrapper(history_wrapper) + ); + } + #[test] fn test_sonarr_serdeable_from_system_status() { let system_status = SystemStatus { diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index dd924a6..b81e851 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -14,7 +14,7 @@ use crate::{ }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrSerdeable, SystemStatus, + SonarrHistoryWrapper, SonarrSerdeable, SystemStatus, }, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, @@ -34,6 +34,7 @@ pub enum SonarrEvent { GetAllIndexerSettings, GetBlocklist, GetDownloads, + GetHistory(Option), GetHostConfig, GetIndexers, GetEpisodeDetails(Option), @@ -59,6 +60,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetDownloads => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", + SonarrEvent::GetHistory(_) => "/history", SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", SonarrEvent::GetIndexers => "/indexer", SonarrEvent::GetLogs(_) => "/log", @@ -106,6 +108,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_episode_details(episode_id) .await .map(SonarrSerdeable::from), + SonarrEvent::GetHistory(items) => self + .get_sonarr_history(items) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetHostConfig => self .get_sonarr_host_config() .await @@ -461,6 +467,33 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_history(&mut self, items: Option) -> Result { + info!("Fetching all Sonarr history events"); + let event = SonarrEvent::GetHistory(items); + + let params = format!( + "pageSize={}&sortDirection=descending&sortKey=time", + items.unwrap_or(500) + ); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), SonarrHistoryWrapper>(request_props, |history_response, mut app| { + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::HistorySortPrompt, _) + ) { + let mut history_vec = history_response.records; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.sonarr_data.history.set_items(history_vec); + app.data.sonarr_data.history.apply_sorting_toggle(false); + } + }) + .await + } + async fn get_sonarr_indexers(&mut self) -> Result> { info!("Fetching Sonarr indexers"); let event = SonarrEvent::GetIndexers; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index cfb3033..50adb81 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -20,11 +20,13 @@ mod test { HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, QualityWrapper, QueueEvent, Release, SecurityConfig, }; - use crate::models::sonarr_models::BlocklistResponse; use crate::models::sonarr_models::SystemStatus; use crate::models::sonarr_models::{ BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, MediaInfo, }; + use crate::models::sonarr_models::{ + BlocklistResponse, SonarrHistoryData, SonarrHistoryItem, SonarrHistoryWrapper, + }; use crate::models::stateful_table::StatefulTable; use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; @@ -170,6 +172,7 @@ mod test { #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(SonarrEvent::GetDownloads, "/queue")] + #[case(SonarrEvent::GetHistory(None), "/history")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] @@ -350,6 +353,78 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_blocklist_event_no_op_when_user_is_selecting_sort_options() { + let blocklist_json = json!({"records": [{ + "seriesId": 1007, + "episodeIds": [42020], + "sourceTitle": "z series", + "language": { "id": 1, "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" }}, + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 123 + }, + { + "seriesId": 2001, + "episodeIds": [42018], + "sourceTitle": "A Series", + "language": { "id": 1, "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" }}, + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 456 + }]}); + let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(blocklist_json), + None, + SonarrEvent::GetBlocklist, + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.blocklist.sort_asc = true; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + let blocklist_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::BlocklistResponse(blocklist) = network + .handle_sonarr_event(SonarrEvent::GetBlocklist) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc.lock().await.data.sonarr_data.blocklist.is_empty()); + assert!(app_arc.lock().await.data.sonarr_data.blocklist.sort_asc); + assert_eq!(blocklist, response); + } + } + #[tokio::test] async fn test_handle_get_sonarr_downloads_event() { let downloads_response_json = json!({ @@ -710,6 +785,246 @@ mod test { } } + #[rstest] + #[tokio::test] + async fn test_handle_get_sonarr_history_event(#[values(true, false)] use_custom_sorting: bool) { + let history_json = json!({"records": [{ + "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: SonarrHistoryWrapper = 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::GetHistory(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), + ) + .await; + app_arc.lock().await.data.sonarr_data.history.sort_asc = true; + 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), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .history + .sorting(vec![history_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.history.items, + expected_history_items + ); + assert!(app_arc.lock().await.data.sonarr_data.history.sort_asc); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_history_event_uses_provided_items() { + let history_json = json!({"records": [{ + "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: SonarrHistoryWrapper = 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::GetHistory(Some(1000)), + None, + Some("pageSize=1000&sortDirection=descending&sortKey=time"), + ) + .await; + app_arc.lock().await.data.sonarr_data.history.sort_asc = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(Some(1000))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.history.items, + expected_history_items + ); + assert!(app_arc.lock().await.data.sonarr_data.history.sort_asc); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!({"records": [{ + "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: SonarrHistoryWrapper = 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::GetHistory(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), + ) + .await; + app_arc.lock().await.data.sonarr_data.history.sort_asc = true; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); + 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), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .history + .sorting(vec![history_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc.lock().await.data.sonarr_data.history.is_empty()); + assert!(app_arc.lock().await.data.sonarr_data.history.sort_asc); + assert_eq!(history, response); + } + } + #[tokio::test] async fn test_handle_get_sonarr_indexers_event() { let indexers_response_json = json!([{ @@ -2326,6 +2641,27 @@ mod test { } } + fn history_data() -> SonarrHistoryData { + SonarrHistoryData { + dropped_path: "/nfs/nzbget/completed/series/Coolness/something.cool.mkv".to_owned(), + imported_path: + "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv".to_owned(), + } + } + + fn history_item() -> SonarrHistoryItem { + SonarrHistoryItem { + id: 1, + source_title: "Test source".into(), + episode_id: 1, + quality: quality_wrapper(), + languages: vec![language()], + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + event_type: "grabbed".into(), + data: history_data(), + } + } + fn indexer() -> Indexer { Indexer { enable_rss: true,