From 11457736e6076874107584a353ae9366a27ef42c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 14 Aug 2025 13:14:23 -0600 Subject: [PATCH] refactor: Network module is now broken out into similar directory structures for each servarr to mimic the rest of the project to make it easier to develop and maintain --- src/network/radarr_network.rs | 1954 ------ src/network/radarr_network/blocklist/mod.rs | 93 + .../radarr_blocklist_network_tests.rs | 354 + src/network/radarr_network/collections/mod.rs | 154 + .../radarr_collections_network_tests.rs | 443 ++ src/network/radarr_network/downloads/mod.rs | 81 + .../radarr_downloads_network_tests.rs | 100 + src/network/radarr_network/indexers/mod.rs | 396 ++ .../indexers/radarr_indexers_network_tests.rs | 980 +++ src/network/radarr_network/library/mod.rs | 675 ++ .../library/radarr_library_network_tests.rs | 1235 ++++ src/network/radarr_network/mod.rs | 420 ++ .../radarr_network_test_utils.rs | 381 ++ .../radarr_network/radarr_network_tests.rs | 363 + .../radarr_network/root_folders/mod.rs | 75 + .../radarr_root_folders_network_tests.rs | 97 + src/network/radarr_network/system/mod.rs | 268 + .../system/radarr_system_network_tests.rs | 477 ++ src/network/radarr_network_tests.rs | 4304 ------------ src/network/sonarr_network.rs | 2419 ------- src/network/sonarr_network/blocklist/mod.rs | 112 + .../sonarr_blocklist_network_tests.rs | 270 + src/network/sonarr_network/downloads/mod.rs | 81 + .../sonarr_downloads_network_tests.rs | 113 + src/network/sonarr_network/history/mod.rs | 63 + .../history/sonarr_history_network_tests.rs | 210 + src/network/sonarr_network/indexers/mod.rs | 394 ++ .../indexers/sonarr_indexers_network_tests.rs | 930 +++ .../sonarr_network/library/episodes/mod.rs | 498 ++ .../episodes/sonarr_episodes_network_tests.rs | 1377 ++++ src/network/sonarr_network/library/mod.rs | 38 + .../sonarr_network/library/seasons/mod.rs | 199 + .../seasons/sonarr_seasons_network_tests.rs | 515 ++ .../sonarr_network/library/series/mod.rs | 450 ++ .../series/sonarr_series_network_tests.rs | 1106 ++++ .../library/sonarr_library_network_tests.rs | 43 + src/network/sonarr_network/mod.rs | 503 ++ .../sonarr_network/root_folders/mod.rs | 75 + .../sonarr_root_folders_network_tests.rs | 107 + .../sonarr_network_test_utils.rs | 395 ++ .../sonarr_network/sonarr_network_tests.rs | 434 ++ src/network/sonarr_network/system/mod.rs | 266 + .../system/sonarr_system_network_tests.rs | 492 ++ src/network/sonarr_network_tests.rs | 5840 ----------------- 44 files changed, 15263 insertions(+), 14517 deletions(-) delete mode 100644 src/network/radarr_network.rs create mode 100644 src/network/radarr_network/blocklist/mod.rs create mode 100644 src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs create mode 100644 src/network/radarr_network/collections/mod.rs create mode 100644 src/network/radarr_network/collections/radarr_collections_network_tests.rs create mode 100644 src/network/radarr_network/downloads/mod.rs create mode 100644 src/network/radarr_network/downloads/radarr_downloads_network_tests.rs create mode 100644 src/network/radarr_network/indexers/mod.rs create mode 100644 src/network/radarr_network/indexers/radarr_indexers_network_tests.rs create mode 100644 src/network/radarr_network/library/mod.rs create mode 100644 src/network/radarr_network/library/radarr_library_network_tests.rs create mode 100644 src/network/radarr_network/mod.rs create mode 100644 src/network/radarr_network/radarr_network_test_utils.rs create mode 100644 src/network/radarr_network/radarr_network_tests.rs create mode 100644 src/network/radarr_network/root_folders/mod.rs create mode 100644 src/network/radarr_network/root_folders/radarr_root_folders_network_tests.rs create mode 100644 src/network/radarr_network/system/mod.rs create mode 100644 src/network/radarr_network/system/radarr_system_network_tests.rs delete mode 100644 src/network/radarr_network_tests.rs delete mode 100644 src/network/sonarr_network.rs create mode 100644 src/network/sonarr_network/blocklist/mod.rs create mode 100644 src/network/sonarr_network/blocklist/sonarr_blocklist_network_tests.rs create mode 100644 src/network/sonarr_network/downloads/mod.rs create mode 100644 src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs create mode 100644 src/network/sonarr_network/history/mod.rs create mode 100644 src/network/sonarr_network/history/sonarr_history_network_tests.rs create mode 100644 src/network/sonarr_network/indexers/mod.rs create mode 100644 src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs create mode 100644 src/network/sonarr_network/library/episodes/mod.rs create mode 100644 src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs create mode 100644 src/network/sonarr_network/library/mod.rs create mode 100644 src/network/sonarr_network/library/seasons/mod.rs create mode 100644 src/network/sonarr_network/library/seasons/sonarr_seasons_network_tests.rs create mode 100644 src/network/sonarr_network/library/series/mod.rs create mode 100644 src/network/sonarr_network/library/series/sonarr_series_network_tests.rs create mode 100644 src/network/sonarr_network/library/sonarr_library_network_tests.rs create mode 100644 src/network/sonarr_network/mod.rs create mode 100644 src/network/sonarr_network/root_folders/mod.rs create mode 100644 src/network/sonarr_network/root_folders/sonarr_root_folders_network_tests.rs create mode 100644 src/network/sonarr_network/sonarr_network_test_utils.rs create mode 100644 src/network/sonarr_network/sonarr_network_tests.rs create mode 100644 src/network/sonarr_network/system/mod.rs create mode 100644 src/network/sonarr_network/system/sonarr_system_network_tests.rs delete mode 100644 src/network/sonarr_network_tests.rs diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs deleted file mode 100644 index 4235667..0000000 --- a/src/network/radarr_network.rs +++ /dev/null @@ -1,1954 +0,0 @@ -use anyhow::Result; -use std::fmt::Debug; - -use indoc::formatdoc; -use log::{debug, info, warn}; -use serde_json::{json, Value}; -use urlencoding::encode; - -use crate::models::radarr_models::{ - AddMovieBody, AddMovieSearchResult, BlocklistResponse, Collection, Credit, CreditType, - DeleteMovieParams, DownloadRecord, DownloadsResponse, EditCollectionParams, EditMovieParams, - IndexerSettings, Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease, - RadarrReleaseDownloadBody, RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus, -}; -use crate::models::servarr_data::modals::IndexerTestResultModalItem; -use crate::models::servarr_data::radarr::modals::MovieDetailsModal; -use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; -use crate::models::servarr_models::{ - AddRootFolderBody, CommandBody, DiskSpace, EditIndexerParams, HostConfig, Indexer, - IndexerTestResult, LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, - Update, -}; -use crate::models::stateful_table::StatefulTable; -use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; -use crate::network::{Network, NetworkEvent, RequestMethod}; -use crate::utils::{convert_runtime, convert_to_gb}; - -use super::NetworkResource; - -#[cfg(test)] -#[path = "radarr_network_tests.rs"] -mod radarr_network_tests; - -#[derive(Debug, Eq, PartialEq, Clone)] -pub enum RadarrEvent { - AddMovie(AddMovieBody), - AddRootFolder(AddRootFolderBody), - AddTag(String), - ClearBlocklist, - DeleteBlocklistItem(i64), - DeleteDownload(i64), - DeleteIndexer(i64), - DeleteMovie(DeleteMovieParams), - DeleteRootFolder(i64), - DeleteTag(i64), - DownloadRelease(RadarrReleaseDownloadBody), - EditAllIndexerSettings(IndexerSettings), - EditCollection(EditCollectionParams), - EditIndexer(EditIndexerParams), - EditMovie(EditMovieParams), - GetBlocklist, - GetCollections, - GetDownloads(u64), - GetHostConfig, - GetIndexers, - GetAllIndexerSettings, - GetLogs(u64), - GetMovieCredits(i64), - GetMovieDetails(i64), - GetMovieHistory(i64), - GetMovies, - GetDiskSpace, - GetQualityProfiles, - GetQueuedEvents, - GetReleases(i64), - GetRootFolders, - GetSecurityConfig, - GetStatus, - GetTags, - GetTasks, - GetUpdates, - HealthCheck, - SearchNewMovie(String), - StartTask(RadarrTaskName), - TestIndexer(i64), - TestAllIndexers, - ToggleMovieMonitoring(i64), - TriggerAutomaticSearch(i64), - UpdateAllMovies, - UpdateAndScan(i64), - UpdateCollections, - UpdateDownloads, -} - -impl NetworkResource for RadarrEvent { - fn resource(&self) -> &'static str { - match &self { - RadarrEvent::ClearBlocklist => "/blocklist/bulk", - RadarrEvent::DeleteBlocklistItem(_) => "/blocklist", - RadarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", - RadarrEvent::GetCollections | RadarrEvent::EditCollection(_) => "/collection", - RadarrEvent::GetDownloads(_) | RadarrEvent::DeleteDownload(_) => "/queue", - RadarrEvent::GetHostConfig | RadarrEvent::GetSecurityConfig => "/config/host", - RadarrEvent::GetIndexers | RadarrEvent::EditIndexer(_) | RadarrEvent::DeleteIndexer(_) => { - "/indexer" - } - RadarrEvent::GetAllIndexerSettings | RadarrEvent::EditAllIndexerSettings(_) => { - "/config/indexer" - } - RadarrEvent::GetLogs(_) => "/log", - RadarrEvent::AddMovie(_) - | RadarrEvent::EditMovie(_) - | RadarrEvent::GetMovies - | RadarrEvent::GetMovieDetails(_) - | RadarrEvent::DeleteMovie(_) - | RadarrEvent::ToggleMovieMonitoring(_) => "/movie", - RadarrEvent::SearchNewMovie(_) => "/movie/lookup", - RadarrEvent::GetMovieCredits(_) => "/credit", - RadarrEvent::GetMovieHistory(_) => "/history/movie", - RadarrEvent::GetDiskSpace => "/diskspace", - RadarrEvent::GetQualityProfiles => "/qualityprofile", - RadarrEvent::GetReleases(_) | RadarrEvent::DownloadRelease(_) => "/release", - RadarrEvent::AddRootFolder(_) - | RadarrEvent::GetRootFolders - | RadarrEvent::DeleteRootFolder(_) => "/rootfolder", - RadarrEvent::GetStatus => "/system/status", - RadarrEvent::GetTags | RadarrEvent::AddTag(_) | RadarrEvent::DeleteTag(_) => "/tag", - RadarrEvent::GetTasks => "/system/task", - RadarrEvent::GetUpdates => "/update", - RadarrEvent::TestIndexer(_) => "/indexer/test", - RadarrEvent::TestAllIndexers => "/indexer/testall", - RadarrEvent::StartTask(_) - | RadarrEvent::GetQueuedEvents - | RadarrEvent::TriggerAutomaticSearch(_) - | RadarrEvent::UpdateAndScan(_) - | RadarrEvent::UpdateAllMovies - | RadarrEvent::UpdateDownloads - | RadarrEvent::UpdateCollections => "/command", - RadarrEvent::HealthCheck => "/health", - } - } -} - -impl From for NetworkEvent { - fn from(radarr_event: RadarrEvent) -> Self { - NetworkEvent::Radarr(radarr_event) - } -} - -impl Network<'_, '_> { - pub async fn handle_radarr_event( - &mut self, - radarr_event: RadarrEvent, - ) -> Result { - match radarr_event { - RadarrEvent::AddMovie(body) => self.add_movie(body).await.map(RadarrSerdeable::from), - RadarrEvent::AddRootFolder(path) => self - .add_radarr_root_folder(path) - .await - .map(RadarrSerdeable::from), - RadarrEvent::AddTag(tag) => self.add_radarr_tag(tag).await.map(RadarrSerdeable::from), - RadarrEvent::ClearBlocklist => self - .clear_radarr_blocklist() - .await - .map(RadarrSerdeable::from), - RadarrEvent::DeleteBlocklistItem(blocklist_item_id) => self - .delete_radarr_blocklist_item(blocklist_item_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::DeleteDownload(download_id) => self - .delete_radarr_download(download_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::DeleteIndexer(indexer_id) => self - .delete_radarr_indexer(indexer_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::DeleteMovie(params) => { - self.delete_movie(params).await.map(RadarrSerdeable::from) - } - RadarrEvent::DeleteRootFolder(root_folder_id) => self - .delete_radarr_root_folder(root_folder_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::DeleteTag(tag_id) => self - .delete_radarr_tag(tag_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::DownloadRelease(params) => self - .download_radarr_release(params) - .await - .map(RadarrSerdeable::from), - RadarrEvent::EditAllIndexerSettings(params) => self - .edit_all_radarr_indexer_settings(params) - .await - .map(RadarrSerdeable::from), - RadarrEvent::EditCollection(params) => self - .edit_collection(params) - .await - .map(RadarrSerdeable::from), - RadarrEvent::EditIndexer(params) => self - .edit_radarr_indexer(params) - .await - .map(RadarrSerdeable::from), - RadarrEvent::EditMovie(params) => self.edit_movie(params).await.map(RadarrSerdeable::from), - RadarrEvent::GetAllIndexerSettings => self - .get_all_radarr_indexer_settings() - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetBlocklist => self.get_radarr_blocklist().await.map(RadarrSerdeable::from), - RadarrEvent::GetCollections => self.get_collections().await.map(RadarrSerdeable::from), - RadarrEvent::GetDownloads(count) => self - .get_radarr_downloads(count) - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetHostConfig => self - .get_radarr_host_config() - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetIndexers => self.get_radarr_indexers().await.map(RadarrSerdeable::from), - RadarrEvent::GetLogs(events) => self - .get_radarr_logs(events) - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetMovieCredits(movie_id) => { - self.get_credits(movie_id).await.map(RadarrSerdeable::from) - } - RadarrEvent::GetMovieDetails(movie_id) => self - .get_movie_details(movie_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetMovieHistory(movie_id) => self - .get_movie_history(movie_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetMovies => self.get_movies().await.map(RadarrSerdeable::from), - RadarrEvent::GetDiskSpace => self.get_radarr_diskspace().await.map(RadarrSerdeable::from), - RadarrEvent::GetQualityProfiles => self - .get_radarr_quality_profiles() - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetQueuedEvents => self - .get_queued_radarr_events() - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetReleases(movie_id) => self - .get_movie_releases(movie_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetRootFolders => self - .get_radarr_root_folders() - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetSecurityConfig => self - .get_radarr_security_config() - .await - .map(RadarrSerdeable::from), - RadarrEvent::GetStatus => self.get_radarr_status().await.map(RadarrSerdeable::from), - RadarrEvent::GetTags => self.get_radarr_tags().await.map(RadarrSerdeable::from), - RadarrEvent::GetTasks => self.get_radarr_tasks().await.map(RadarrSerdeable::from), - RadarrEvent::GetUpdates => self.get_radarr_updates().await.map(RadarrSerdeable::from), - RadarrEvent::HealthCheck => self - .get_radarr_healthcheck() - .await - .map(RadarrSerdeable::from), - RadarrEvent::SearchNewMovie(query) => { - self.search_movie(query).await.map(RadarrSerdeable::from) - } - RadarrEvent::StartTask(task_name) => self - .start_radarr_task(task_name) - .await - .map(RadarrSerdeable::from), - RadarrEvent::TestIndexer(indexer_id) => self - .test_radarr_indexer(indexer_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::TestAllIndexers => self - .test_all_radarr_indexers() - .await - .map(RadarrSerdeable::from), - RadarrEvent::ToggleMovieMonitoring(movie_id) => self - .toggle_movie_monitoring(movie_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::TriggerAutomaticSearch(movie_id) => self - .trigger_automatic_movie_search(movie_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::UpdateAllMovies => self.update_all_movies().await.map(RadarrSerdeable::from), - RadarrEvent::UpdateAndScan(movie_id) => self - .update_and_scan_movie(movie_id) - .await - .map(RadarrSerdeable::from), - RadarrEvent::UpdateCollections => self.update_collections().await.map(RadarrSerdeable::from), - RadarrEvent::UpdateDownloads => self - .update_radarr_downloads() - .await - .map(RadarrSerdeable::from), - } - } - - async fn add_movie(&mut self, mut add_movie_body: AddMovieBody) -> Result { - info!("Adding new movie to Radarr"); - let event = RadarrEvent::AddMovie(AddMovieBody::default()); - if let Some(tag_input_str) = add_movie_body.tag_input_string.as_ref() { - let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tag_input_str).await; - add_movie_body.tags = tag_ids_vec; - } - - debug!("Add movie body: {add_movie_body:?}"); - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(add_movie_body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn add_radarr_root_folder( - &mut self, - add_root_folder_body: AddRootFolderBody, - ) -> Result { - info!("Adding new root folder to Radarr"); - let event = RadarrEvent::AddRootFolder(AddRootFolderBody::default()); - - debug!("Add root folder body: {add_root_folder_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Post, - Some(add_root_folder_body), - None, - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn add_radarr_tag(&mut self, tag: String) -> Result { - info!("Adding a new Radarr tag"); - let event = RadarrEvent::AddTag(String::new()); - - let request_props = self - .request_props_from( - event, - RequestMethod::Post, - Some(json!({ "label": tag })), - None, - None, - ) - .await; - - self - .handle_request::(request_props, |tag, mut app| { - app.data.radarr_data.tags_map.insert(tag.id, tag.label); - }) - .await - } - - async fn delete_radarr_tag(&mut self, id: i64) -> Result<()> { - info!("Deleting Radarr tag with id: {id}"); - let event = RadarrEvent::DeleteTag(id); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn clear_radarr_blocklist(&mut self) -> Result<()> { - info!("Clearing Radarr blocklist"); - let event = RadarrEvent::ClearBlocklist; - - let ids = self - .app - .lock() - .await - .data - .radarr_data - .blocklist - .items - .iter() - .map(|item| item.id) - .collect::>(); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - Some(json!({"ids": ids})), - None, - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn delete_radarr_blocklist_item(&mut self, blocklist_item_id: i64) -> Result<()> { - let event = RadarrEvent::DeleteBlocklistItem(blocklist_item_id); - - info!("Deleting Radarr blocklist item for item with id: {blocklist_item_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{blocklist_item_id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_radarr_download(&mut self, download_id: i64) -> Result<()> { - let event = RadarrEvent::DeleteDownload(download_id); - info!("Deleting Radarr download for download with id: {download_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{download_id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_radarr_indexer(&mut self, indexer_id: i64) -> Result<()> { - let event = RadarrEvent::DeleteIndexer(indexer_id); - info!("Deleting Radarr indexer for indexer with id: {indexer_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{indexer_id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_movie(&mut self, delete_movie_params: DeleteMovieParams) -> Result<()> { - let event = RadarrEvent::DeleteMovie(DeleteMovieParams::default()); - let DeleteMovieParams { - id, - delete_movie_files, - add_list_exclusion, - } = delete_movie_params; - info!("Deleting Radarr movie with ID: {id} with deleteFiles={delete_movie_files} and addImportExclusion={add_list_exclusion}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{id}")), - Some(format!( - "deleteFiles={delete_movie_files}&addImportExclusion={add_list_exclusion}" - )), - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_radarr_root_folder(&mut self, root_folder_id: i64) -> Result<()> { - let event = RadarrEvent::DeleteRootFolder(root_folder_id); - info!("Deleting Radarr root folder for folder with id: {root_folder_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{root_folder_id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn download_radarr_release(&mut self, params: RadarrReleaseDownloadBody) -> Result { - let event = RadarrEvent::DownloadRelease(RadarrReleaseDownloadBody::default()); - info!("Downloading Radarr release with params: {params:?}"); - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(params), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn edit_all_radarr_indexer_settings(&mut self, params: IndexerSettings) -> Result { - info!("Updating Radarr indexer settings"); - let event = RadarrEvent::EditAllIndexerSettings(IndexerSettings::default()); - - debug!("Indexer settings body: {params:?}"); - - let request_props = self - .request_props_from(event, RequestMethod::Put, Some(params), None, None) - .await; - - self - .handle_request::(request_props, |_, _| {}) - .await - } - - async fn edit_collection(&mut self, edit_collection_params: EditCollectionParams) -> Result<()> { - info!("Editing Radarr collection"); - let detail_event = RadarrEvent::GetCollections; - let event = RadarrEvent::EditCollection(EditCollectionParams::default()); - info!("Fetching collection details"); - let collection_id = edit_collection_params.collection_id; - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{collection_id}")), - None, - ) - .await; - - let mut response = String::new(); - - self - .handle_request::<(), Value>(request_props, |detailed_collection_body, _| { - response = detailed_collection_body.to_string() - }) - .await?; - - info!("Constructing edit collection body"); - - let mut detailed_collection_body: Value = serde_json::from_str(&response)?; - let (monitored, minimum_availability, quality_profile_id, root_folder_path, search_on_add) = { - let monitored = edit_collection_params.monitored.unwrap_or_else(|| { - detailed_collection_body["monitored"] - .as_bool() - .expect("Unable to deserialize 'monitored' bool") - }); - let minimum_availability = edit_collection_params - .minimum_availability - .unwrap_or_else(|| { - serde_json::from_value(detailed_collection_body["minimumAvailability"].clone()) - .expect("Unable to deserialize 'minimumAvailability'") - }) - .to_string(); - let quality_profile_id = edit_collection_params - .quality_profile_id - .unwrap_or_else(|| { - detailed_collection_body["qualityProfileId"] - .as_i64() - .expect("Unable to deserialize 'qualityProfileId'") - }); - let root_folder_path = edit_collection_params.root_folder_path.unwrap_or_else(|| { - detailed_collection_body["rootFolderPath"] - .as_str() - .expect("Unable to deserialize 'rootFolderPath'") - .to_owned() - }); - let search_on_add = edit_collection_params.search_on_add.unwrap_or_else(|| { - detailed_collection_body["searchOnAdd"] - .as_bool() - .expect("Unable to deserialize 'searchOnAdd'") - }); - - ( - monitored, - minimum_availability, - quality_profile_id, - root_folder_path, - search_on_add, - ) - }; - - *detailed_collection_body.get_mut("monitored").unwrap() = json!(monitored); - *detailed_collection_body - .get_mut("minimumAvailability") - .unwrap() = json!(minimum_availability); - *detailed_collection_body - .get_mut("qualityProfileId") - .unwrap() = json!(quality_profile_id); - *detailed_collection_body.get_mut("rootFolderPath").unwrap() = json!(root_folder_path); - *detailed_collection_body.get_mut("searchOnAdd").unwrap() = json!(search_on_add); - - debug!("Edit collection body: {detailed_collection_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Put, - Some(detailed_collection_body), - Some(format!("/{collection_id}")), - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn edit_radarr_indexer( - &mut self, - mut edit_indexer_params: EditIndexerParams, - ) -> Result<()> { - let detail_event = RadarrEvent::GetIndexers; - let event = RadarrEvent::EditIndexer(EditIndexerParams::default()); - let id = edit_indexer_params.indexer_id; - if let Some(tag_input_str) = edit_indexer_params.tag_input_string.as_ref() { - let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tag_input_str).await; - edit_indexer_params.tags = Some(tag_ids_vec); - } - info!("Updating Radarr indexer with ID: {id}"); - - info!("Fetching indexer details for indexer with ID: {id}"); - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{id}")), - None, - ) - .await; - - let mut response = String::new(); - - self - .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { - response = detailed_indexer_body.to_string() - }) - .await?; - - info!("Constructing edit indexer body"); - - let mut detailed_indexer_body: Value = serde_json::from_str(&response)?; - - let ( - name, - enable_rss, - enable_automatic_search, - enable_interactive_search, - url, - api_key, - seed_ratio, - tags, - priority, - ) = { - let priority = detailed_indexer_body["priority"] - .as_i64() - .expect("Unable to deserialize 'priority'"); - let seed_ratio_field_option = detailed_indexer_body["fields"] - .as_array() - .unwrap() - .iter() - .find(|field| field["name"] == "seedCriteria.seedRatio"); - let name = edit_indexer_params.name.unwrap_or( - detailed_indexer_body["name"] - .as_str() - .expect("Unable to deserialize 'name'") - .to_owned(), - ); - let enable_rss = edit_indexer_params.enable_rss.unwrap_or( - detailed_indexer_body["enableRss"] - .as_bool() - .expect("Unable to deserialize 'enableRss'"), - ); - let enable_automatic_search = edit_indexer_params.enable_automatic_search.unwrap_or( - detailed_indexer_body["enableAutomaticSearch"] - .as_bool() - .expect("Unable to deserialize 'enableAutomaticSearch"), - ); - let enable_interactive_search = edit_indexer_params.enable_interactive_search.unwrap_or( - detailed_indexer_body["enableInteractiveSearch"] - .as_bool() - .expect("Unable to deserialize 'enableInteractiveSearch'"), - ); - let url = edit_indexer_params.url.unwrap_or( - detailed_indexer_body["fields"] - .as_array() - .expect("Unable to deserialize 'fields'") - .iter() - .find(|field| field["name"] == "baseUrl") - .expect("Field 'baseUrl' was not found in the 'fields' array") - .get("value") - .unwrap_or(&json!("")) - .as_str() - .expect("Unable to deserialize 'baseUrl value'") - .to_owned(), - ); - let api_key = edit_indexer_params.api_key.unwrap_or( - detailed_indexer_body["fields"] - .as_array() - .expect("Unable to deserialize 'fields'") - .iter() - .find(|field| field["name"] == "apiKey") - .expect("Field 'apiKey' was not found in the 'fields' array") - .get("value") - .unwrap_or(&json!("")) - .as_str() - .expect("Unable to deserialize 'apiKey value'") - .to_owned(), - ); - let seed_ratio = edit_indexer_params.seed_ratio.unwrap_or_else(|| { - if let Some(seed_ratio_field) = seed_ratio_field_option { - return seed_ratio_field - .get("value") - .unwrap_or(&json!("")) - .as_str() - .expect("Unable to deserialize 'seedCriteria.seedRatio value'") - .to_owned(); - } - - String::new() - }); - let tags = if edit_indexer_params.clear_tags { - vec![] - } else { - edit_indexer_params.tags.unwrap_or( - detailed_indexer_body["tags"] - .as_array() - .expect("Unable to deserialize 'tags'") - .iter() - .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) - .collect(), - ) - }; - let priority = edit_indexer_params.priority.unwrap_or(priority); - - ( - name, - enable_rss, - enable_automatic_search, - enable_interactive_search, - url, - api_key, - seed_ratio, - tags, - priority, - ) - }; - - *detailed_indexer_body.get_mut("name").unwrap() = json!(name); - *detailed_indexer_body.get_mut("priority").unwrap() = json!(priority); - *detailed_indexer_body.get_mut("enableRss").unwrap() = json!(enable_rss); - *detailed_indexer_body - .get_mut("enableAutomaticSearch") - .unwrap() = json!(enable_automatic_search); - *detailed_indexer_body - .get_mut("enableInteractiveSearch") - .unwrap() = json!(enable_interactive_search); - *detailed_indexer_body - .get_mut("fields") - .unwrap() - .as_array_mut() - .unwrap() - .iter_mut() - .find(|field| field["name"] == "baseUrl") - .unwrap() - .get_mut("value") - .unwrap() = json!(url); - *detailed_indexer_body - .get_mut("fields") - .unwrap() - .as_array_mut() - .unwrap() - .iter_mut() - .find(|field| field["name"] == "apiKey") - .unwrap() - .get_mut("value") - .unwrap() = json!(api_key); - *detailed_indexer_body.get_mut("tags").unwrap() = json!(tags); - let seed_ratio_field_option = detailed_indexer_body - .get_mut("fields") - .unwrap() - .as_array_mut() - .unwrap() - .iter_mut() - .find(|field| field["name"] == "seedCriteria.seedRatio"); - if let Some(seed_ratio_field) = seed_ratio_field_option { - seed_ratio_field - .as_object_mut() - .unwrap() - .insert("value".to_string(), json!(seed_ratio)); - } - - debug!("Edit indexer body: {detailed_indexer_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Put, - Some(detailed_indexer_body), - Some(format!("/{id}")), - Some("forceSave=true".to_owned()), - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn edit_movie(&mut self, mut edit_movie_params: EditMovieParams) -> Result<()> { - info!("Editing Radarr movie"); - let movie_id = edit_movie_params.movie_id; - let detail_event = RadarrEvent::GetMovieDetails(movie_id); - let event = RadarrEvent::EditMovie(EditMovieParams::default()); - if let Some(tag_input_str) = edit_movie_params.tag_input_string.as_ref() { - let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tag_input_str).await; - edit_movie_params.tags = Some(tag_ids_vec); - } - - info!("Fetching movie details for movie with ID: {movie_id}"); - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{movie_id}")), - None, - ) - .await; - - let mut response = String::new(); - - self - .handle_request::<(), Value>(request_props, |detailed_movie_body, _| { - response = detailed_movie_body.to_string() - }) - .await?; - - info!("Constructing edit movie body"); - - let mut detailed_movie_body: Value = serde_json::from_str(&response)?; - let (monitored, minimum_availability, quality_profile_id, root_folder_path, tags) = { - let monitored = edit_movie_params.monitored.unwrap_or( - detailed_movie_body["monitored"] - .as_bool() - .expect("Unable to deserialize 'monitored'"), - ); - let minimum_availability = edit_movie_params - .minimum_availability - .unwrap_or_else(|| { - serde_json::from_value(detailed_movie_body["minimumAvailability"].clone()) - .expect("Unable to deserialize 'minimumAvailability'") - }) - .to_string(); - let quality_profile_id = edit_movie_params.quality_profile_id.unwrap_or_else(|| { - detailed_movie_body["qualityProfileId"] - .as_i64() - .expect("Unable to deserialize 'qualityProfileId'") - }); - let root_folder_path = edit_movie_params.root_folder_path.unwrap_or_else(|| { - detailed_movie_body["path"] - .as_str() - .expect("Unable to deserialize 'path'") - .to_owned() - }); - let tags = if edit_movie_params.clear_tags { - vec![] - } else { - edit_movie_params.tags.unwrap_or( - detailed_movie_body["tags"] - .as_array() - .expect("Unable to deserialize 'tags'") - .iter() - .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) - .collect(), - ) - }; - - ( - monitored, - minimum_availability, - quality_profile_id, - root_folder_path, - tags, - ) - }; - - *detailed_movie_body.get_mut("monitored").unwrap() = json!(monitored); - *detailed_movie_body.get_mut("minimumAvailability").unwrap() = json!(minimum_availability); - *detailed_movie_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); - *detailed_movie_body.get_mut("path").unwrap() = json!(root_folder_path); - *detailed_movie_body.get_mut("tags").unwrap() = json!(tags); - - debug!("Edit movie body: {detailed_movie_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Put, - Some(detailed_movie_body), - Some(format!("/{movie_id}")), - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn get_radarr_blocklist(&mut self) -> Result { - info!("Fetching Radarr blocklist"); - let event = RadarrEvent::GetBlocklist; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| { - if !matches!( - app.get_current_route(), - Route::Radarr(ActiveRadarrBlock::BlocklistSortPrompt, _) - ) { - let mut blocklist_vec = blocklist_resp.records; - blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); - app.data.radarr_data.blocklist.set_items(blocklist_vec); - app.data.radarr_data.blocklist.apply_sorting_toggle(false); - } - }) - .await - } - - async fn get_collections(&mut self) -> Result> { - info!("Fetching Radarr collections"); - let event = RadarrEvent::GetCollections; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |mut collections_vec, mut app| { - if !matches!( - app.get_current_route(), - Route::Radarr(ActiveRadarrBlock::CollectionsSortPrompt, _) - ) { - collections_vec.sort_by(|a, b| a.id.cmp(&b.id)); - app.data.radarr_data.collections.set_items(collections_vec); - app.data.radarr_data.collections.apply_sorting_toggle(false); - } - }) - .await - } - - async fn get_credits(&mut self, movie_id: i64) -> Result> { - info!("Fetching Radarr movie credits"); - let event = RadarrEvent::GetMovieCredits(movie_id); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("movieId={movie_id}")), - ) - .await; - - self - .handle_request::<(), Vec>(request_props, |credit_vec, mut app| { - let cast_vec: Vec = credit_vec - .iter() - .filter(|&credit| credit.credit_type == CreditType::Cast) - .cloned() - .collect(); - let crew_vec: Vec = credit_vec - .iter() - .filter(|&credit| credit.credit_type == CreditType::Crew) - .cloned() - .collect(); - - if app.data.radarr_data.movie_details_modal.is_none() { - app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); - } - - app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_cast - .set_items(cast_vec); - app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_crew - .set_items(crew_vec); - }) - .await - } - - async fn get_radarr_diskspace(&mut self) -> Result> { - info!("Fetching Radarr disk space"); - let event = RadarrEvent::GetDiskSpace; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { - app.data.radarr_data.disk_space_vec = disk_space_vec; - }) - .await - } - - async fn get_radarr_downloads(&mut self, count: u64) -> Result { - info!("Fetching Radarr downloads"); - let event = RadarrEvent::GetDownloads(count); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("pageSize={count}")), - ) - .await; - - self - .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { - app - .data - .radarr_data - .downloads - .set_items(queue_response.records); - }) - .await - } - - async fn get_radarr_host_config(&mut self) -> Result { - info!("Fetching Radarr host config"); - let event = RadarrEvent::GetHostConfig; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), HostConfig>(request_props, |_, _| ()) - .await - } - - async fn get_radarr_indexers(&mut self) -> Result> { - info!("Fetching Radarr indexers"); - let event = RadarrEvent::GetIndexers; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |indexers, mut app| { - app.data.radarr_data.indexers.set_items(indexers); - }) - .await - } - - async fn get_all_radarr_indexer_settings(&mut self) -> Result { - info!("Fetching Radarr indexer settings"); - let event = RadarrEvent::GetAllIndexerSettings; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), IndexerSettings>(request_props, |indexer_settings, mut app| { - if app.data.radarr_data.indexer_settings.is_none() { - app.data.radarr_data.indexer_settings = Some(indexer_settings); - } else { - debug!("Indexer Settings are being modified. Ignoring update..."); - } - }) - .await - } - - async fn get_radarr_healthcheck(&mut self) -> Result<()> { - info!("Performing Radarr health check"); - let event = RadarrEvent::HealthCheck; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn get_radarr_logs(&mut self, events: u64) -> Result { - info!("Fetching Radarr logs"); - let event = RadarrEvent::GetLogs(events); - - let params = format!("pageSize={events}&sortDirection=descending&sortKey=time"); - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) - .await; - - self - .handle_request::<(), LogResponse>(request_props, |log_response, mut app| { - let mut logs = log_response.records; - logs.reverse(); - - let log_lines = logs - .into_iter() - .map(|log| { - if log.exception.is_some() { - HorizontallyScrollableText::from(format!( - "{}|{}|{}|{}|{}", - log.time, - log.level.to_uppercase(), - log.logger.as_ref().unwrap(), - log.exception_type.as_ref().unwrap(), - log.exception.as_ref().unwrap() - )) - } else { - HorizontallyScrollableText::from(format!( - "{}|{}|{}|{}", - log.time, - log.level.to_uppercase(), - log.logger.as_ref().unwrap(), - log.message.as_ref().unwrap() - )) - } - }) - .collect(); - - app.data.radarr_data.logs.set_items(log_lines); - app.data.radarr_data.logs.scroll_to_bottom(); - }) - .await - } - - async fn get_movie_details(&mut self, movie_id: i64) -> Result { - info!("Fetching Radarr movie details"); - let event = RadarrEvent::GetMovieDetails(movie_id); - - info!("Fetching movie details for movie with ID: {movie_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - Some(format!("/{movie_id}")), - None, - ) - .await; - - self - .handle_request::<(), Movie>(request_props, |movie_response, mut app| { - let Movie { - id, - title, - year, - overview, - path, - studio, - has_file, - quality_profile_id, - size_on_disk, - genres, - runtime, - certification, - ratings, - movie_file, - collection, - .. - } = movie_response; - let (hours, minutes) = convert_runtime(runtime); - let size = convert_to_gb(size_on_disk); - let studio = studio.clone().unwrap_or_default(); - let quality_profile = app - .data - .radarr_data - .quality_profile_map - .get_by_left(&quality_profile_id) - .unwrap_or(&"".to_owned()) - .to_owned(); - let imdb_rating = if let Some(rating) = ratings.imdb { - if let Some(value) = rating.value.as_f64() { - format!("{value:.1}") - } else { - String::new() - } - } else { - String::new() - }; - - let tmdb_rating = if let Some(rating) = ratings.tmdb { - if let Some(value) = rating.value.as_f64() { - format!("{}%", (value * 10f64).ceil()) - } else { - String::new() - } - } else { - String::new() - }; - - let rotten_tomatoes_rating = if let Some(rating) = ratings.rotten_tomatoes { - if let Some(value) = rating.value.as_u64() { - format!("{value}%") - } else { - String::new() - } - } else { - String::new() - }; - - let status = get_movie_status(has_file, &app.data.radarr_data.downloads.items, id); - let collection = collection.unwrap_or_default(); - - let mut movie_details_modal = MovieDetailsModal { - movie_details: ScrollableText::with_string(formatdoc!( - "Title: {title} - Year: {year} - Runtime: {hours}h {minutes}m - Rating: {} - Collection: {} - Status: {status} - Description: {overview} - TMDB: {tmdb_rating} - IMDB: {imdb_rating} - Rotten Tomatoes: {rotten_tomatoes_rating} - Quality Profile: {quality_profile} - Size: {size:.2} GB - Path: {path} - Studio: {studio} - Genres: {}", - certification.unwrap_or_default(), - collection - .title - .as_ref() - .unwrap_or(&String::new()) - .to_owned(), - genres.join(", ") - )), - ..MovieDetailsModal::default() - }; - - if let Some(file) = movie_file { - movie_details_modal.file_details = formatdoc!( - "Relative Path: {} - Absolute Path: {} - Size: {size:.2} GB - Date Added: {}", - file.relative_path, - file.path, - file.date_added - ); - - if let Some(media_info) = file.media_info { - movie_details_modal.audio_details = formatdoc!( - "Bitrate: {} - Channels: {:.1} - Codec: {} - Languages: {} - Stream Count: {}", - media_info.audio_bitrate, - media_info.audio_channels.as_f64().unwrap(), - media_info.audio_codec.unwrap_or_default(), - media_info.audio_languages.unwrap_or_default(), - media_info.audio_stream_count - ); - - movie_details_modal.video_details = formatdoc!( - "Bit Depth: {} - Bitrate: {} - Codec: {} - FPS: {} - Resolution: {} - Scan Type: {} - Runtime: {}", - media_info.video_bit_depth, - media_info.video_bitrate, - media_info.video_codec.unwrap_or_default(), - media_info.video_fps.as_f64().unwrap(), - media_info.resolution, - media_info.scan_type, - media_info.run_time - ); - } - } - - app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - }) - .await - } - - async fn get_movie_history(&mut self, movie_id: i64) -> Result> { - info!("Fetching Radarr movie history"); - let event = RadarrEvent::GetMovieHistory(movie_id); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("movieId={movie_id}")), - ) - .await; - - self - .handle_request::<(), Vec>(request_props, |movie_history_vec, mut app| { - let mut reversed_movie_history_vec = movie_history_vec.to_vec(); - reversed_movie_history_vec.reverse(); - - if app.data.radarr_data.movie_details_modal.is_none() { - app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()) - } - - app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_history - .set_items(reversed_movie_history_vec) - }) - .await - } - - async fn get_movies(&mut self) -> Result> { - info!("Fetching Radarr library"); - let event = RadarrEvent::GetMovies; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |mut movie_vec, mut app| { - if !matches!( - app.get_current_route(), - Route::Radarr(ActiveRadarrBlock::MoviesSortPrompt, _) - ) { - movie_vec.sort_by(|a, b| a.id.cmp(&b.id)); - app.data.radarr_data.movies.set_items(movie_vec); - app.data.radarr_data.movies.apply_sorting_toggle(false); - } - }) - .await - } - - async fn get_radarr_quality_profiles(&mut self) -> Result> { - info!("Fetching Radarr quality profiles"); - let event = RadarrEvent::GetQualityProfiles; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { - app.data.radarr_data.quality_profile_map = quality_profiles - .into_iter() - .map(|profile| (profile.id, profile.name)) - .collect(); - }) - .await - } - - async fn get_queued_radarr_events(&mut self) -> Result> { - info!("Fetching Radarr queued events"); - let event = RadarrEvent::GetQueuedEvents; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |queued_events_vec, mut app| { - app - .data - .radarr_data - .queued_events - .set_items(queued_events_vec); - }) - .await - } - - async fn get_movie_releases(&mut self, movie_id: i64) -> Result> { - info!("Fetching releases for movie with ID: {movie_id}"); - let event = RadarrEvent::GetReleases(movie_id); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("movieId={movie_id}")), - ) - .await; - - self - .handle_request::<(), Vec>(request_props, |release_vec, mut app| { - if app.data.radarr_data.movie_details_modal.is_none() { - app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); - } - - app - .data - .radarr_data - .movie_details_modal - .as_mut() - .unwrap() - .movie_releases - .set_items(release_vec); - }) - .await - } - - async fn get_radarr_root_folders(&mut self) -> Result> { - info!("Fetching Radarr root folders"); - let event = RadarrEvent::GetRootFolders; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |root_folders, mut app| { - app.data.radarr_data.root_folders.set_items(root_folders); - }) - .await - } - - async fn get_radarr_security_config(&mut self) -> Result { - info!("Fetching Radarr security config"); - let event = RadarrEvent::GetSecurityConfig; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) - .await - } - - async fn get_radarr_status(&mut self) -> Result { - info!("Fetching Radarr system status"); - let event = RadarrEvent::GetStatus; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { - app.data.radarr_data.version = system_status.version; - app.data.radarr_data.start_time = system_status.start_time; - }) - .await - } - - async fn get_radarr_tags(&mut self) -> Result> { - info!("Fetching Radarr tags"); - let event = RadarrEvent::GetTags; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { - app.data.radarr_data.tags_map = tags_vec - .into_iter() - .map(|tag| (tag.id, tag.label)) - .collect(); - }) - .await - } - - async fn get_radarr_tasks(&mut self) -> Result> { - info!("Fetching Radarr tasks"); - let event = RadarrEvent::GetTasks; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { - app.data.radarr_data.tasks.set_items(tasks_vec); - }) - .await - } - - async fn get_radarr_updates(&mut self) -> Result> { - info!("Fetching Radarr updates"); - let event = RadarrEvent::GetUpdates; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |updates_vec, mut app| { - let latest_installed = if updates_vec - .iter() - .any(|update| update.latest && update.installed_on.is_some()) - { - "already".to_owned() - } else { - "not".to_owned() - }; - let updates = updates_vec - .into_iter() - .map(|update| { - let install_status = if update.installed_on.is_some() { - if update.installed { - "(Currently Installed)".to_owned() - } else { - "(Previously Installed)".to_owned() - } - } else { - String::new() - }; - let vec_to_bullet_points = |vec: Vec| { - vec - .iter() - .map(|change| format!(" * {change}")) - .collect::>() - .join("\n") - }; - - let mut update_info = formatdoc!( - "{} - {} {install_status} - {}", - update.version, - update.release_date, - "-".repeat(200) - ); - - if let Some(new_changes) = update.changes.new { - let changes = vec_to_bullet_points(new_changes); - update_info = formatdoc!( - "{update_info} - New: - {changes}" - ) - } - - if let Some(fixes) = update.changes.fixed { - let fixes = vec_to_bullet_points(fixes); - update_info = formatdoc!( - "{update_info} - Fixed: - {fixes}" - ); - } - - update_info - }) - .reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}")) - .unwrap(); - - app.data.radarr_data.updates = ScrollableText::with_string(formatdoc!( - "The latest version of Radarr is {latest_installed} installed - - {updates}" - )); - }) - .await - } - - async fn search_movie(&mut self, query: String) -> Result> { - info!("Searching for specific Radarr movie"); - let event = RadarrEvent::SearchNewMovie(String::new()); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("term={}", encode(&query))), - ) - .await; - - self - .handle_request::<(), Vec>(request_props, |movie_vec, mut app| { - if movie_vec.is_empty() { - app.pop_and_push_navigation_stack(ActiveRadarrBlock::AddMovieEmptySearchResults.into()); - } else if let Some(add_searched_movies) = app.data.radarr_data.add_searched_movies.as_mut() - { - add_searched_movies.set_items(movie_vec); - } else { - let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(movie_vec); - app.data.radarr_data.add_searched_movies = Some(add_searched_movies); - } - }) - .await - } - - async fn start_radarr_task(&mut self, task_name: RadarrTaskName) -> Result { - let event = RadarrEvent::StartTask(task_name); - - info!("Starting Radarr task: {task_name}"); - - let body = CommandBody { - name: task_name.to_string(), - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn test_radarr_indexer(&mut self, indexer_id: i64) -> Result { - let detail_event = RadarrEvent::GetIndexers; - let event = RadarrEvent::TestIndexer(indexer_id); - info!("Testing Radarr indexer with ID: {indexer_id}"); - - info!("Fetching indexer details for indexer with ID: {indexer_id}"); - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{indexer_id}")), - None, - ) - .await; - - let mut test_body: Value = Value::default(); - - self - .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { - test_body = detailed_indexer_body; - }) - .await?; - - info!("Testing indexer"); - - let mut request_props = self - .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) - .await; - request_props.ignore_status_code = true; - - self - .handle_request::(request_props, |test_results, mut app| { - if test_results.as_object().is_none() { - app.data.radarr_data.indexer_test_errors = Some( - test_results.as_array().unwrap()[0] - .get("errorMessage") - .unwrap() - .to_string(), - ); - } else { - app.data.radarr_data.indexer_test_errors = Some(String::new()); - }; - }) - .await - } - - async fn test_all_radarr_indexers(&mut self) -> Result> { - info!("Testing all Radarr indexers"); - let event = RadarrEvent::TestAllIndexers; - - let mut request_props = self - .request_props_from(event, RequestMethod::Post, None, None, None) - .await; - request_props.ignore_status_code = true; - - self - .handle_request::<(), Vec>(request_props, |test_results, mut app| { - let mut test_all_indexer_results = StatefulTable::default(); - let indexers = app.data.radarr_data.indexers.items.clone(); - let modal_test_results = test_results - .iter() - .map(|result| { - let name = indexers - .iter() - .filter(|&indexer| indexer.id == result.id) - .map(|indexer| indexer.name.clone()) - .nth(0) - .unwrap_or_default(); - let validation_failures = result - .validation_failures - .iter() - .map(|failure| { - format!( - "Failure for field '{}': {}", - failure.property_name, failure.error_message - ) - }) - .collect::>() - .join(", "); - - IndexerTestResultModalItem { - name: name.unwrap_or_default(), - is_valid: result.is_valid, - validation_failures: validation_failures.into(), - } - }) - .collect(); - test_all_indexer_results.set_items(modal_test_results); - app.data.radarr_data.indexer_test_all_results = Some(test_all_indexer_results); - }) - .await - } - - async fn toggle_movie_monitoring(&mut self, movie_id: i64) -> Result<()> { - let event = RadarrEvent::ToggleMovieMonitoring(movie_id); - - let detail_event = RadarrEvent::GetMovieDetails(movie_id); - info!("Toggling movie monitoring for movie with ID: {movie_id}"); - info!("Fetching movie details for movie with ID: {movie_id}"); - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{movie_id}")), - None, - ) - .await; - - let mut response = String::new(); - - self - .handle_request::<(), Value>(request_props, |detailed_movie_body, _| { - response = detailed_movie_body.to_string() - }) - .await?; - - info!("Constructing toggle movie monitoring body"); - - match serde_json::from_str::(&response) { - Ok(mut detailed_movie_body) => { - let monitored = detailed_movie_body - .get("monitored") - .unwrap() - .as_bool() - .unwrap(); - - *detailed_movie_body.get_mut("monitored").unwrap() = json!(!monitored); - - debug!("Toggle movie monitoring body: {detailed_movie_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Put, - Some(detailed_movie_body), - Some(format!("/{movie_id}")), - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - Err(_) => { - warn!("Request for detailed movie body was interrupted"); - Ok(()) - } - } - } - - async fn trigger_automatic_movie_search(&mut self, movie_id: i64) -> Result { - let event = RadarrEvent::TriggerAutomaticSearch(movie_id); - info!("Searching indexers for movie with ID: {movie_id}"); - let body = MovieCommandBody { - name: "MoviesSearch".to_owned(), - movie_ids: vec![movie_id], - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn update_all_movies(&mut self) -> Result { - info!("Updating all movies"); - let event = RadarrEvent::UpdateAllMovies; - let body = MovieCommandBody { - name: "RefreshMovie".to_owned(), - movie_ids: Vec::new(), - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn update_and_scan_movie(&mut self, movie_id: i64) -> Result { - let event = RadarrEvent::UpdateAndScan(movie_id); - info!("Updating and scanning movie with ID: {movie_id}"); - let body = MovieCommandBody { - name: "RefreshMovie".to_owned(), - movie_ids: vec![movie_id], - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn update_collections(&mut self) -> Result { - info!("Updating collections"); - let event = RadarrEvent::UpdateCollections; - let body = CommandBody { - name: "RefreshCollections".to_owned(), - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn update_radarr_downloads(&mut self) -> Result { - info!("Updating Radarr downloads"); - let event = RadarrEvent::UpdateDownloads; - let body = CommandBody { - name: "RefreshMonitoredDownloads".to_owned(), - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn extract_and_add_radarr_tag_ids_vec(&mut self, edit_tags: &str) -> Vec { - let missing_tags_vec = { - let tags_map = &self.app.lock().await.data.radarr_data.tags_map; - edit_tags - .split(',') - .filter(|&tag| { - !tag.is_empty() && tags_map.get_by_right(tag.to_lowercase().trim()).is_none() - }) - .collect::>() - }; - - for tag in missing_tags_vec { - self - .add_radarr_tag(tag.trim().to_owned()) - .await - .expect("Unable to add tag"); - } - - let app = self.app.lock().await; - edit_tags - .split(',') - .filter(|tag| !tag.is_empty()) - .map(|tag| { - *app - .data - .radarr_data - .tags_map - .get_by_right(tag.to_lowercase().trim()) - .unwrap() - }) - .collect() - } -} - -fn get_movie_status(has_file: bool, downloads_vec: &[DownloadRecord], movie_id: i64) -> String { - if !has_file { - if let Some(download) = downloads_vec - .iter() - .find(|&download| download.movie_id == movie_id) - { - if download.status == "downloading" { - return "Downloading".to_owned(); - } - - if download.status == "completed" { - return "Awaiting Import".to_owned(); - } - } - - return "Missing".to_owned(); - } - - "Downloaded".to_owned() -} diff --git a/src/network/radarr_network/blocklist/mod.rs b/src/network/radarr_network/blocklist/mod.rs new file mode 100644 index 0000000..477b658 --- /dev/null +++ b/src/network/radarr_network/blocklist/mod.rs @@ -0,0 +1,93 @@ +use crate::models::radarr_models::BlocklistResponse; +use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::Route; +use crate::network::radarr_network::RadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; +use serde_json::{json, Value}; + +#[cfg(test)] +#[path = "radarr_blocklist_network_tests.rs"] +mod radarr_blocklist_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::radarr_network) async fn clear_radarr_blocklist(&mut self) -> Result<()> { + info!("Clearing Radarr blocklist"); + let event = RadarrEvent::ClearBlocklist; + + let ids = self + .app + .lock() + .await + .data + .radarr_data + .blocklist + .items + .iter() + .map(|item| item.id) + .collect::>(); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + Some(json!({"ids": ids})), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn delete_radarr_blocklist_item( + &mut self, + blocklist_item_id: i64, + ) -> Result<()> { + let event = RadarrEvent::DeleteBlocklistItem(blocklist_item_id); + + info!("Deleting Radarr blocklist item for item with id: {blocklist_item_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{blocklist_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_blocklist( + &mut self, + ) -> Result { + info!("Fetching Radarr blocklist"); + let event = RadarrEvent::GetBlocklist; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| { + if !matches!( + app.get_current_route(), + Route::Radarr(ActiveRadarrBlock::BlocklistSortPrompt, _) + ) { + let mut blocklist_vec = blocklist_resp.records; + blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.radarr_data.blocklist.set_items(blocklist_vec); + app.data.radarr_data.blocklist.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs b/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs new file mode 100644 index 0000000..86e8885 --- /dev/null +++ b/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs @@ -0,0 +1,354 @@ +#[cfg(test)] +mod tests { + use crate::models::radarr_models::BlocklistItem; + use crate::models::radarr_models::BlocklistItemMovie; + use crate::models::radarr_models::BlocklistResponse; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::blocklist_item; + use crate::network::radarr_network::RadarrEvent; + use crate::network::radarr_network::RadarrSerdeable; + use crate::network::{Network, RequestMethod}; + use pretty_assertions::assert_eq; + use reqwest::Client; + use rstest::rstest; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_clear_radarr_blocklist_event() { + let blocklist_items = vec![ + BlocklistItem { + id: 1, + ..blocklist_item() + }, + BlocklistItem { + id: 2, + ..blocklist_item() + }, + BlocklistItem { + id: 3, + ..blocklist_item() + }, + ]; + let expected_request_json = json!({ "ids": [1, 2, 3]}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + Some(expected_request_json), + None, + None, + RadarrEvent::ClearBlocklist, + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .radarr_data + .blocklist + .set_items(blocklist_items); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::ClearBlocklist) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_radarr_blocklist_item_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteBlocklistItem(1), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::DeleteBlocklistItem(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_radarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { + let blocklist_json = json!({"records": [{ + "id": 123, + "movieId": 1007, + "sourceTitle": "z movie", + "languages": [{"id": 1, "name": "English"}], + "quality": {"quality": {"name": "HD - 1080p"}}, + "customFormats": [{"id": 1, "name": "English"}], + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "DrunkenSlug (Prowlarr)", + "message": "test message", + "movie": { + "id": 1007, + "title": "z movie", + "tmdbId": 1234, + "originalLanguage": {"id": 1, "name": "English"}, + "sizeOnDisk": 3543348019i64, + "status": "Downloaded", + "overview": "Blah blah blah", + "path": "/nfs/movies", + "studio": "21st Century Alex", + "genres": ["cool", "family", "fun"], + "year": 2023, + "monitored": true, + "hasFile": true, + "runtime": 120, + "qualityProfileId": 2222, + "minimumAvailability": "announced", + "certification": "R", + "tags": [1], + "ratings": { + "imdb": {"value": 9.9}, + "tmdb": {"value": 9.9}, + "rottenTomatoes": {"value": 9.9} + }, + }, + }, { + "id": 456, + "movieId": 2001, + "sourceTitle": "A Movie", + "languages": [{"id": 1, "name": "English"}], + "quality": {"quality": {"name": "HD - 1080p"}}, + "customFormats": [{"id": 1, "name": "English"}], + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "DrunkenSlug (Prowlarr)", + "message": "test message", + "movie": { + "id": 2001, + "title": "A Movie", + "tmdbId": 1234, + "originalLanguage": {"id": 1, "name": "English"}, + "sizeOnDisk": 3543348019i64, + "status": "Downloaded", + "overview": "Blah blah blah", + "path": "/nfs/movies", + "studio": "21st Century Alex", + "genres": ["cool", "family", "fun"], + "year": 2023, + "monitored": true, + "hasFile": true, + "runtime": 120, + "qualityProfileId": 2222, + "minimumAvailability": "announced", + "certification": "R", + "tags": [1], + "ratings": { + "imdb": {"value": 9.9}, + "tmdb": {"value": 9.9}, + "rottenTomatoes": {"value": 9.9} + }, + }, + }]}); + let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap(); + let mut expected_blocklist = vec![ + BlocklistItem { + id: 123, + movie_id: 1007, + source_title: "z movie".into(), + movie: BlocklistItemMovie { + title: "z movie".into(), + }, + ..blocklist_item() + }, + BlocklistItem { + id: 456, + movie_id: 2001, + source_title: "A Movie".into(), + movie: BlocklistItemMovie { + title: "A Movie".into(), + }, + ..blocklist_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(blocklist_json), + None, + RadarrEvent::GetBlocklist, + None, + None, + ) + .await; + app_arc.lock().await.data.radarr_data.blocklist.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + expected_blocklist.sort_by(cmp_fn); + + let blocklist_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .radarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::BlocklistResponse(blocklist) = network + .handle_radarr_event(RadarrEvent::GetBlocklist) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.blocklist.items, + expected_blocklist + ); + assert!(app_arc.lock().await.data.radarr_data.blocklist.sort_asc); + assert_eq!(blocklist, response); + } + } + + #[tokio::test] + async fn test_handle_get_blocklist_event_no_op_when_user_is_selecting_sort_options() { + let blocklist_json = json!({"records": [{ + "id": 123, + "movieId": 1007, + "sourceTitle": "z movie", + "languages": [{"id": 1, "name": "English"}], + "quality": {"quality": {"name": "HD - 1080p"}}, + "customFormats": [{"id": 1, "name": "English"}], + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "DrunkenSlug (Prowlarr)", + "message": "test message", + "movie": { + "id": 1007, + "title": "z movie", + "tmdbId": 1234, + "originalLanguage": {"id": 1, "name": "English"}, + "sizeOnDisk": 3543348019i64, + "status": "Downloaded", + "overview": "Blah blah blah", + "path": "/nfs/movies", + "studio": "21st Century Alex", + "genres": ["cool", "family", "fun"], + "year": 2023, + "monitored": true, + "hasFile": true, + "runtime": 120, + "qualityProfileId": 2222, + "minimumAvailability": "announced", + "certification": "R", + "tags": [1], + "ratings": { + "imdb": {"value": 9.9}, + "tmdb": {"value": 9.9}, + "rottenTomatoes": {"value": 9.9} + }, + }, + }, { + "id": 456, + "movieId": 2001, + "sourceTitle": "A Movie", + "languages": [{"id": 1, "name": "English"}], + "quality": {"quality": {"name": "HD - 1080p"}}, + "customFormats": [{"id": 1, "name": "English"}], + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "DrunkenSlug (Prowlarr)", + "message": "test message", + "movie": { + "id": 2001, + "title": "A Movie", + "tmdbId": 1234, + "originalLanguage": {"id": 1, "name": "English"}, + "sizeOnDisk": 3543348019i64, + "status": "Downloaded", + "overview": "Blah blah blah", + "path": "/nfs/movies", + "studio": "21st Century Alex", + "genres": ["cool", "family", "fun"], + "year": 2023, + "monitored": true, + "hasFile": true, + "runtime": 120, + "qualityProfileId": 2222, + "minimumAvailability": "announced", + "certification": "R", + "tags": [1], + "ratings": { + "imdb": {"value": 9.9}, + "tmdb": {"value": 9.9}, + "rottenTomatoes": {"value": 9.9} + }, + }, + }]}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(blocklist_json), + None, + RadarrEvent::GetBlocklist, + None, + None, + ) + .await; + app_arc.lock().await.data.radarr_data.blocklist.sort_asc = true; + app_arc + .lock() + .await + .push_navigation_stack(ActiveRadarrBlock::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 + .radarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::GetBlocklist) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .radarr_data + .blocklist + .items + .is_empty()); + assert!(app_arc.lock().await.data.radarr_data.blocklist.sort_asc); + } +} diff --git a/src/network/radarr_network/collections/mod.rs b/src/network/radarr_network/collections/mod.rs new file mode 100644 index 0000000..6003098 --- /dev/null +++ b/src/network/radarr_network/collections/mod.rs @@ -0,0 +1,154 @@ +use crate::models::radarr_models::{Collection, EditCollectionParams}; +use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::servarr_models::CommandBody; +use crate::models::Route; +use crate::network::radarr_network::RadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::{debug, info}; +use serde_json::{json, Value}; + +#[cfg(test)] +#[path = "radarr_collections_network_tests.rs"] +mod radarr_collections_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::radarr_network) async fn edit_collection( + &mut self, + edit_collection_params: EditCollectionParams, + ) -> Result<()> { + info!("Editing Radarr collection"); + let detail_event = RadarrEvent::GetCollections; + let event = RadarrEvent::EditCollection(EditCollectionParams::default()); + info!("Fetching collection details"); + let collection_id = edit_collection_params.collection_id; + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{collection_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_collection_body, _| { + response = detailed_collection_body.to_string() + }) + .await?; + + info!("Constructing edit collection body"); + + let mut detailed_collection_body: Value = serde_json::from_str(&response)?; + let (monitored, minimum_availability, quality_profile_id, root_folder_path, search_on_add) = { + let monitored = edit_collection_params.monitored.unwrap_or_else(|| { + detailed_collection_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored' bool") + }); + let minimum_availability = edit_collection_params + .minimum_availability + .unwrap_or_else(|| { + serde_json::from_value(detailed_collection_body["minimumAvailability"].clone()) + .expect("Unable to deserialize 'minimumAvailability'") + }) + .to_string(); + let quality_profile_id = edit_collection_params + .quality_profile_id + .unwrap_or_else(|| { + detailed_collection_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let root_folder_path = edit_collection_params.root_folder_path.unwrap_or_else(|| { + detailed_collection_body["rootFolderPath"] + .as_str() + .expect("Unable to deserialize 'rootFolderPath'") + .to_owned() + }); + let search_on_add = edit_collection_params.search_on_add.unwrap_or_else(|| { + detailed_collection_body["searchOnAdd"] + .as_bool() + .expect("Unable to deserialize 'searchOnAdd'") + }); + + ( + monitored, + minimum_availability, + quality_profile_id, + root_folder_path, + search_on_add, + ) + }; + + *detailed_collection_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_collection_body + .get_mut("minimumAvailability") + .unwrap() = json!(minimum_availability); + *detailed_collection_body + .get_mut("qualityProfileId") + .unwrap() = json!(quality_profile_id); + *detailed_collection_body.get_mut("rootFolderPath").unwrap() = json!(root_folder_path); + *detailed_collection_body.get_mut("searchOnAdd").unwrap() = json!(search_on_add); + + debug!("Edit collection body: {detailed_collection_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_collection_body), + Some(format!("/{collection_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn get_collections( + &mut self, + ) -> Result> { + info!("Fetching Radarr collections"); + let event = RadarrEvent::GetCollections; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut collections_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Radarr(ActiveRadarrBlock::CollectionsSortPrompt, _) + ) { + collections_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.radarr_data.collections.set_items(collections_vec); + app.data.radarr_data.collections.apply_sorting_toggle(false); + } + }) + .await + } + + pub(in crate::network::radarr_network) async fn update_collections(&mut self) -> Result { + info!("Updating collections"); + let event = RadarrEvent::UpdateCollections; + let body = CommandBody { + name: "RefreshCollections".to_owned(), + }; + + 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/radarr_network/collections/radarr_collections_network_tests.rs b/src/network/radarr_network/collections/radarr_collections_network_tests.rs new file mode 100644 index 0000000..9c9b95f --- /dev/null +++ b/src/network/radarr_network/collections/radarr_collections_network_tests.rs @@ -0,0 +1,443 @@ +#[cfg(test)] +mod tests { + use crate::models::radarr_models::{ + Collection, EditCollectionParams, MinimumAvailability, RadarrSerdeable, + }; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::collection; + use crate::network::radarr_network::RadarrEvent; + use crate::network::{Network, NetworkResource, RequestMethod}; + use mockito::Matcher; + use pretty_assertions::assert_eq; + use reqwest::Client; + use rstest::rstest; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_edit_collection_event() { + let detailed_collection_body = json!({ + "id": 123, + "title": "Test Collection", + "rootFolderPath": "/nfs/movies", + "searchOnAdd": true, + "monitored": true, + "minimumAvailability": "released", + "overview": "Collection blah blah blah", + "qualityProfileId": 2222, + "movies": [ + { + "title": "Test", + "overview": "Collection blah blah blah", + "year": 2023, + "runtime": 120, + "tmdbId": 1234, + "genres": ["cool", "family", "fun"], + "ratings": { + "imdb": { + "value": 9.9 + }, + "tmdb": { + "value": 9.9 + }, + "rottenTomatoes": { + "value": 9.9 + } + } + } + ] + }); + let mut expected_body = detailed_collection_body.clone(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("minimumAvailability").unwrap() = json!("announced"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("searchOnAdd").unwrap() = json!(false); + let edit_collection_params = EditCollectionParams { + collection_id: 123, + monitored: Some(false), + minimum_availability: Some(MinimumAvailability::Announced), + quality_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + search_on_add: Some(false), + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(detailed_collection_body), + None, + RadarrEvent::GetCollections, + Some("/123"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/123", + RadarrEvent::EditCollection(edit_collection_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditCollection(edit_collection_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_collection_event_defaults_to_previous_values_when_no_params_are_provided( + ) { + let detailed_collection_body = json!({ + "id": 123, + "title": "Test Collection", + "rootFolderPath": "/nfs/movies", + "searchOnAdd": true, + "monitored": true, + "minimumAvailability": "released", + "overview": "Collection blah blah blah", + "qualityProfileId": 2222, + "movies": [ + { + "title": "Test", + "overview": "Collection blah blah blah", + "year": 2023, + "runtime": 120, + "tmdbId": 1234, + "genres": ["cool", "family", "fun"], + "ratings": { + "imdb": { + "value": 9.9 + }, + "tmdb": { + "value": 9.9 + }, + "rottenTomatoes": { + "value": 9.9 + } + } + } + ] + }); + let mut expected_body = detailed_collection_body.clone(); + *expected_body.get_mut("monitored").unwrap() = json!(true); + *expected_body.get_mut("minimumAvailability").unwrap() = json!("released"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(2222); + *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/movies"); + *expected_body.get_mut("searchOnAdd").unwrap() = json!(true); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(detailed_collection_body), + None, + RadarrEvent::GetCollections, + Some("/123"), + None, + ) + .await; + let edit_collection_params = EditCollectionParams { + collection_id: 123, + ..EditCollectionParams::default() + }; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/123", + RadarrEvent::EditCollection(edit_collection_params).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_collection_params = EditCollectionParams { + collection_id: 123, + ..EditCollectionParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditCollection(edit_collection_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_collections_event(#[values(true, false)] use_custom_sorting: bool) { + let collections_json = json!([{ + "id": 123, + "title": "z Collection", + "rootFolderPath": "/nfs/movies", + "searchOnAdd": true, + "monitored": true, + "minimumAvailability": "released", + "overview": "Collection blah blah blah", + "qualityProfileId": 2222, + "movies": [{ + "title": "Test", + "overview": "Collection blah blah blah", + "year": 2023, + "runtime": 120, + "tmdbId": 1234, + "genres": ["cool", "family", "fun"], + "ratings": { + "imdb": { + "value": 9.9 + }, + "tmdb": { + "value": 9.9 + }, + "rottenTomatoes": { + "value": 9.9 + } + } + }], + }, + { + "id": 456, + "title": "A Collection", + "rootFolderPath": "/nfs/movies", + "searchOnAdd": true, + "monitored": true, + "minimumAvailability": "released", + "overview": "Collection blah blah blah", + "qualityProfileId": 2222, + "movies": [{ + "title": "Test", + "overview": "Collection blah blah blah", + "year": 2023, + "runtime": 120, + "tmdbId": 1234, + "genres": ["cool", "family", "fun"], + "ratings": { + "imdb": { + "value": 9.9 + }, + "tmdb": { + "value": 9.9 + }, + "rottenTomatoes": { + "value": 9.9 + } + } + }], + }]); + let response: Vec = serde_json::from_value(collections_json.clone()).unwrap(); + let mut expected_collections = vec![ + Collection { + id: 123, + title: "z Collection".into(), + ..collection() + }, + Collection { + id: 456, + title: "A Collection".into(), + ..collection() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(collections_json), + None, + RadarrEvent::GetCollections, + None, + None, + ) + .await; + app_arc.lock().await.data.radarr_data.collections.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &Collection, b: &Collection| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + expected_collections.sort_by(cmp_fn); + + let collection_sort_option = SortOption { + name: "Collection", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .radarr_data + .collections + .sorting(vec![collection_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Collections(collections) = network + .handle_radarr_event(RadarrEvent::GetCollections) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.collections.items, + expected_collections + ); + assert!(app_arc.lock().await.data.radarr_data.collections.sort_asc); + assert_eq!(collections, response); + } + } + + #[tokio::test] + async fn test_handle_get_collections_event_no_op_when_user_is_selecting_sort_options() { + let collections_json = json!([{ + "id": 123, + "title": "z Collection", + "rootFolderPath": "/nfs/movies", + "searchOnAdd": true, + "monitored": true, + "minimumAvailability": "released", + "overview": "Collection blah blah blah", + "qualityProfileId": 2222, + "movies": [{ + "title": "Test", + "overview": "Collection blah blah blah", + "year": 2023, + "runtime": 120, + "tmdbId": 1234, + "genres": ["cool", "family", "fun"], + "ratings": { + "imdb": { + "value": 9.9 + }, + "tmdb": { + "value": 9.9 + }, + "rottenTomatoes": { + "value": 9.9 + } + } + }], + }, + { + "id": 456, + "title": "A Collection", + "rootFolderPath": "/nfs/movies", + "searchOnAdd": true, + "monitored": true, + "minimumAvailability": "released", + "overview": "Collection blah blah blah", + "qualityProfileId": 2222, + "movies": [{ + "title": "Test", + "overview": "Collection blah blah blah", + "year": 2023, + "runtime": 120, + "tmdbId": 1234, + "genres": ["cool", "family", "fun"], + "ratings": { + "imdb": { + "value": 9.9 + }, + "tmdb": { + "value": 9.9 + }, + "rottenTomatoes": { + "value": 9.9 + } + } + }], + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(collections_json), + None, + RadarrEvent::GetCollections, + None, + None, + ) + .await; + app_arc.lock().await.data.radarr_data.collections.sort_asc = true; + app_arc + .lock() + .await + .push_navigation_stack(ActiveRadarrBlock::CollectionsSortPrompt.into()); + let cmp_fn = |a: &Collection, b: &Collection| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let collection_sort_option = SortOption { + name: "Collection", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .radarr_data + .collections + .sorting(vec![collection_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::GetCollections) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .radarr_data + .collections + .items + .is_empty()); + assert!(app_arc.lock().await.data.radarr_data.collections.sort_asc); + } + + #[tokio::test] + async fn test_handle_update_collections_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshCollections" + })), + Some(json!({})), + None, + RadarrEvent::UpdateCollections, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::UpdateCollections) + .await + .is_ok()); + + async_server.assert_async().await; + } +} diff --git a/src/network/radarr_network/downloads/mod.rs b/src/network/radarr_network/downloads/mod.rs new file mode 100644 index 0000000..1200099 --- /dev/null +++ b/src/network/radarr_network/downloads/mod.rs @@ -0,0 +1,81 @@ +use crate::models::radarr_models::DownloadsResponse; +use crate::models::servarr_models::CommandBody; +use crate::network::radarr_network::RadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; +use serde_json::Value; + +#[cfg(test)] +#[path = "radarr_downloads_network_tests.rs"] +mod radarr_downloads_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::radarr_network) async fn delete_radarr_download( + &mut self, + download_id: i64, + ) -> Result<()> { + let event = RadarrEvent::DeleteDownload(download_id); + info!("Deleting Radarr download for download with id: {download_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{download_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_downloads( + &mut self, + count: u64, + ) -> Result { + info!("Fetching Radarr downloads"); + let event = RadarrEvent::GetDownloads(count); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("pageSize={count}")), + ) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .radarr_data + .downloads + .set_items(queue_response.records); + }) + .await + } + + pub(in crate::network::radarr_network) async fn update_radarr_downloads( + &mut self, + ) -> Result { + info!("Updating Radarr downloads"); + let event = RadarrEvent::UpdateDownloads; + let body = CommandBody { + name: "RefreshMonitoredDownloads".to_owned(), + }; + + 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/radarr_network/downloads/radarr_downloads_network_tests.rs b/src/network/radarr_network/downloads/radarr_downloads_network_tests.rs new file mode 100644 index 0000000..b5e5d18 --- /dev/null +++ b/src/network/radarr_network/downloads/radarr_downloads_network_tests.rs @@ -0,0 +1,100 @@ +#[cfg(test)] +mod tests { + use crate::models::radarr_models::{DownloadsResponse, RadarrSerdeable}; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::downloads_response; + use crate::network::radarr_network::RadarrEvent; + use crate::network::{Network, RequestMethod}; + use reqwest::Client; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_delete_radarr_download_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteDownload(1), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::DeleteDownload(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_radarr_downloads_event() { + let downloads_response_json = json!({ + "records": [{ + "title": "Test Download Title", + "status": "downloading", + "id": 1, + "movieId": 1, + "size": 3543348019u64, + "sizeleft": 1771674009, + "outputPath": "/nfs/movies/Test", + "indexer": "kickass torrents", + "downloadClient": "transmission", + }] + }); + let response: DownloadsResponse = + serde_json::from_value(downloads_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(downloads_response_json), + None, + RadarrEvent::GetDownloads(500), + None, + Some("pageSize=500"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::DownloadsResponse(downloads) = network + .handle_radarr_event(RadarrEvent::GetDownloads(500)) + .await + .unwrap() + { + async_server.assert_async().await; + pretty_assertions::assert_eq!( + app_arc.lock().await.data.radarr_data.downloads.items, + downloads_response().records + ); + pretty_assertions::assert_eq!(downloads, response); + } + } + + #[tokio::test] + async fn test_handle_update_radarr_downloads_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshMonitoredDownloads" + })), + Some(json!({})), + None, + RadarrEvent::UpdateDownloads, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::UpdateDownloads) + .await + .is_ok()); + + async_server.assert_async().await; + } +} diff --git a/src/network/radarr_network/indexers/mod.rs b/src/network/radarr_network/indexers/mod.rs new file mode 100644 index 0000000..b06d5f5 --- /dev/null +++ b/src/network/radarr_network/indexers/mod.rs @@ -0,0 +1,396 @@ +use crate::models::radarr_models::IndexerSettings; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; +use crate::models::stateful_table::StatefulTable; +use crate::network::radarr_network::RadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::{debug, info}; +use serde_json::{json, Value}; + +#[cfg(test)] +#[path = "radarr_indexers_network_tests.rs"] +mod radarr_indexers_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::radarr_network) async fn delete_radarr_indexer( + &mut self, + indexer_id: i64, + ) -> Result<()> { + let event = RadarrEvent::DeleteIndexer(indexer_id); + info!("Deleting Radarr indexer for indexer with id: {indexer_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{indexer_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn edit_all_radarr_indexer_settings( + &mut self, + params: IndexerSettings, + ) -> Result { + info!("Updating Radarr indexer settings"); + let event = RadarrEvent::EditAllIndexerSettings(IndexerSettings::default()); + + debug!("Indexer settings body: {params:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Put, Some(params), None, None) + .await; + + self + .handle_request::(request_props, |_, _| {}) + .await + } + + pub(in crate::network::radarr_network) async fn edit_radarr_indexer( + &mut self, + mut edit_indexer_params: EditIndexerParams, + ) -> Result<()> { + let detail_event = RadarrEvent::GetIndexers; + let event = RadarrEvent::EditIndexer(EditIndexerParams::default()); + let id = edit_indexer_params.indexer_id; + if let Some(tag_input_str) = edit_indexer_params.tag_input_string.as_ref() { + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tag_input_str).await; + edit_indexer_params.tags = Some(tag_ids_vec); + } + info!("Updating Radarr indexer with ID: {id}"); + + info!("Fetching indexer details for indexer with ID: {id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + response = detailed_indexer_body.to_string() + }) + .await?; + + info!("Constructing edit indexer body"); + + let mut detailed_indexer_body: Value = serde_json::from_str(&response)?; + + let ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) = { + let priority = detailed_indexer_body["priority"] + .as_i64() + .expect("Unable to deserialize 'priority'"); + let seed_ratio_field_option = detailed_indexer_body["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + let name = edit_indexer_params.name.unwrap_or( + detailed_indexer_body["name"] + .as_str() + .expect("Unable to deserialize 'name'") + .to_owned(), + ); + let enable_rss = edit_indexer_params.enable_rss.unwrap_or( + detailed_indexer_body["enableRss"] + .as_bool() + .expect("Unable to deserialize 'enableRss'"), + ); + let enable_automatic_search = edit_indexer_params.enable_automatic_search.unwrap_or( + detailed_indexer_body["enableAutomaticSearch"] + .as_bool() + .expect("Unable to deserialize 'enableAutomaticSearch"), + ); + let enable_interactive_search = edit_indexer_params.enable_interactive_search.unwrap_or( + detailed_indexer_body["enableInteractiveSearch"] + .as_bool() + .expect("Unable to deserialize 'enableInteractiveSearch'"), + ); + let url = edit_indexer_params.url.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .expect("Unable to deserialize 'fields'") + .iter() + .find(|field| field["name"] == "baseUrl") + .expect("Field 'baseUrl' was not found in the 'fields' array") + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'baseUrl value'") + .to_owned(), + ); + let api_key = edit_indexer_params.api_key.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .expect("Unable to deserialize 'fields'") + .iter() + .find(|field| field["name"] == "apiKey") + .expect("Field 'apiKey' was not found in the 'fields' array") + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'apiKey value'") + .to_owned(), + ); + let seed_ratio = edit_indexer_params.seed_ratio.unwrap_or_else(|| { + if let Some(seed_ratio_field) = seed_ratio_field_option { + return seed_ratio_field + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'seedCriteria.seedRatio value'") + .to_owned(); + } + + String::new() + }); + let tags = if edit_indexer_params.clear_tags { + vec![] + } else { + edit_indexer_params.tags.unwrap_or( + detailed_indexer_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + let priority = edit_indexer_params.priority.unwrap_or(priority); + + ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) + }; + + *detailed_indexer_body.get_mut("name").unwrap() = json!(name); + *detailed_indexer_body.get_mut("priority").unwrap() = json!(priority); + *detailed_indexer_body.get_mut("enableRss").unwrap() = json!(enable_rss); + *detailed_indexer_body + .get_mut("enableAutomaticSearch") + .unwrap() = json!(enable_automatic_search); + *detailed_indexer_body + .get_mut("enableInteractiveSearch") + .unwrap() = json!(enable_interactive_search); + *detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "baseUrl") + .unwrap() + .get_mut("value") + .unwrap() = json!(url); + *detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "apiKey") + .unwrap() + .get_mut("value") + .unwrap() = json!(api_key); + *detailed_indexer_body.get_mut("tags").unwrap() = json!(tags); + let seed_ratio_field_option = detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field + .as_object_mut() + .unwrap() + .insert("value".to_string(), json!(seed_ratio)); + } + + debug!("Edit indexer body: {detailed_indexer_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_indexer_body), + Some(format!("/{id}")), + Some("forceSave=true".to_owned()), + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_indexers( + &mut self, + ) -> Result> { + info!("Fetching Radarr indexers"); + let event = RadarrEvent::GetIndexers; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |indexers, mut app| { + app.data.radarr_data.indexers.set_items(indexers); + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_all_radarr_indexer_settings( + &mut self, + ) -> Result { + info!("Fetching Radarr indexer settings"); + let event = RadarrEvent::GetAllIndexerSettings; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), IndexerSettings>(request_props, |indexer_settings, mut app| { + if app.data.radarr_data.indexer_settings.is_none() { + app.data.radarr_data.indexer_settings = Some(indexer_settings); + } else { + debug!("Indexer Settings are being modified. Ignoring update..."); + } + }) + .await + } + + pub(in crate::network::radarr_network) async fn test_radarr_indexer( + &mut self, + indexer_id: i64, + ) -> Result { + let detail_event = RadarrEvent::GetIndexers; + let event = RadarrEvent::TestIndexer(indexer_id); + info!("Testing Radarr indexer with ID: {indexer_id}"); + + info!("Fetching indexer details for indexer with ID: {indexer_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{indexer_id}")), + None, + ) + .await; + + let mut test_body: Value = Value::default(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + test_body = detailed_indexer_body; + }) + .await?; + + info!("Testing indexer"); + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::(request_props, |test_results, mut app| { + if test_results.as_object().is_none() { + app.data.radarr_data.indexer_test_errors = Some( + test_results.as_array().unwrap()[0] + .get("errorMessage") + .unwrap() + .to_string(), + ); + } else { + app.data.radarr_data.indexer_test_errors = Some(String::new()); + }; + }) + .await + } + + pub(in crate::network::radarr_network) async fn test_all_radarr_indexers( + &mut self, + ) -> Result> { + info!("Testing all Radarr indexers"); + let event = RadarrEvent::TestAllIndexers; + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, None, None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::<(), Vec>(request_props, |test_results, mut app| { + let mut test_all_indexer_results = StatefulTable::default(); + let indexers = app.data.radarr_data.indexers.items.clone(); + let modal_test_results = test_results + .iter() + .map(|result| { + let name = indexers + .iter() + .filter(|&indexer| indexer.id == result.id) + .map(|indexer| indexer.name.clone()) + .nth(0) + .unwrap_or_default(); + let validation_failures = result + .validation_failures + .iter() + .map(|failure| { + format!( + "Failure for field '{}': {}", + failure.property_name, failure.error_message + ) + }) + .collect::>() + .join(", "); + + IndexerTestResultModalItem { + name: name.unwrap_or_default(), + is_valid: result.is_valid, + validation_failures: validation_failures.into(), + } + }) + .collect(); + test_all_indexer_results.set_items(modal_test_results); + app.data.radarr_data.indexer_test_all_results = Some(test_all_indexer_results); + }) + .await + } +} diff --git a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs new file mode 100644 index 0000000..675931b --- /dev/null +++ b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs @@ -0,0 +1,980 @@ +#[cfg(test)] +mod tests { + use crate::models::radarr_models::{IndexerSettings, RadarrSerdeable}; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; + use crate::models::HorizontallyScrollableText; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::{ + indexer, indexer_settings, + }; + use crate::network::radarr_network::RadarrEvent; + use crate::network::{Network, NetworkResource, RequestMethod}; + use bimap::BiMap; + use mockito::Matcher; + use pretty_assertions::assert_eq; + use reqwest::Client; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_delete_radarr_indexer_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteIndexer(1), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::DeleteIndexer(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_all_radarr_indexer_settings_event() { + let indexer_settings_json = json!({ + "minimumAge": 0, + "maximumSize": 0, + "retention": 0, + "rssSyncInterval": 60, + "preferIndexerFlags": false, + "availabilityDelay": 0, + "allowHardcodedSubs": true, + "whitelistedHardcodedSubs": "", + "id": 1 + }); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Put, + Some(indexer_settings_json), + None, + None, + RadarrEvent::EditAllIndexerSettings(indexer_settings()), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_radarr_indexer_event() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 0, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + RadarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_radarr_indexer_event_does_not_overwrite_tags_vec_if_tag_input_string_is_none( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 0, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tags: Some(vec![1, 2]), + priority: Some(0), + ..EditIndexerParams::default() + }; + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + RadarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_radarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 0, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + RadarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_radarr_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 0, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + RadarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_radarr_indexer_event_defaults_to_previous_values() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + ..EditIndexerParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + RadarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_radarr_indexer_event_clears_tags_when_clear_tags_is_true() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let expected_edit_indexer_body = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + clear_tags: true, + ..EditIndexerParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + RadarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_edit_indexer_body)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_radarr_indexers_event() { + let indexers_response_json = json!([{ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "supportsRss": true, + "supportsSearch": true, + "protocol": "torrent", + "priority": 25, + "downloadClientId": 0, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "implementationName": "Torznab", + "implementation": "Torznab", + "configContract": "TorznabSettings", + "tags": [1], + "id": 1 + }]); + let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexers_response_json), + None, + RadarrEvent::GetIndexers, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Indexers(indexers) = network + .handle_radarr_event(RadarrEvent::GetIndexers) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.indexers.items, + vec![indexer()] + ); + assert_eq!(indexers, response); + } + } + + #[tokio::test] + async fn test_handle_get_all_indexer_settings_event() { + let indexer_settings_response_json = json!({ + "minimumAge": 0, + "maximumSize": 0, + "retention": 0, + "rssSyncInterval": 60, + "preferIndexerFlags": false, + "availabilityDelay": 0, + "allowHardcodedSubs": true, + "whitelistedHardcodedSubs": "", + "id": 1 + }); + let response: IndexerSettings = + serde_json::from_value(indexer_settings_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_settings_response_json), + None, + RadarrEvent::GetAllIndexerSettings, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::IndexerSettings(settings) = network + .handle_radarr_event(RadarrEvent::GetAllIndexerSettings) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.indexer_settings, + Some(indexer_settings()) + ); + assert_eq!(settings, response); + } + } + + #[tokio::test] + async fn test_handle_get_all_indexer_settings_event_no_op_if_already_present() { + let indexer_settings_response_json = json!({ + "minimumAge": 0, + "maximumSize": 0, + "retention": 0, + "rssSyncInterval": 60, + "preferIndexerFlags": false, + "availabilityDelay": 0, + "allowHardcodedSubs": true, + "whitelistedHardcodedSubs": "", + "id": 1 + }); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_settings_response_json), + None, + RadarrEvent::GetAllIndexerSettings, + None, + None, + ) + .await; + app_arc.lock().await.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::GetAllIndexerSettings) + .await + .is_ok()); + + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.indexer_settings, + Some(IndexerSettings::default()) + ); + } + + #[tokio::test] + async fn test_handle_test_radarr_indexer_event_error() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let response_json = json!([ + { + "isWarning": false, + "propertyName": "", + "errorMessage": "test failure", + "severity": "error" + }]); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + RadarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", RadarrEvent::TestIndexer(1).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body(response_json.to_string()) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Value(value) = network + .handle_radarr_event(RadarrEvent::TestIndexer(1)) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.indexer_test_errors, + Some("\"test failure\"".to_owned()) + ); + assert_eq!(value, response_json) + } + } + + #[tokio::test] + async fn test_handle_test_radarr_indexer_event_success() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + RadarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", RadarrEvent::TestIndexer(1).resource()).as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body("{}") + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Value(value) = network + .handle_radarr_event(RadarrEvent::TestIndexer(1)) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.indexer_test_errors, + Some(String::new()) + ); + assert_eq!(value, json!({})); + } + } + + #[tokio::test] + async fn test_handle_test_all_radarr_indexers_event() { + let indexers = vec![ + Indexer { + id: 1, + name: Some("Test 1".to_owned()), + ..Indexer::default() + }, + Indexer { + id: 2, + name: Some("Test 2".to_owned()), + ..Indexer::default() + }, + ]; + let indexer_test_results_modal_items = vec![ + IndexerTestResultModalItem { + name: "Test 1".to_owned(), + is_valid: true, + validation_failures: HorizontallyScrollableText::default(), + }, + IndexerTestResultModalItem { + name: "Test 2".to_owned(), + is_valid: false, + validation_failures: "Failure for field 'test field 1': test error message, Failure for field 'test field 2': test error message 2".into(), + }, + ]; + let response_json = json!([ + { + "id": 1, + "isValid": true, + "validationFailures": [] + }, + { + "id": 2, + "isValid": false, + "validationFailures": [ + { + "propertyName": "test field 1", + "errorMessage": "test error message", + "severity": "error" + }, + { + "propertyName": "test field 2", + "errorMessage": "test error message 2", + "severity": "error" + }, + ] + }]); + let response: Vec = serde_json::from_value(response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + None, + Some(response_json), + Some(400), + RadarrEvent::TestAllIndexers, + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .radarr_data + .indexers + .set_items(indexers); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::IndexerTestResults(results) = network + .handle_radarr_event(RadarrEvent::TestAllIndexers) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .radarr_data + .indexer_test_all_results + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .radarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items, + indexer_test_results_modal_items + ); + assert_eq!(results, response); + } + } +} diff --git a/src/network/radarr_network/library/mod.rs b/src/network/radarr_network/library/mod.rs new file mode 100644 index 0000000..b71b338 --- /dev/null +++ b/src/network/radarr_network/library/mod.rs @@ -0,0 +1,675 @@ +use crate::models::radarr_models::{ + AddMovieBody, AddMovieSearchResult, Credit, CreditType, DeleteMovieParams, DownloadRecord, + EditMovieParams, Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease, + RadarrReleaseDownloadBody, +}; +use crate::models::servarr_data::radarr::modals::MovieDetailsModal; +use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::stateful_table::StatefulTable; +use crate::models::{Route, ScrollableText}; +use crate::network::radarr_network::RadarrEvent; +use crate::network::{Network, RequestMethod}; +use crate::utils::{convert_runtime, convert_to_gb}; +use anyhow::Result; +use indoc::formatdoc; +use log::{debug, info, warn}; +use serde_json::{json, Value}; +use urlencoding::encode; + +#[cfg(test)] +#[path = "radarr_library_network_tests.rs"] +mod radarr_library_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::radarr_network) async fn add_movie( + &mut self, + mut add_movie_body: AddMovieBody, + ) -> Result { + info!("Adding new movie to Radarr"); + let event = RadarrEvent::AddMovie(AddMovieBody::default()); + if let Some(tag_input_str) = add_movie_body.tag_input_string.as_ref() { + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tag_input_str).await; + add_movie_body.tags = tag_ids_vec; + } + + debug!("Add movie body: {add_movie_body:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(add_movie_body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn delete_movie( + &mut self, + delete_movie_params: DeleteMovieParams, + ) -> Result<()> { + let event = RadarrEvent::DeleteMovie(DeleteMovieParams::default()); + let DeleteMovieParams { + id, + delete_movie_files, + add_list_exclusion, + } = delete_movie_params; + info!("Deleting Radarr movie with ID: {id} with deleteFiles={delete_movie_files} and addImportExclusion={add_list_exclusion}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + Some(format!( + "deleteFiles={delete_movie_files}&addImportExclusion={add_list_exclusion}" + )), + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn download_radarr_release( + &mut self, + params: RadarrReleaseDownloadBody, + ) -> Result { + let event = RadarrEvent::DownloadRelease(RadarrReleaseDownloadBody::default()); + info!("Downloading Radarr release with params: {params:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(params), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn edit_movie( + &mut self, + mut edit_movie_params: EditMovieParams, + ) -> Result<()> { + info!("Editing Radarr movie"); + let movie_id = edit_movie_params.movie_id; + let detail_event = RadarrEvent::GetMovieDetails(movie_id); + let event = RadarrEvent::EditMovie(EditMovieParams::default()); + if let Some(tag_input_str) = edit_movie_params.tag_input_string.as_ref() { + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tag_input_str).await; + edit_movie_params.tags = Some(tag_ids_vec); + } + + info!("Fetching movie details for movie with ID: {movie_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{movie_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_movie_body, _| { + response = detailed_movie_body.to_string() + }) + .await?; + + info!("Constructing edit movie body"); + + let mut detailed_movie_body: Value = serde_json::from_str(&response)?; + let (monitored, minimum_availability, quality_profile_id, root_folder_path, tags) = { + let monitored = edit_movie_params.monitored.unwrap_or( + detailed_movie_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored'"), + ); + let minimum_availability = edit_movie_params + .minimum_availability + .unwrap_or_else(|| { + serde_json::from_value(detailed_movie_body["minimumAvailability"].clone()) + .expect("Unable to deserialize 'minimumAvailability'") + }) + .to_string(); + let quality_profile_id = edit_movie_params.quality_profile_id.unwrap_or_else(|| { + detailed_movie_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let root_folder_path = edit_movie_params.root_folder_path.unwrap_or_else(|| { + detailed_movie_body["path"] + .as_str() + .expect("Unable to deserialize 'path'") + .to_owned() + }); + let tags = if edit_movie_params.clear_tags { + vec![] + } else { + edit_movie_params.tags.unwrap_or( + detailed_movie_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + + ( + monitored, + minimum_availability, + quality_profile_id, + root_folder_path, + tags, + ) + }; + + *detailed_movie_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_movie_body.get_mut("minimumAvailability").unwrap() = json!(minimum_availability); + *detailed_movie_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); + *detailed_movie_body.get_mut("path").unwrap() = json!(root_folder_path); + *detailed_movie_body.get_mut("tags").unwrap() = json!(tags); + + debug!("Edit movie body: {detailed_movie_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_movie_body), + Some(format!("/{movie_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn get_credits( + &mut self, + movie_id: i64, + ) -> Result> { + info!("Fetching Radarr movie credits"); + let event = RadarrEvent::GetMovieCredits(movie_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("movieId={movie_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |credit_vec, mut app| { + let cast_vec: Vec = credit_vec + .iter() + .filter(|&credit| credit.credit_type == CreditType::Cast) + .cloned() + .collect(); + let crew_vec: Vec = credit_vec + .iter() + .filter(|&credit| credit.credit_type == CreditType::Crew) + .cloned() + .collect(); + + if app.data.radarr_data.movie_details_modal.is_none() { + app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); + } + + app + .data + .radarr_data + .movie_details_modal + .as_mut() + .unwrap() + .movie_cast + .set_items(cast_vec); + app + .data + .radarr_data + .movie_details_modal + .as_mut() + .unwrap() + .movie_crew + .set_items(crew_vec); + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_movies(&mut self) -> Result> { + info!("Fetching Radarr library"); + let event = RadarrEvent::GetMovies; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut movie_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Radarr(ActiveRadarrBlock::MoviesSortPrompt, _) + ) { + movie_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.radarr_data.movies.set_items(movie_vec); + app.data.radarr_data.movies.apply_sorting_toggle(false); + } + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_movie_details( + &mut self, + movie_id: i64, + ) -> Result { + info!("Fetching Radarr movie details"); + let event = RadarrEvent::GetMovieDetails(movie_id); + + info!("Fetching movie details for movie with ID: {movie_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{movie_id}")), + None, + ) + .await; + + self + .handle_request::<(), Movie>(request_props, |movie_response, mut app| { + let Movie { + id, + title, + year, + overview, + path, + studio, + has_file, + quality_profile_id, + size_on_disk, + genres, + runtime, + certification, + ratings, + movie_file, + collection, + .. + } = movie_response; + let (hours, minutes) = convert_runtime(runtime); + let size = convert_to_gb(size_on_disk); + let studio = studio.clone().unwrap_or_default(); + let quality_profile = app + .data + .radarr_data + .quality_profile_map + .get_by_left(&quality_profile_id) + .unwrap_or(&"".to_owned()) + .to_owned(); + let imdb_rating = if let Some(rating) = ratings.imdb { + if let Some(value) = rating.value.as_f64() { + format!("{value:.1}") + } else { + String::new() + } + } else { + String::new() + }; + + let tmdb_rating = if let Some(rating) = ratings.tmdb { + if let Some(value) = rating.value.as_f64() { + format!("{}%", (value * 10f64).ceil()) + } else { + String::new() + } + } else { + String::new() + }; + + let rotten_tomatoes_rating = if let Some(rating) = ratings.rotten_tomatoes { + if let Some(value) = rating.value.as_u64() { + format!("{value}%") + } else { + String::new() + } + } else { + String::new() + }; + + let status = get_movie_status(has_file, &app.data.radarr_data.downloads.items, id); + let collection = collection.unwrap_or_default(); + + let mut movie_details_modal = MovieDetailsModal { + movie_details: ScrollableText::with_string(formatdoc!( + "Title: {title} + Year: {year} + Runtime: {hours}h {minutes}m + Rating: {} + Collection: {} + Status: {status} + Description: {overview} + TMDB: {tmdb_rating} + IMDB: {imdb_rating} + Rotten Tomatoes: {rotten_tomatoes_rating} + Quality Profile: {quality_profile} + Size: {size:.2} GB + Path: {path} + Studio: {studio} + Genres: {}", + certification.unwrap_or_default(), + collection + .title + .as_ref() + .unwrap_or(&String::new()) + .to_owned(), + genres.join(", ") + )), + ..MovieDetailsModal::default() + }; + + if let Some(file) = movie_file { + movie_details_modal.file_details = formatdoc!( + "Relative Path: {} + Absolute Path: {} + Size: {size:.2} GB + Date Added: {}", + file.relative_path, + file.path, + file.date_added + ); + + if let Some(media_info) = file.media_info { + movie_details_modal.audio_details = formatdoc!( + "Bitrate: {} + Channels: {:.1} + Codec: {} + Languages: {} + Stream Count: {}", + media_info.audio_bitrate, + media_info.audio_channels.as_f64().unwrap(), + media_info.audio_codec.unwrap_or_default(), + media_info.audio_languages.unwrap_or_default(), + media_info.audio_stream_count + ); + + movie_details_modal.video_details = formatdoc!( + "Bit Depth: {} + Bitrate: {} + Codec: {} + FPS: {} + Resolution: {} + Scan Type: {} + Runtime: {}", + media_info.video_bit_depth, + media_info.video_bitrate, + media_info.video_codec.unwrap_or_default(), + media_info.video_fps.as_f64().unwrap(), + media_info.resolution, + media_info.scan_type, + media_info.run_time + ); + } + } + + app.data.radarr_data.movie_details_modal = Some(movie_details_modal); + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_movie_history( + &mut self, + movie_id: i64, + ) -> Result> { + info!("Fetching Radarr movie history"); + let event = RadarrEvent::GetMovieHistory(movie_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("movieId={movie_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |movie_history_vec, mut app| { + let mut reversed_movie_history_vec = movie_history_vec.to_vec(); + reversed_movie_history_vec.reverse(); + + if app.data.radarr_data.movie_details_modal.is_none() { + app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()) + } + + app + .data + .radarr_data + .movie_details_modal + .as_mut() + .unwrap() + .movie_history + .set_items(reversed_movie_history_vec) + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_movie_releases( + &mut self, + movie_id: i64, + ) -> Result> { + info!("Fetching releases for movie with ID: {movie_id}"); + let event = RadarrEvent::GetReleases(movie_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("movieId={movie_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + if app.data.radarr_data.movie_details_modal.is_none() { + app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); + } + + app + .data + .radarr_data + .movie_details_modal + .as_mut() + .unwrap() + .movie_releases + .set_items(release_vec); + }) + .await + } + + pub(in crate::network::radarr_network) async fn search_movie( + &mut self, + query: String, + ) -> Result> { + info!("Searching for specific Radarr movie"); + let event = RadarrEvent::SearchNewMovie(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("term={}", encode(&query))), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |movie_vec, mut app| { + if movie_vec.is_empty() { + app.pop_and_push_navigation_stack(ActiveRadarrBlock::AddMovieEmptySearchResults.into()); + } else if let Some(add_searched_movies) = app.data.radarr_data.add_searched_movies.as_mut() + { + add_searched_movies.set_items(movie_vec); + } else { + let mut add_searched_movies = StatefulTable::default(); + add_searched_movies.set_items(movie_vec); + app.data.radarr_data.add_searched_movies = Some(add_searched_movies); + } + }) + .await + } + + pub(in crate::network) async fn toggle_movie_monitoring(&mut self, movie_id: i64) -> Result<()> { + let event = RadarrEvent::ToggleMovieMonitoring(movie_id); + + let detail_event = RadarrEvent::GetMovieDetails(movie_id); + info!("Toggling movie monitoring for movie with ID: {movie_id}"); + info!("Fetching movie details for movie with ID: {movie_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{movie_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_movie_body, _| { + response = detailed_movie_body.to_string() + }) + .await?; + + info!("Constructing toggle movie monitoring body"); + + match serde_json::from_str::(&response) { + Ok(mut detailed_movie_body) => { + let monitored = detailed_movie_body + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + + *detailed_movie_body.get_mut("monitored").unwrap() = json!(!monitored); + + debug!("Toggle movie monitoring body: {detailed_movie_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_movie_body), + Some(format!("/{movie_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + Err(_) => { + warn!("Request for detailed movie body was interrupted"); + Ok(()) + } + } + } + + pub(in crate::network) async fn trigger_automatic_movie_search( + &mut self, + movie_id: i64, + ) -> Result { + let event = RadarrEvent::TriggerAutomaticSearch(movie_id); + info!("Searching indexers for movie with ID: {movie_id}"); + let body = MovieCommandBody { + name: "MoviesSearch".to_owned(), + movie_ids: vec![movie_id], + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network) async fn update_all_movies(&mut self) -> Result { + info!("Updating all movies"); + let event = RadarrEvent::UpdateAllMovies; + let body = MovieCommandBody { + name: "RefreshMovie".to_owned(), + movie_ids: Vec::new(), + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network) async fn update_and_scan_movie(&mut self, movie_id: i64) -> Result { + let event = RadarrEvent::UpdateAndScan(movie_id); + info!("Updating and scanning movie with ID: {movie_id}"); + let body = MovieCommandBody { + name: "RefreshMovie".to_owned(), + movie_ids: vec![movie_id], + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } +} + +pub(in crate::network::radarr_network::library) fn get_movie_status( + has_file: bool, + downloads_vec: &[DownloadRecord], + movie_id: i64, +) -> String { + if !has_file { + if let Some(download) = downloads_vec + .iter() + .find(|&download| download.movie_id == movie_id) + { + if download.status == "downloading" { + return "Downloading".to_owned(); + } + + if download.status == "completed" { + return "Awaiting Import".to_owned(); + } + } + + return "Missing".to_owned(); + } + + "Downloaded".to_owned() +} diff --git a/src/network/radarr_network/library/radarr_library_network_tests.rs b/src/network/radarr_network/library/radarr_library_network_tests.rs new file mode 100644 index 0000000..3199972 --- /dev/null +++ b/src/network/radarr_network/library/radarr_library_network_tests.rs @@ -0,0 +1,1235 @@ +#[cfg(test)] +mod tests { + use crate::models::radarr_models::{ + AddMovieBody, AddMovieOptions, Credit, DeleteMovieParams, DownloadRecord, EditMovieParams, + MinimumAvailability, Movie, MovieHistoryItem, RadarrReleaseDownloadBody, + }; + use crate::models::servarr_data::radarr::modals::MovieDetailsModal; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::radarr_network::library::get_movie_status; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::MOVIE_JSON; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::{ + add_movie_search_result, cast_credit, crew_credit, movie, movie_history_item, release, + }; + use crate::network::radarr_network::RadarrEvent; + use crate::network::radarr_network::RadarrSerdeable; + use crate::network::{Network, NetworkResource, RequestMethod}; + use bimap::BiMap; + use indoc::formatdoc; + use mockito::Matcher; + use pretty_assertions::{assert_eq, assert_str_eq}; + use reqwest::Client; + use rstest::rstest; + use serde_json::{json, Value}; + use std::slice; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_add_movie_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tmdbId": 1234, + "title": "Test", + "rootFolderPath": "/nfs2", + "minimumAvailability": "announced", + "monitored": true, + "qualityProfileId": 2222, + "tags": [1, 2], + "addOptions": { + "monitor": "movieOnly", + "searchForMovie": true + } + })), + Some(json!({})), + None, + RadarrEvent::AddMovie(AddMovieBody::default()), + None, + None, + ) + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let add_movie_body = AddMovieBody { + tmdb_id: 1234, + title: "Test".to_owned(), + root_folder_path: "/nfs2".to_owned(), + minimum_availability: "announced".to_owned(), + monitored: true, + quality_profile_id: 2222, + tags: vec![1, 2], + tag_input_string: Some("usenet, testing".into()), + add_options: AddMovieOptions { + monitor: "movieOnly".to_owned(), + search_for_movie: true, + }, + }; + + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::AddMovie(add_movie_body)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_add_movie_event_does_not_overwrite_tags_field_if_tag_input_string_is_none() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tmdbId": 1234, + "title": "Test", + "rootFolderPath": "/nfs2", + "minimumAvailability": "announced", + "monitored": true, + "qualityProfileId": 2222, + "tags": [1, 2], + "addOptions": { + "monitor": "movieOnly", + "searchForMovie": true + } + })), + Some(json!({})), + None, + RadarrEvent::AddMovie(AddMovieBody::default()), + None, + None, + ) + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let add_movie_body = AddMovieBody { + tmdb_id: 1234, + title: "Test".to_owned(), + root_folder_path: "/nfs2".to_owned(), + minimum_availability: "announced".to_owned(), + monitored: true, + quality_profile_id: 2222, + tags: vec![1, 2], + tag_input_string: None, + add_options: AddMovieOptions { + monitor: "movieOnly".to_owned(), + search_for_movie: true, + }, + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::AddMovie(add_movie_body)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_movie_event() { + let delete_movie_params = DeleteMovieParams { + id: 1, + delete_movie_files: true, + add_list_exclusion: true, + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteMovie(delete_movie_params.clone()), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::DeleteMovie(delete_movie_params)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_download_radarr_release_event() { + let expected_body = RadarrReleaseDownloadBody { + guid: "1234".to_owned(), + indexer_id: 2, + movie_id: 1, + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "guid": "1234", + "indexerId": 2, + "movieId": 1 + })), + Some(json!({})), + None, + RadarrEvent::DownloadRelease(expected_body.clone()), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::DownloadRelease(expected_body)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_movie_event() { + let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("minimumAvailability").unwrap() = json!("announced"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_movie_params = EditMovieParams { + movie_id: 1, + monitored: Some(false), + minimum_availability: Some(MinimumAvailability::Announced), + quality_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".into()), + ..EditMovieParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(MOVIE_JSON).unwrap()), + None, + RadarrEvent::GetMovieDetails(1), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + RadarrEvent::EditMovie(edit_movie_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditMovie(edit_movie_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_movie_event_does_not_overwrite_tags_vec_if_tag_input_string_is_none() { + let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("minimumAvailability").unwrap() = json!("announced"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_movie_params = EditMovieParams { + movie_id: 1, + monitored: Some(false), + minimum_availability: Some(MinimumAvailability::Announced), + quality_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tags: Some(vec![1, 2]), + ..EditMovieParams::default() + }; + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(MOVIE_JSON).unwrap()), + None, + RadarrEvent::GetMovieDetails(1), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + RadarrEvent::EditMovie(edit_movie_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditMovie(edit_movie_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_movie_event_defaults_to_previous_values() { + let expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(MOVIE_JSON).unwrap()), + None, + RadarrEvent::GetMovieDetails(1), + Some("/1"), + None, + ) + .await; + let edit_movie_params = EditMovieParams { + movie_id: 1, + ..EditMovieParams::default() + }; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + RadarrEvent::EditMovie(edit_movie_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditMovie(edit_movie_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_movie_event_uses_provided_parameters_returns_empty_tags_vec_when_clear_tags_is_true( + ) { + let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(MOVIE_JSON).unwrap()), + None, + RadarrEvent::GetMovieDetails(1), + Some("/1"), + None, + ) + .await; + let edit_movie_params = EditMovieParams { + movie_id: 1, + clear_tags: true, + ..EditMovieParams::default() + }; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + RadarrEvent::EditMovie(edit_movie_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::EditMovie(edit_movie_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_movie_credits_event() { + let credits_json = json!([ + { + "personName": "Madison Clarke", + "character": "Johnny Blaze", + "type": "cast", + }, + { + "personName": "Alex Clarke", + "department": "Music", + "job": "Composition", + "type": "crew", + } + ]); + let response: Vec = serde_json::from_value(credits_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(credits_json), + None, + RadarrEvent::GetMovieCredits(1), + None, + Some("movieId=1"), + ) + .await; + app_arc.lock().await.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Credits(credits) = network + .handle_radarr_event(RadarrEvent::GetMovieCredits(1)) + .await + .unwrap() + { + let app = app_arc.lock().await; + let movie_details_modal = app.data.radarr_data.movie_details_modal.as_ref().unwrap(); + + async_server.assert_async().await; + assert_eq!(movie_details_modal.movie_cast.items, vec![cast_credit()]); + assert_eq!(movie_details_modal.movie_crew.items, vec![crew_credit()]); + assert_eq!(credits, response); + } + } + + #[tokio::test] + async fn test_handle_get_movie_credits_event_empty_movie_details_modal() { + let credits_json = json!([ + { + "personName": "Madison Clarke", + "character": "Johnny Blaze", + "type": "cast", + }, + { + "personName": "Alex Clarke", + "department": "Music", + "job": "Composition", + "type": "crew", + } + ]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(credits_json), + None, + RadarrEvent::GetMovieCredits(1), + None, + Some("movieId=1"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::GetMovieCredits(1)) + .await + .is_ok()); + + let app = app_arc.lock().await; + let movie_details_modal = app.data.radarr_data.movie_details_modal.as_ref().unwrap(); + + async_server.assert_async().await; + assert_eq!(movie_details_modal.movie_cast.items, vec![cast_credit()]); + assert_eq!(movie_details_modal.movie_crew.items, vec![crew_credit()]); + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_movies_event(#[values(true, false)] use_custom_sorting: bool) { + let mut movie_1: Value = serde_json::from_str(MOVIE_JSON).unwrap(); + let mut movie_2: Value = serde_json::from_str(MOVIE_JSON).unwrap(); + *movie_1.get_mut("id").unwrap() = json!(1); + *movie_1.get_mut("title").unwrap() = json!("z test"); + *movie_2.get_mut("id").unwrap() = json!(2); + *movie_2.get_mut("title").unwrap() = json!("A test"); + let expected_movies = vec![ + Movie { + id: 1, + title: "z test".into(), + ..movie() + }, + Movie { + id: 2, + title: "A test".into(), + ..movie() + }, + ]; + let mut expected_sorted_movies = vec![ + Movie { + id: 1, + title: "z test".into(), + ..movie() + }, + Movie { + id: 2, + title: "A test".into(), + ..movie() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([movie_1, movie_2])), + None, + RadarrEvent::GetMovies, + None, + None, + ) + .await; + app_arc.lock().await.data.radarr_data.movies.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &Movie, b: &Movie| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + expected_sorted_movies.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .radarr_data + .movies + .sorting(vec![title_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Movies(movies) = network + .handle_radarr_event(RadarrEvent::GetMovies) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.movies.items, + expected_sorted_movies + ); + assert!(app_arc.lock().await.data.radarr_data.movies.sort_asc); + assert_eq!(movies, expected_movies); + } + } + + #[tokio::test] + async fn test_handle_get_movies_event_no_op_while_user_is_selecting_sort_options() { + let mut movie_1: Value = serde_json::from_str(MOVIE_JSON).unwrap(); + let mut movie_2: Value = serde_json::from_str(MOVIE_JSON).unwrap(); + *movie_1.get_mut("id").unwrap() = json!(1); + *movie_1.get_mut("title").unwrap() = json!("z test"); + *movie_2.get_mut("id").unwrap() = json!(2); + *movie_2.get_mut("title").unwrap() = json!("A test"); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([movie_1, movie_2])), + None, + RadarrEvent::GetMovies, + None, + None, + ) + .await; + app_arc + .lock() + .await + .push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); + app_arc.lock().await.data.radarr_data.movies.sort_asc = true; + let cmp_fn = |a: &Movie, b: &Movie| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .radarr_data + .movies + .sorting(vec![title_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::GetMovies) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .radarr_data + .movies + .items + .is_empty()); + assert!(app_arc.lock().await.data.radarr_data.movies.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_movie_details_event() { + let response: Movie = serde_json::from_str(MOVIE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(MOVIE_JSON).unwrap()), + None, + RadarrEvent::GetMovieDetails(1), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.data.radarr_data.quality_profile_map = + BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Movie(movie) = network + .handle_radarr_event(RadarrEvent::GetMovieDetails(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .radarr_data + .movie_details_modal + .is_some()); + assert_eq!(movie, response); + + let app = app_arc.lock().await; + let movie_details_modal = app.data.radarr_data.movie_details_modal.as_ref().unwrap(); + assert_str_eq!( + movie_details_modal.movie_details.get_text(), + formatdoc!( + "Title: Test + Year: 2023 + Runtime: 2h 0m + Rating: R + Collection: Test Collection + Status: Downloaded + Description: Blah blah blah + TMDB: 99% + IMDB: 9.9 + Rotten Tomatoes: + Quality Profile: HD - 1080p + Size: 3.30 GB + Path: /nfs/movies + Studio: 21st Century Alex + Genres: cool, family, fun" + ) + ); + assert_str_eq!( + movie_details_modal.file_details, + formatdoc!( + "Relative Path: Test.mkv + Absolute Path: /nfs/movies/Test.mkv + Size: 3.30 GB + Date Added: 2022-12-30 07:37:56 UTC" + ) + ); + assert_str_eq!( + movie_details_modal.audio_details, + formatdoc!( + "Bitrate: 0 + Channels: 7.1 + Codec: AAC + Languages: eng + Stream Count: 1" + ) + ); + assert_str_eq!( + movie_details_modal.video_details, + formatdoc!( + "Bit Depth: 10 + Bitrate: 0 + Codec: x265 + FPS: 23.976 + Resolution: 1920x804 + Scan Type: Progressive + Runtime: 2:00:00" + ) + ); + } + } + + #[tokio::test] + async fn test_handle_get_movie_details_event_empty_options_give_correct_defaults() { + let movie_json_with_missing_fields = json!({ + "id": 1, + "title": "Test", + "originalLanguage": { + "id": 1, + "name": "English" + }, + "sizeOnDisk": 0, + "status": "Downloaded", + "overview": "Blah blah blah", + "path": "/nfs/movies", + "studio": "21st Century Alex", + "genres": ["cool", "family", "fun"], + "year": 2023, + "monitored": true, + "hasFile": false, + "runtime": 120, + "tmdbId": 1234, + "qualityProfileId": 2222, + "tags": [1], + "minimumAvailability": "released", + "ratings": {} + }); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(movie_json_with_missing_fields), + None, + RadarrEvent::GetMovieDetails(1), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.data.radarr_data.quality_profile_map = + BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::GetMovieDetails(1)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .radarr_data + .movie_details_modal + .is_some()); + + let app = app_arc.lock().await; + let movie_details_modal = app.data.radarr_data.movie_details_modal.as_ref().unwrap(); + assert_str_eq!( + movie_details_modal.movie_details.get_text(), + formatdoc!( + "Title: Test + Year: 2023 + Runtime: 2h 0m + Rating: + Collection: + Status: Missing + Description: Blah blah blah + TMDB: + IMDB: + Rotten Tomatoes: + Quality Profile: HD - 1080p + Size: 0.00 GB + Path: /nfs/movies + Studio: 21st Century Alex + Genres: cool, family, fun" + ) + ); + assert!(movie_details_modal.file_details.is_empty()); + assert!(movie_details_modal.audio_details.is_empty()); + assert!(movie_details_modal.video_details.is_empty()); + } + + #[tokio::test] + async fn test_handle_get_movie_history_event() { + let movie_history_item_json = json!([{ + "sourceTitle": "Test", + "quality": { "quality": { "name": "HD - 1080p" }}, + "languages": [ { "id": 1, "name": "English" } ], + "date": "2022-12-30T07:37:56Z", + "eventType": "grabbed" + }]); + let response: Vec = + serde_json::from_value(movie_history_item_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(movie_history_item_json), + None, + RadarrEvent::GetMovieHistory(1), + None, + Some("movieId=1"), + ) + .await; + app_arc.lock().await.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::MovieHistoryItems(history) = network + .handle_radarr_event(RadarrEvent::GetMovieHistory(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .radarr_data + .movie_details_modal + .as_ref() + .unwrap() + .movie_history + .items, + vec![movie_history_item()] + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_movie_history_event_empty_movie_details_modal() { + let movie_history_item_json = json!([{ + "sourceTitle": "Test", + "quality": { "quality": { "name": "HD - 1080p" }}, + "languages": [ { "id": 1, "name": "English" } ], + "date": "2022-12-30T07:37:56Z", + "eventType": "grabbed" + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(movie_history_item_json), + None, + RadarrEvent::GetMovieHistory(1), + None, + Some("movieId=1"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::GetMovieHistory(1)) + .await + .is_ok()); + + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .radarr_data + .movie_details_modal + .as_ref() + .unwrap() + .movie_history + .items, + vec![movie_history_item()] + ); + } + + #[tokio::test] + async fn test_handle_get_movie_releases_event() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "HD - 1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + RadarrEvent::GetReleases(1), + None, + Some("movieId=1"), + ) + .await; + app_arc.lock().await.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Releases(releases_vec) = network + .handle_radarr_event(RadarrEvent::GetReleases(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .radarr_data + .movie_details_modal + .as_ref() + .unwrap() + .movie_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + async fn test_handle_get_movie_releases_event_empty_movie_details_modal() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "HD - 1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + RadarrEvent::GetReleases(1), + None, + Some("movieId=1"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::GetReleases(1)) + .await + .is_ok()); + + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .radarr_data + .movie_details_modal + .as_ref() + .unwrap() + .movie_releases + .items, + vec![release()] + ); + } + + #[tokio::test] + async fn test_handle_search_new_movie_event() { + let add_movie_search_result_json = json!([{ + "tmdbId": 1234, + "title": "Test", + "originalLanguage": { "id": 1, "name": "English" }, + "status": "released", + "overview": "New movie blah blah blah", + "genres": ["cool", "family", "fun"], + "year": 2023, + "runtime": 120, + "ratings": { + "imdb": { + "value": 9.9 + }, + "tmdb": { + "value": 9.9 + }, + "rottenTomatoes": { + "value": 9.9 + } + } + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(add_movie_search_result_json), + None, + RadarrEvent::SearchNewMovie("test term".into()), + None, + Some("term=test%20term"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::AddMovieSearchResults(add_movie_search_results) = network + .handle_radarr_event(RadarrEvent::SearchNewMovie("test term".into())) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .radarr_data + .add_searched_movies + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .radarr_data + .add_searched_movies + .as_ref() + .unwrap() + .items, + vec![add_movie_search_result()] + ); + assert_eq!(add_movie_search_results, vec![add_movie_search_result()]); + } + } + + #[tokio::test] + async fn test_handle_search_new_movie_event_no_results() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([])), + None, + RadarrEvent::SearchNewMovie("test term".into()), + None, + Some("term=test%20term"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::SearchNewMovie("test term".into())) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .radarr_data + .add_searched_movies + .is_none()); + assert_eq!( + app_arc.lock().await.get_current_route(), + ActiveRadarrBlock::AddMovieEmptySearchResults.into() + ); + } + + #[tokio::test] + async fn test_handle_toggle_movie_monitoring_event() { + let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(MOVIE_JSON).unwrap()), + None, + RadarrEvent::GetMovieDetails(1), + Some("/1"), + None, + ) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + RadarrEvent::ToggleMovieMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.radarr_data.movies.set_items(vec![movie()]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::ToggleMovieMonitoring(1)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_movie_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "MoviesSearch", + "movieIds": [ 1 ] + })), + Some(json!({})), + None, + RadarrEvent::TriggerAutomaticSearch(1), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::TriggerAutomaticSearch(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_update_all_movies_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshMovie", + "movieIds": [] + })), + Some(json!({})), + None, + RadarrEvent::UpdateAllMovies, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::UpdateAllMovies) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_update_and_scan_movie_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshMovie", + "movieIds": [ 1 ] + })), + Some(json!({})), + None, + RadarrEvent::UpdateAndScan(1), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::UpdateAndScan(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[test] + fn test_get_movie_status_downloaded() { + assert_str_eq!(get_movie_status(true, &[], 0), "Downloaded"); + } + + #[test] + fn test_get_movie_status_missing() { + let download_record = DownloadRecord { + movie_id: 1, + ..DownloadRecord::default() + }; + + assert_str_eq!( + get_movie_status(false, slice::from_ref(&download_record), 0), + "Missing" + ); + + assert_str_eq!(get_movie_status(false, &[download_record], 1), "Missing"); + } + + #[test] + fn test_get_movie_status_downloading() { + assert_str_eq!( + get_movie_status( + false, + &[DownloadRecord { + movie_id: 1, + status: "downloading".to_owned(), + ..DownloadRecord::default() + }], + 1 + ), + "Downloading" + ); + } + + #[test] + fn test_get_movie_status_awaiting_import() { + assert_str_eq!( + get_movie_status( + false, + &[DownloadRecord { + movie_id: 1, + status: "completed".to_owned(), + ..DownloadRecord::default() + }], + 1 + ), + "Awaiting Import" + ); + } +} diff --git a/src/network/radarr_network/mod.rs b/src/network/radarr_network/mod.rs new file mode 100644 index 0000000..14e5511 --- /dev/null +++ b/src/network/radarr_network/mod.rs @@ -0,0 +1,420 @@ +use anyhow::Result; +use std::fmt::Debug; + +use log::info; +use serde_json::{json, Value}; + +use crate::models::radarr_models::{ + AddMovieBody, DeleteMovieParams, EditCollectionParams, EditMovieParams, IndexerSettings, + RadarrReleaseDownloadBody, RadarrSerdeable, RadarrTaskName, +}; +use crate::models::servarr_models::{AddRootFolderBody, EditIndexerParams, QualityProfile, Tag}; +use crate::network::{Network, NetworkEvent, RequestMethod}; + +use super::NetworkResource; + +mod blocklist; +mod collections; +mod downloads; +mod indexers; +mod library; +mod root_folders; +mod system; + +#[cfg(test)] +#[path = "radarr_network_tests.rs"] +mod radarr_network_tests; + +#[cfg(test)] +#[path = "radarr_network_test_utils.rs"] +mod radarr_network_test_utils; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum RadarrEvent { + AddMovie(AddMovieBody), + AddRootFolder(AddRootFolderBody), + AddTag(String), + ClearBlocklist, + DeleteBlocklistItem(i64), + DeleteDownload(i64), + DeleteIndexer(i64), + DeleteMovie(DeleteMovieParams), + DeleteRootFolder(i64), + DeleteTag(i64), + DownloadRelease(RadarrReleaseDownloadBody), + EditAllIndexerSettings(IndexerSettings), + EditCollection(EditCollectionParams), + EditIndexer(EditIndexerParams), + EditMovie(EditMovieParams), + GetBlocklist, + GetCollections, + GetDownloads(u64), + GetHostConfig, + GetIndexers, + GetAllIndexerSettings, + GetLogs(u64), + GetMovieCredits(i64), + GetMovieDetails(i64), + GetMovieHistory(i64), + GetMovies, + GetDiskSpace, + GetQualityProfiles, + GetQueuedEvents, + GetReleases(i64), + GetRootFolders, + GetSecurityConfig, + GetStatus, + GetTags, + GetTasks, + GetUpdates, + HealthCheck, + SearchNewMovie(String), + StartTask(RadarrTaskName), + TestIndexer(i64), + TestAllIndexers, + ToggleMovieMonitoring(i64), + TriggerAutomaticSearch(i64), + UpdateAllMovies, + UpdateAndScan(i64), + UpdateCollections, + UpdateDownloads, +} + +impl NetworkResource for RadarrEvent { + fn resource(&self) -> &'static str { + match &self { + RadarrEvent::ClearBlocklist => "/blocklist/bulk", + RadarrEvent::DeleteBlocklistItem(_) => "/blocklist", + RadarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", + RadarrEvent::GetCollections | RadarrEvent::EditCollection(_) => "/collection", + RadarrEvent::GetDownloads(_) | RadarrEvent::DeleteDownload(_) => "/queue", + RadarrEvent::GetHostConfig | RadarrEvent::GetSecurityConfig => "/config/host", + RadarrEvent::GetIndexers | RadarrEvent::EditIndexer(_) | RadarrEvent::DeleteIndexer(_) => { + "/indexer" + } + RadarrEvent::GetAllIndexerSettings | RadarrEvent::EditAllIndexerSettings(_) => { + "/config/indexer" + } + RadarrEvent::GetLogs(_) => "/log", + RadarrEvent::AddMovie(_) + | RadarrEvent::EditMovie(_) + | RadarrEvent::GetMovies + | RadarrEvent::GetMovieDetails(_) + | RadarrEvent::DeleteMovie(_) + | RadarrEvent::ToggleMovieMonitoring(_) => "/movie", + RadarrEvent::SearchNewMovie(_) => "/movie/lookup", + RadarrEvent::GetMovieCredits(_) => "/credit", + RadarrEvent::GetMovieHistory(_) => "/history/movie", + RadarrEvent::GetDiskSpace => "/diskspace", + RadarrEvent::GetQualityProfiles => "/qualityprofile", + RadarrEvent::GetReleases(_) | RadarrEvent::DownloadRelease(_) => "/release", + RadarrEvent::AddRootFolder(_) + | RadarrEvent::GetRootFolders + | RadarrEvent::DeleteRootFolder(_) => "/rootfolder", + RadarrEvent::GetStatus => "/system/status", + RadarrEvent::GetTags | RadarrEvent::AddTag(_) | RadarrEvent::DeleteTag(_) => "/tag", + RadarrEvent::GetTasks => "/system/task", + RadarrEvent::GetUpdates => "/update", + RadarrEvent::TestIndexer(_) => "/indexer/test", + RadarrEvent::TestAllIndexers => "/indexer/testall", + RadarrEvent::StartTask(_) + | RadarrEvent::GetQueuedEvents + | RadarrEvent::TriggerAutomaticSearch(_) + | RadarrEvent::UpdateAndScan(_) + | RadarrEvent::UpdateAllMovies + | RadarrEvent::UpdateDownloads + | RadarrEvent::UpdateCollections => "/command", + RadarrEvent::HealthCheck => "/health", + } + } +} + +impl From for NetworkEvent { + fn from(radarr_event: RadarrEvent) -> Self { + NetworkEvent::Radarr(radarr_event) + } +} + +impl Network<'_, '_> { + pub async fn handle_radarr_event( + &mut self, + radarr_event: RadarrEvent, + ) -> Result { + match radarr_event { + RadarrEvent::AddMovie(body) => self.add_movie(body).await.map(RadarrSerdeable::from), + RadarrEvent::AddRootFolder(path) => self + .add_radarr_root_folder(path) + .await + .map(RadarrSerdeable::from), + RadarrEvent::AddTag(tag) => self.add_radarr_tag(tag).await.map(RadarrSerdeable::from), + RadarrEvent::ClearBlocklist => self + .clear_radarr_blocklist() + .await + .map(RadarrSerdeable::from), + RadarrEvent::DeleteBlocklistItem(blocklist_item_id) => self + .delete_radarr_blocklist_item(blocklist_item_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::DeleteDownload(download_id) => self + .delete_radarr_download(download_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::DeleteIndexer(indexer_id) => self + .delete_radarr_indexer(indexer_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::DeleteMovie(params) => { + self.delete_movie(params).await.map(RadarrSerdeable::from) + } + RadarrEvent::DeleteRootFolder(root_folder_id) => self + .delete_radarr_root_folder(root_folder_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::DeleteTag(tag_id) => self + .delete_radarr_tag(tag_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::DownloadRelease(params) => self + .download_radarr_release(params) + .await + .map(RadarrSerdeable::from), + RadarrEvent::EditAllIndexerSettings(params) => self + .edit_all_radarr_indexer_settings(params) + .await + .map(RadarrSerdeable::from), + RadarrEvent::EditCollection(params) => self + .edit_collection(params) + .await + .map(RadarrSerdeable::from), + RadarrEvent::EditIndexer(params) => self + .edit_radarr_indexer(params) + .await + .map(RadarrSerdeable::from), + RadarrEvent::EditMovie(params) => self.edit_movie(params).await.map(RadarrSerdeable::from), + RadarrEvent::GetAllIndexerSettings => self + .get_all_radarr_indexer_settings() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetBlocklist => self.get_radarr_blocklist().await.map(RadarrSerdeable::from), + RadarrEvent::GetCollections => self.get_collections().await.map(RadarrSerdeable::from), + RadarrEvent::GetDownloads(count) => self + .get_radarr_downloads(count) + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetHostConfig => self + .get_radarr_host_config() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetIndexers => self.get_radarr_indexers().await.map(RadarrSerdeable::from), + RadarrEvent::GetLogs(events) => self + .get_radarr_logs(events) + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetMovieCredits(movie_id) => { + self.get_credits(movie_id).await.map(RadarrSerdeable::from) + } + RadarrEvent::GetMovieDetails(movie_id) => self + .get_movie_details(movie_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetMovieHistory(movie_id) => self + .get_movie_history(movie_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetMovies => self.get_movies().await.map(RadarrSerdeable::from), + RadarrEvent::GetDiskSpace => self.get_radarr_diskspace().await.map(RadarrSerdeable::from), + RadarrEvent::GetQualityProfiles => self + .get_radarr_quality_profiles() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetQueuedEvents => self + .get_queued_radarr_events() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetReleases(movie_id) => self + .get_movie_releases(movie_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetRootFolders => self + .get_radarr_root_folders() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetSecurityConfig => self + .get_radarr_security_config() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetStatus => self.get_radarr_status().await.map(RadarrSerdeable::from), + RadarrEvent::GetTags => self.get_radarr_tags().await.map(RadarrSerdeable::from), + RadarrEvent::GetTasks => self.get_radarr_tasks().await.map(RadarrSerdeable::from), + RadarrEvent::GetUpdates => self.get_radarr_updates().await.map(RadarrSerdeable::from), + RadarrEvent::HealthCheck => self + .get_radarr_healthcheck() + .await + .map(RadarrSerdeable::from), + RadarrEvent::SearchNewMovie(query) => { + self.search_movie(query).await.map(RadarrSerdeable::from) + } + RadarrEvent::StartTask(task_name) => self + .start_radarr_task(task_name) + .await + .map(RadarrSerdeable::from), + RadarrEvent::TestIndexer(indexer_id) => self + .test_radarr_indexer(indexer_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::TestAllIndexers => self + .test_all_radarr_indexers() + .await + .map(RadarrSerdeable::from), + RadarrEvent::ToggleMovieMonitoring(movie_id) => self + .toggle_movie_monitoring(movie_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::TriggerAutomaticSearch(movie_id) => self + .trigger_automatic_movie_search(movie_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::UpdateAllMovies => self.update_all_movies().await.map(RadarrSerdeable::from), + RadarrEvent::UpdateAndScan(movie_id) => self + .update_and_scan_movie(movie_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::UpdateCollections => self.update_collections().await.map(RadarrSerdeable::from), + RadarrEvent::UpdateDownloads => self + .update_radarr_downloads() + .await + .map(RadarrSerdeable::from), + } + } + + pub(in crate::network::radarr_network) async fn add_radarr_tag( + &mut self, + tag: String, + ) -> Result { + info!("Adding a new Radarr tag"); + let event = RadarrEvent::AddTag(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(json!({ "label": tag })), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |tag, mut app| { + app.data.radarr_data.tags_map.insert(tag.id, tag.label); + }) + .await + } + + pub(in crate::network::radarr_network) async fn delete_radarr_tag( + &mut self, + id: i64, + ) -> Result<()> { + info!("Deleting Radarr tag with id: {id}"); + let event = RadarrEvent::DeleteTag(id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn get_radarr_healthcheck(&mut self) -> Result<()> { + info!("Performing Radarr health check"); + let event = RadarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn get_radarr_quality_profiles(&mut self) -> Result> { + info!("Fetching Radarr quality profiles"); + let event = RadarrEvent::GetQualityProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { + app.data.radarr_data.quality_profile_map = quality_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + async fn get_radarr_tags(&mut self) -> Result> { + info!("Fetching Radarr tags"); + let event = RadarrEvent::GetTags; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { + app.data.radarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id, tag.label)) + .collect(); + }) + .await + } + + pub(in crate::network::radarr_network) async fn extract_and_add_radarr_tag_ids_vec( + &mut self, + edit_tags: &str, + ) -> Vec { + let missing_tags_vec = { + let tags_map = &self.app.lock().await.data.radarr_data.tags_map; + edit_tags + .split(',') + .filter(|&tag| { + !tag.is_empty() && tags_map.get_by_right(tag.to_lowercase().trim()).is_none() + }) + .collect::>() + }; + + for tag in missing_tags_vec { + self + .add_radarr_tag(tag.trim().to_owned()) + .await + .expect("Unable to add tag"); + } + + let app = self.app.lock().await; + edit_tags + .split(',') + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .radarr_data + .tags_map + .get_by_right(tag.to_lowercase().trim()) + .unwrap() + }) + .collect() + } +} diff --git a/src/network/radarr_network/radarr_network_test_utils.rs b/src/network/radarr_network/radarr_network_test_utils.rs new file mode 100644 index 0000000..54fb205 --- /dev/null +++ b/src/network/radarr_network/radarr_network_test_utils.rs @@ -0,0 +1,381 @@ +#[cfg(test)] +pub(in crate::network::radarr_network) mod test_utils { + use crate::models::radarr_models::{ + AddMovieSearchResult, BlocklistItem, BlocklistItemMovie, Collection, CollectionMovie, Credit, + CreditType, DownloadRecord, DownloadsResponse, IndexerSettings, MediaInfo, MinimumAvailability, + Movie, MovieCollection, MovieFile, MovieHistoryItem, RadarrRelease, Rating, RatingsList, + }; + use crate::models::servarr_models::{ + Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + }; + use crate::models::HorizontallyScrollableText; + use chrono::DateTime; + use serde_json::{json, Number}; + + pub const MOVIE_JSON: &str = r#"{ + "id": 1, + "title": "Test", + "tmdbId": 1234, + "originalLanguage": { + "id": 1, + "name": "English" + }, + "sizeOnDisk": 3543348019, + "status": "Downloaded", + "overview": "Blah blah blah", + "path": "/nfs/movies", + "studio": "21st Century Alex", + "genres": ["cool", "family", "fun"], + "year": 2023, + "monitored": true, + "hasFile": true, + "runtime": 120, + "qualityProfileId": 2222, + "minimumAvailability": "announced", + "certification": "R", + "tags": [1], + "ratings": { + "imdb": { + "value": 9.9 + }, + "tmdb": { + "value": 9.9 + }, + "rottenTomatoes": { + "value": 9.9 + } + }, + "movieFile": { + "relativePath": "Test.mkv", + "path": "/nfs/movies/Test.mkv", + "dateAdded": "2022-12-30T07:37:56Z", + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 7.1, + "audioCodec": "AAC", + "audioLanguages": "eng", + "audioStreamCount": 1, + "videoBitDepth": 10, + "videoBitrate": 0, + "videoCodec": "x265", + "videoFps": 23.976, + "resolution": "1920x804", + "runTime": "2:00:00", + "scanType": "Progressive" + } + }, + "collection": { + "id": 123, + "title": "Test Collection", + "rootFolderPath": "/nfs/movies", + "searchOnAdd": true, + "monitored": true, + "minimumAvailability": "released", + "overview": "Collection blah blah blah", + "qualityProfileId": 2222, + "movies": [ + { + "title": "Test", + "overview": "Collection blah blah blah", + "year": 2023, + "runtime": 120, + "tmdbId": 1234, + "genres": ["cool", "family", "fun"], + "ratings": { + "imdb": { + "value": 9.9 + }, + "tmdb": { + "value": 9.9 + }, + "rottenTomatoes": { + "value": 9.9 + } + } + } + ] + } + }"#; + + pub fn language() -> Language { + Language { + id: 1, + name: "English".to_owned(), + } + } + + pub fn genres() -> Vec { + vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()] + } + + pub fn rating() -> Rating { + Rating { + value: Number::from_f64(9.9).unwrap(), + } + } + + pub fn ratings_list() -> RatingsList { + RatingsList { + imdb: Some(rating()), + tmdb: Some(rating()), + rotten_tomatoes: Some(rating()), + } + } + + pub fn media_info() -> MediaInfo { + MediaInfo { + audio_bitrate: 0, + audio_channels: Number::from_f64(7.1).unwrap(), + audio_codec: Some("AAC".to_owned()), + audio_languages: Some("eng".to_owned()), + audio_stream_count: 1, + video_bit_depth: 10, + video_bitrate: 0, + video_codec: Some("x265".to_owned()), + video_fps: Number::from_f64(23.976).unwrap(), + resolution: "1920x804".to_owned(), + run_time: "2:00:00".to_owned(), + scan_type: "Progressive".to_owned(), + } + } + + pub fn movie_file() -> MovieFile { + MovieFile { + relative_path: "Test.mkv".to_owned(), + path: "/nfs/movies/Test.mkv".to_owned(), + date_added: DateTime::from(DateTime::parse_from_rfc3339("2022-12-30T07:37:56Z").unwrap()), + media_info: Some(media_info()), + } + } + + pub fn collection_movie() -> CollectionMovie { + CollectionMovie { + title: "Test".to_owned().into(), + overview: "Collection blah blah blah".to_owned(), + year: 2023, + runtime: 120, + tmdb_id: 1234, + genres: genres(), + ratings: ratings_list(), + } + } + + pub fn blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 1, + movie_id: 1, + source_title: "z movie".to_owned(), + languages: vec![language()], + quality: quality_wrapper(), + custom_formats: Some(vec![language()]), + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug (Prowlarr)".to_owned(), + message: "test message".to_owned(), + movie: blocklist_item_movie(), + } + } + + pub fn blocklist_item_movie() -> BlocklistItemMovie { + BlocklistItemMovie { + title: "Test".into(), + } + } + + pub fn collection() -> Collection { + Collection { + id: 123, + title: "Test Collection".to_owned().into(), + root_folder_path: Some("/nfs/movies".to_owned()), + search_on_add: true, + monitored: true, + minimum_availability: MinimumAvailability::Released, + overview: Some("Collection blah blah blah".to_owned()), + quality_profile_id: 2222, + movies: Some(vec![collection_movie()]), + } + } + + pub fn movie() -> Movie { + Movie { + id: 1, + title: "Test".to_owned().into(), + original_language: language(), + size_on_disk: 3543348019, + status: "Downloaded".to_owned(), + overview: "Blah blah blah".to_owned(), + path: "/nfs/movies".to_owned(), + studio: Some("21st Century Alex".to_owned()), + genres: genres(), + year: 2023, + monitored: true, + has_file: true, + runtime: 120, + tmdb_id: 1234, + quality_profile_id: 2222, + minimum_availability: MinimumAvailability::Announced, + certification: Some("R".to_owned()), + tags: vec![Number::from(1)], + ratings: ratings_list(), + movie_file: Some(movie_file()), + collection: Some(movie_collection()), + } + } + + pub fn movie_collection() -> MovieCollection { + MovieCollection { + title: Some("Test Collection".to_owned()), + } + } + + pub fn rejections() -> Vec { + vec![ + "Unknown quality profile".to_owned(), + "Release is already mapped".to_owned(), + ] + } + + pub fn quality() -> Quality { + Quality { + name: "HD - 1080p".to_owned(), + } + } + + pub fn quality_wrapper() -> QualityWrapper { + QualityWrapper { quality: quality() } + } + + pub fn release() -> RadarrRelease { + RadarrRelease { + guid: "1234".to_owned(), + protocol: "torrent".to_owned(), + age: 1, + title: HorizontallyScrollableText::from("Test Release"), + indexer: "kickass torrents".to_owned(), + indexer_id: 2, + size: 1234, + rejected: true, + rejections: Some(rejections()), + seeders: Some(Number::from(2)), + leechers: Some(Number::from(1)), + languages: Some(vec![language()]), + quality: quality_wrapper(), + } + } + + pub fn add_movie_search_result() -> AddMovieSearchResult { + AddMovieSearchResult { + tmdb_id: 1234, + title: HorizontallyScrollableText::from("Test"), + original_language: language(), + status: "released".to_owned(), + overview: "New movie blah blah blah".to_owned(), + genres: genres(), + year: 2023, + runtime: 120, + ratings: ratings_list(), + } + } + + pub fn movie_history_item() -> MovieHistoryItem { + MovieHistoryItem { + source_title: HorizontallyScrollableText::from("Test"), + quality: quality_wrapper(), + languages: vec![language()], + date: DateTime::from(DateTime::parse_from_rfc3339("2022-12-30T07:37:56Z").unwrap()), + event_type: "grabbed".to_owned(), + } + } + + pub fn download_record() -> DownloadRecord { + DownloadRecord { + title: "Test Download Title".to_owned(), + status: "downloading".to_owned(), + id: 1, + movie_id: 1, + size: 3543348019, + sizeleft: 1771674009, + output_path: Some(HorizontallyScrollableText::from("/nfs/movies/Test")), + indexer: "kickass torrents".to_owned(), + download_client: "transmission".to_owned(), + } + } + + pub fn downloads_response() -> DownloadsResponse { + DownloadsResponse { + records: vec![download_record()], + } + } + + pub fn root_folder() -> RootFolder { + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + } + } + + pub fn cast_credit() -> Credit { + Credit { + person_name: "Madison Clarke".to_owned(), + character: Some("Johnny Blaze".to_owned()), + department: None, + job: None, + credit_type: CreditType::Cast, + } + } + + pub fn crew_credit() -> Credit { + Credit { + person_name: "Alex Clarke".to_owned(), + character: None, + department: Some("Music".to_owned()), + job: Some("Composition".to_owned()), + credit_type: CreditType::Crew, + } + } + + pub fn indexer() -> Indexer { + Indexer { + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + supports_rss: true, + supports_search: true, + protocol: "torrent".to_owned(), + priority: 25, + download_client_id: 0, + name: Some("Test Indexer".to_owned()), + implementation_name: Some("Torznab".to_owned()), + implementation: Some("Torznab".to_owned()), + config_contract: Some("TorznabSettings".to_owned()), + tags: vec![Number::from(1)], + id: 1, + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(json!("https://test.com")), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(json!("")), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(json!("1.2")), + }, + ]), + } + } + + pub fn indexer_settings() -> IndexerSettings { + IndexerSettings { + rss_sync_interval: 60, + allow_hardcoded_subs: true, + id: 1, + ..IndexerSettings::default() + } + } +} diff --git a/src/network/radarr_network/radarr_network_tests.rs b/src/network/radarr_network/radarr_network_tests.rs new file mode 100644 index 0000000..a0cba2f --- /dev/null +++ b/src/network/radarr_network/radarr_network_tests.rs @@ -0,0 +1,363 @@ +#[cfg(test)] +mod test { + use super::super::*; + use crate::models::radarr_models::{ + EditCollectionParams, EditMovieParams, IndexerSettings, RadarrTaskName, + }; + use crate::models::servarr_data::radarr::modals::EditMovieModal; + use crate::models::servarr_models::EditIndexerParams; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::App; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use reqwest::Client; + use rstest::rstest; + use serde_json::json; + use std::sync::Arc; + use tokio::sync::Mutex; + use tokio_util::sync::CancellationToken; + + #[rstest] + fn test_resource_movie( + #[values( + RadarrEvent::AddMovie(AddMovieBody::default()), + RadarrEvent::EditMovie(EditMovieParams::default()), + RadarrEvent::GetMovies, + RadarrEvent::GetMovieDetails(0), + RadarrEvent::DeleteMovie(DeleteMovieParams::default()), + RadarrEvent::ToggleMovieMonitoring(0) + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/movie"); + } + + #[rstest] + fn test_resource_collection( + #[values( + RadarrEvent::GetCollections, + RadarrEvent::EditCollection(EditCollectionParams::default()) + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/collection"); + } + + #[rstest] + fn test_resource_indexer( + #[values( + RadarrEvent::GetIndexers, + RadarrEvent::DeleteIndexer(0), + RadarrEvent::EditIndexer(EditIndexerParams::default()) + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/indexer"); + } + + #[rstest] + fn test_resource_all_indexer_settings( + #[values( + RadarrEvent::GetAllIndexerSettings, + RadarrEvent::EditAllIndexerSettings(IndexerSettings::default()) + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/indexer"); + } + + #[rstest] + fn test_resource_root_folder( + #[values( + RadarrEvent::AddRootFolder(AddRootFolderBody::default()), + RadarrEvent::GetRootFolders, + RadarrEvent::DeleteRootFolder(0) + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/rootfolder"); + } + + #[rstest] + fn test_resource_tag( + #[values( + RadarrEvent::AddTag(String::new()), + RadarrEvent::GetTags, + RadarrEvent::DeleteTag(0) + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/tag"); + } + + #[rstest] + fn test_resource_release( + #[values( + RadarrEvent::GetReleases(0), + RadarrEvent::DownloadRelease(RadarrReleaseDownloadBody::default()) + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/release"); + } + + #[rstest] + fn test_resource_queue( + #[values(RadarrEvent::GetDownloads(0), RadarrEvent::DeleteDownload(0))] event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/queue"); + } + + #[rstest] + fn test_resource_host_config( + #[values(RadarrEvent::GetHostConfig, RadarrEvent::GetSecurityConfig)] event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/host"); + } + + #[rstest] + fn test_resource_command( + #[values( + RadarrEvent::StartTask(RadarrTaskName::default()), + RadarrEvent::GetQueuedEvents, + RadarrEvent::TriggerAutomaticSearch(0), + RadarrEvent::UpdateAndScan(0), + RadarrEvent::UpdateAllMovies, + RadarrEvent::UpdateDownloads, + RadarrEvent::UpdateCollections + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/command"); + } + + #[rstest] + #[case(RadarrEvent::ClearBlocklist, "/blocklist/bulk")] + #[case(RadarrEvent::DeleteBlocklistItem(1), "/blocklist")] + #[case(RadarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] + #[case(RadarrEvent::GetLogs(500), "/log")] + #[case(RadarrEvent::SearchNewMovie(String::new()), "/movie/lookup")] + #[case(RadarrEvent::GetMovieCredits(0), "/credit")] + #[case(RadarrEvent::GetMovieHistory(0), "/history/movie")] + #[case(RadarrEvent::GetDiskSpace, "/diskspace")] + #[case(RadarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(RadarrEvent::GetStatus, "/system/status")] + #[case(RadarrEvent::GetTasks, "/system/task")] + #[case(RadarrEvent::GetUpdates, "/update")] + #[case(RadarrEvent::TestIndexer(0), "/indexer/test")] + #[case(RadarrEvent::TestAllIndexers, "/indexer/testall")] + #[case(RadarrEvent::HealthCheck, "/health")] + fn test_resource(#[case] event: RadarrEvent, #[case] expected_uri: String) { + assert_str_eq!(event.resource(), expected_uri); + } + + #[test] + fn test_from_radarr_event() { + assert_eq!( + NetworkEvent::Radarr(RadarrEvent::HealthCheck), + NetworkEvent::from(RadarrEvent::HealthCheck) + ); + } + + #[tokio::test] + async fn test_handle_get_radarr_healthcheck_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + None, + None, + RadarrEvent::HealthCheck, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let _ = network.handle_radarr_event(RadarrEvent::HealthCheck).await; + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_radarr_quality_profiles_event() { + let quality_profile_json = json!([{ + "id": 2222, + "name": "HD - 1080p" + }]); + let response: Vec = + serde_json::from_value(quality_profile_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(quality_profile_json), + None, + RadarrEvent::GetQualityProfiles, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::QualityProfiles(quality_profiles) = network + .handle_radarr_event(RadarrEvent::GetQualityProfiles) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.quality_profile_map, + BiMap::from_iter([(2222i64, "HD - 1080p".to_owned())]) + ); + assert_eq!(quality_profiles, response); + } + } + + #[tokio::test] + async fn test_handle_get_radarr_tags_event() { + let tags_json = json!([{ + "id": 2222, + "label": "usenet" + }]); + let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(tags_json), + None, + RadarrEvent::GetTags, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Tags(tags) = network + .handle_radarr_event(RadarrEvent::GetTags) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.tags_map, + BiMap::from_iter([(2222i64, "usenet".to_owned())]) + ); + assert_eq!(tags, response); + } + } + + #[tokio::test] + async fn test_handle_add_radarr_tag() { + let tag_json = json!({ "id": 3, "label": "testing" }); + let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ "label": "testing" })), + Some(tag_json), + None, + RadarrEvent::AddTag(String::new()), + None, + None, + ) + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Tag(tag) = network + .handle_radarr_event(RadarrEvent::AddTag("testing".to_owned())) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + assert_eq!(tag, response); + } + } + + #[tokio::test] + async fn test_handle_delete_radarr_tag_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteTag(1), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::DeleteTag(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_extract_and_add_radarr_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::test_default())); + let tags = " test,HI ,, usenet "; + { + let mut app = app_arc.lock().await; + app.data.radarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert_eq!( + network.extract_and_add_radarr_tag_ids_vec(tags).await, + vec![2, 3, 1] + ); + } + + #[tokio::test] + async fn test_extract_and_add_radarr_tag_ids_vec_add_missing_tags_first() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ "label": "TESTING" })), + Some(json!({ "id": 3, "label": "testing" })), + None, + RadarrEvent::GetTags, + None, + None, + ) + .await; + let tags = "usenet, test, TESTING"; + { + let mut app = app_arc.lock().await; + app.data.radarr_data.edit_movie_modal = Some(EditMovieModal { + tags: tags.into(), + ..EditMovieModal::default() + }); + app.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let tag_ids_vec = network.extract_and_add_radarr_tag_ids_vec(tags).await; + + async_server.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app_arc.lock().await.data.radarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } +} diff --git a/src/network/radarr_network/root_folders/mod.rs b/src/network/radarr_network/root_folders/mod.rs new file mode 100644 index 0000000..90f7e50 --- /dev/null +++ b/src/network/radarr_network/root_folders/mod.rs @@ -0,0 +1,75 @@ +use crate::models::servarr_models::{AddRootFolderBody, RootFolder}; +use crate::network::radarr_network::RadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::{debug, info}; +use serde_json::Value; + +#[cfg(test)] +#[path = "radarr_root_folders_network_tests.rs"] +mod radarr_root_folders_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::radarr_network) async fn add_radarr_root_folder( + &mut self, + add_root_folder_body: AddRootFolderBody, + ) -> Result { + info!("Adding new root folder to Radarr"); + let event = RadarrEvent::AddRootFolder(AddRootFolderBody::default()); + + debug!("Add root folder body: {add_root_folder_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(add_root_folder_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn delete_radarr_root_folder( + &mut self, + root_folder_id: i64, + ) -> Result<()> { + let event = RadarrEvent::DeleteRootFolder(root_folder_id); + info!("Deleting Radarr root folder for folder with id: {root_folder_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{root_folder_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_root_folders( + &mut self, + ) -> Result> { + info!("Fetching Radarr root folders"); + let event = RadarrEvent::GetRootFolders; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |root_folders, mut app| { + app.data.radarr_data.root_folders.set_items(root_folders); + }) + .await + } +} diff --git a/src/network/radarr_network/root_folders/radarr_root_folders_network_tests.rs b/src/network/radarr_network/root_folders/radarr_root_folders_network_tests.rs new file mode 100644 index 0000000..abc806c --- /dev/null +++ b/src/network/radarr_network/root_folders/radarr_root_folders_network_tests.rs @@ -0,0 +1,97 @@ +#[cfg(test)] +mod tests { + use crate::models::radarr_models::RadarrSerdeable; + use crate::models::servarr_models::{AddRootFolderBody, RootFolder}; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::root_folder; + use crate::network::radarr_network::RadarrEvent; + use crate::network::{Network, RequestMethod}; + use pretty_assertions::assert_eq; + use reqwest::Client; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_add_radarr_root_folder_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "path": "/nfs/test" + })), + Some(json!({})), + None, + RadarrEvent::AddRootFolder(AddRootFolderBody::default()), + None, + None, + ) + .await; + let add_root_folder_body = AddRootFolderBody { + path: "/nfs/test".to_owned(), + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::AddRootFolder(add_root_folder_body)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_radarr_root_folder_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteRootFolder(1), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_radarr_event(RadarrEvent::DeleteRootFolder(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_radarr_root_folders_event() { + let root_folder_json = json!([{ + "id": 1, + "path": "/nfs", + "accessible": true, + "freeSpace": 219902325555200u64, + }]); + let response: Vec = serde_json::from_value(root_folder_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(root_folder_json), + None, + RadarrEvent::GetRootFolders, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::RootFolders(root_folders) = network + .handle_radarr_event(RadarrEvent::GetRootFolders) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.root_folders.items, + vec![root_folder()] + ); + assert_eq!(root_folders, response); + } + } +} diff --git a/src/network/radarr_network/system/mod.rs b/src/network/radarr_network/system/mod.rs new file mode 100644 index 0000000..1dde56e --- /dev/null +++ b/src/network/radarr_network/system/mod.rs @@ -0,0 +1,268 @@ +use crate::models::radarr_models::{RadarrTask, RadarrTaskName, SystemStatus}; +use crate::models::servarr_models::{ + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, +}; +use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; +use crate::network::radarr_network::RadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use indoc::formatdoc; +use log::info; +use serde_json::Value; + +#[cfg(test)] +#[path = "radarr_system_network_tests.rs"] +mod radarr_system_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::radarr_network) async fn get_radarr_diskspace( + &mut self, + ) -> Result> { + info!("Fetching Radarr disk space"); + let event = RadarrEvent::GetDiskSpace; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { + app.data.radarr_data.disk_space_vec = disk_space_vec; + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_host_config( + &mut self, + ) -> Result { + info!("Fetching Radarr host config"); + let event = RadarrEvent::GetHostConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), HostConfig>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_logs( + &mut self, + events: u64, + ) -> Result { + info!("Fetching Radarr logs"); + let event = RadarrEvent::GetLogs(events); + + let params = format!("pageSize={events}&sortDirection=descending&sortKey=time"); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), LogResponse>(request_props, |log_response, mut app| { + let mut logs = log_response.records; + logs.reverse(); + + let log_lines = logs + .into_iter() + .map(|log| { + if log.exception.is_some() { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().unwrap(), + log.exception_type.as_ref().unwrap(), + log.exception.as_ref().unwrap() + )) + } else { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().unwrap(), + log.message.as_ref().unwrap() + )) + } + }) + .collect(); + + app.data.radarr_data.logs.set_items(log_lines); + app.data.radarr_data.logs.scroll_to_bottom(); + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_queued_radarr_events( + &mut self, + ) -> Result> { + info!("Fetching Radarr queued events"); + let event = RadarrEvent::GetQueuedEvents; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |queued_events_vec, mut app| { + app + .data + .radarr_data + .queued_events + .set_items(queued_events_vec); + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_security_config( + &mut self, + ) -> Result { + info!("Fetching Radarr security config"); + let event = RadarrEvent::GetSecurityConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_status( + &mut self, + ) -> Result { + info!("Fetching Radarr system status"); + let event = RadarrEvent::GetStatus; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { + app.data.radarr_data.version = system_status.version; + app.data.radarr_data.start_time = system_status.start_time; + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_updates( + &mut self, + ) -> Result> { + info!("Fetching Radarr updates"); + let event = RadarrEvent::GetUpdates; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |updates_vec, mut app| { + let latest_installed = if updates_vec + .iter() + .any(|update| update.latest && update.installed_on.is_some()) + { + "already".to_owned() + } else { + "not".to_owned() + }; + let updates = updates_vec + .into_iter() + .map(|update| { + let install_status = if update.installed_on.is_some() { + if update.installed { + "(Currently Installed)".to_owned() + } else { + "(Previously Installed)".to_owned() + } + } else { + String::new() + }; + let vec_to_bullet_points = |vec: Vec| { + vec + .iter() + .map(|change| format!(" * {change}")) + .collect::>() + .join("\n") + }; + + let mut update_info = formatdoc!( + "{} - {} {install_status} + {}", + update.version, + update.release_date, + "-".repeat(200) + ); + + if let Some(new_changes) = update.changes.new { + let changes = vec_to_bullet_points(new_changes); + update_info = formatdoc!( + "{update_info} + New: + {changes}" + ) + } + + if let Some(fixes) = update.changes.fixed { + let fixes = vec_to_bullet_points(fixes); + update_info = formatdoc!( + "{update_info} + Fixed: + {fixes}" + ); + } + + update_info + }) + .reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}")) + .unwrap(); + + app.data.radarr_data.updates = ScrollableText::with_string(formatdoc!( + "The latest version of Radarr is {latest_installed} installed + + {updates}" + )); + }) + .await + } + + pub(in crate::network::radarr_network) async fn get_radarr_tasks( + &mut self, + ) -> Result> { + info!("Fetching Radarr tasks"); + let event = RadarrEvent::GetTasks; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { + app.data.radarr_data.tasks.set_items(tasks_vec); + }) + .await + } + + pub(in crate::network::radarr_network) async fn start_radarr_task( + &mut self, + task_name: RadarrTaskName, + ) -> Result { + let event = RadarrEvent::StartTask(task_name); + + info!("Starting Radarr task: {task_name}"); + + let body = CommandBody { + name: task_name.to_string(), + }; + + 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/radarr_network/system/radarr_system_network_tests.rs b/src/network/radarr_network/system/radarr_system_network_tests.rs new file mode 100644 index 0000000..e7b28d0 --- /dev/null +++ b/src/network/radarr_network/system/radarr_system_network_tests.rs @@ -0,0 +1,477 @@ +#[cfg(test)] +mod tests { + use crate::models::radarr_models::{RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus}; + use crate::models::servarr_models::{ + DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + }; + use crate::models::{HorizontallyScrollableText, ScrollableText}; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::radarr_network::RadarrEvent; + use crate::network::{Network, RequestMethod}; + use chrono::DateTime; + use indoc::formatdoc; + use pretty_assertions::{assert_eq, assert_str_eq}; + use reqwest::Client; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_get_radarr_diskspace_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([ + { + "freeSpace": 1111, + "totalSpace": 2222, + }, + { + "freeSpace": 3333, + "totalSpace": 4444 + } + ])), + None, + RadarrEvent::GetDiskSpace, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let disk_space_vec = vec![ + DiskSpace { + free_space: 1111, + total_space: 2222, + }, + DiskSpace { + free_space: 3333, + total_space: 4444, + }, + ]; + + if let RadarrSerdeable::DiskSpaces(disk_space) = network + .handle_radarr_event(RadarrEvent::GetDiskSpace) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.disk_space_vec, + disk_space_vec + ); + assert_eq!(disk_space, disk_space_vec); + } + } + + #[tokio::test] + async fn test_handle_get_radarr_host_config_event() { + let host_config_response = json!({ + "bindAddress": "*", + "port": 7878, + "urlBase": "some.test.site/radarr", + "instanceName": "Radarr", + "applicationUrl": "https://some.test.site:7878/radarr", + "enableSsl": true, + "sslPort": 9898, + "sslCertPath": "/app/radarr.pfx", + "sslCertPassword": "test" + }); + let response: HostConfig = serde_json::from_value(host_config_response.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(host_config_response), + None, + RadarrEvent::GetHostConfig, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::HostConfig(host_config) = network + .handle_radarr_event(RadarrEvent::GetHostConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(host_config, response); + } + } + + #[tokio::test] + async fn test_handle_get_radarr_logs_event() { + let expected_logs = vec![ + HorizontallyScrollableText::from( + "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", + ), + HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), + ]; + let logs_response_json = json!({ + "page": 1, + "pageSize": 500, + "sortKey": "time", + "sortDirection": "descending", + "totalRecords": 2, + "records": [ + { + "time": "2023-05-20T21:29:16Z", + "level": "info", + "logger": "TestLogger", + "message": "test message", + "id": 1 + }, + { + "time": "2023-05-20T21:29:16Z", + "level": "fatal", + "logger": "RadarrError", + "exception": "test exception", + "exceptionType": "Some.Big.Bad.Exception", + "id": 2 + } + ] + }); + let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(logs_response_json), + None, + RadarrEvent::GetLogs(500), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::LogResponse(logs) = network + .handle_radarr_event(RadarrEvent::GetLogs(500)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.logs.items, + expected_logs + ); + assert!(app_arc + .lock() + .await + .data + .radarr_data + .logs + .current_selection() + .text + .contains("INFO")); + assert_eq!(logs, response); + } + } + + #[tokio::test] + async fn test_handle_get_queued_radarr_events_event() { + let queued_events_json = json!([{ + "name": "RefreshMonitoredDownloads", + "commandName": "Refresh Monitored Downloads", + "status": "completed", + "queued": "2023-05-20T21:29:16Z", + "started": "2023-05-20T21:29:16Z", + "ended": "2023-05-20T21:29:16Z", + "duration": "00:00:00.5111547", + "trigger": "scheduled", + }]); + let response: Vec = serde_json::from_value(queued_events_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_event = QueueEvent { + name: "RefreshMonitoredDownloads".to_owned(), + command_name: "Refresh Monitored Downloads".to_owned(), + status: "completed".to_owned(), + queued: timestamp, + started: Some(timestamp), + ended: Some(timestamp), + duration: Some("00:00:00.5111547".to_owned()), + trigger: "scheduled".to_owned(), + }; + + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(queued_events_json), + None, + RadarrEvent::GetQueuedEvents, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::QueueEvents(events) = network + .handle_radarr_event(RadarrEvent::GetQueuedEvents) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.queued_events.items, + vec![expected_event] + ); + assert_eq!(events, response); + } + } + + #[tokio::test] + async fn test_handle_get_radarr_security_config_event() { + let security_config_response = json!({ + "authenticationMethod": "forms", + "authenticationRequired": "disabledForLocalAddresses", + "username": "test", + "password": "some password", + "apiKey": "someApiKey12345", + "certificateValidation": "disabledForLocalAddresses", + }); + let response: SecurityConfig = + serde_json::from_value(security_config_response.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(security_config_response), + None, + RadarrEvent::GetSecurityConfig, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::SecurityConfig(security_config) = network + .handle_radarr_event(RadarrEvent::GetSecurityConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(security_config, response); + } + } + + #[tokio::test] + async fn test_handle_get_radarr_status_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!({ + "version": "v1", + "startTime": "2023-02-25T20:16:43Z" + })), + None, + RadarrEvent::GetStatus, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let date_time = DateTime::from(DateTime::parse_from_rfc3339("2023-02-25T20:16:43Z").unwrap()); + + if let RadarrSerdeable::SystemStatus(status) = network + .handle_radarr_event(RadarrEvent::GetStatus) + .await + .unwrap() + { + async_server.assert_async().await; + assert_str_eq!(app_arc.lock().await.data.radarr_data.version, "v1"); + assert_eq!(app_arc.lock().await.data.radarr_data.start_time, date_time); + assert_eq!( + status, + SystemStatus { + version: "v1".to_owned(), + start_time: date_time + } + ); + } + } + + #[tokio::test] + async fn test_handle_get_radarr_updates_event() { + let updates_json = json!([{ + "version": "4.3.2.1", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": true, + "installedOn": "2023-04-15T02:02:53Z", + "latest": true, + "changes": { + "new": [ + "Cool new thing" + ], + "fixed": [ + "Some bugs killed" + ] + }, + }, + { + "version": "3.2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "installedOn": "2023-04-15T02:02:53Z", + "latest": false, + "changes": { + "new": [ + "Cool new thing (old)", + "Other cool new thing (old)" + ], + }, + }, + { + "version": "2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + "changes": { + "fixed": [ + "Killed bug 1", + "Fixed bug 2" + ] + }, + }]); + let response: Vec = serde_json::from_value(updates_json.clone()).unwrap(); + let line_break = "-".repeat(200); + let expected_text = ScrollableText::with_string(formatdoc!( + " + The latest version of Radarr is already installed + + 4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) + {line_break} + New: + * Cool new thing + Fixed: + * Some bugs killed + + + 3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) + {line_break} + New: + * Cool new thing (old) + * Other cool new thing (old) + + + 2.1.0 - 2023-04-15 02:02:53 UTC + {line_break} + Fixed: + * Killed bug 1 + * Fixed bug 2" + )); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(updates_json), + None, + RadarrEvent::GetUpdates, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Updates(updates) = network + .handle_radarr_event(RadarrEvent::GetUpdates) + .await + .unwrap() + { + async_server.assert_async().await; + assert_str_eq!( + app_arc.lock().await.data.radarr_data.updates.get_text(), + expected_text.get_text() + ); + assert_eq!(updates, response); + } + } + + #[tokio::test] + async fn test_handle_get_radarr_tasks_event() { + let tasks_json = json!([{ + "name": "Application Check Update", + "taskName": "ApplicationCheckUpdate", + "interval": 360, + "lastExecution": "2023-05-20T21:29:16Z", + "nextExecution": "2023-05-20T21:29:16Z", + "lastDuration": "00:00:00.5111547", + }, + { + "name": "Backup", + "taskName": "Backup", + "interval": 10080, + "lastExecution": "2023-05-20T21:29:16Z", + "nextExecution": "2023-05-20T21:29:16Z", + "lastDuration": "00:00:00.5111547", + }]); + let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_tasks = vec![ + RadarrTask { + name: "Application Check Update".to_owned(), + task_name: RadarrTaskName::ApplicationCheckUpdate, + interval: 360, + last_execution: timestamp, + next_execution: timestamp, + last_duration: "00:00:00.5111547".to_owned(), + }, + RadarrTask { + name: "Backup".to_owned(), + task_name: RadarrTaskName::Backup, + interval: 10080, + last_execution: timestamp, + next_execution: timestamp, + last_duration: "00:00:00.5111547".to_owned(), + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(tasks_json), + None, + RadarrEvent::GetTasks, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Tasks(tasks) = network + .handle_radarr_event(RadarrEvent::GetTasks) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.tasks.items, + expected_tasks + ); + assert_eq!(tasks, response); + } + } + + #[tokio::test] + async fn test_handle_start_radarr_task_event() { + 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(RadarrTaskName::ApplicationCheckUpdate), + 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( + RadarrTaskName::ApplicationCheckUpdate, + )) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(value, response); + } + } +} diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs deleted file mode 100644 index ad860fc..0000000 --- a/src/network/radarr_network_tests.rs +++ /dev/null @@ -1,4304 +0,0 @@ -#[cfg(test)] -mod test { - use std::slice; - use std::sync::Arc; - - use bimap::BiMap; - use chrono::DateTime; - use mockito::Matcher; - use pretty_assertions::{assert_eq, assert_str_eq}; - use reqwest::Client; - use rstest::rstest; - use serde_json::{json, Number, Value}; - use tokio::sync::Mutex; - use tokio_util::sync::CancellationToken; - - use super::super::*; - use crate::models::radarr_models::{ - AddMovieOptions, BlocklistItem, BlocklistItemMovie, CollectionMovie, EditCollectionParams, - EditMovieParams, IndexerSettings, MediaInfo, MinimumAvailability, MovieCollection, MovieFile, - RadarrTaskName, Rating, RatingsList, - }; - use crate::models::servarr_data::radarr::modals::EditMovieModal; - use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; - use crate::models::servarr_models::{ - EditIndexerParams, HostConfig, IndexerField, Language, Quality, QualityWrapper, - }; - use crate::models::stateful_table::SortOption; - use crate::models::HorizontallyScrollableText; - use crate::network::network_tests::test_utils::mock_servarr_api; - use crate::App; - - const MOVIE_JSON: &str = r#"{ - "id": 1, - "title": "Test", - "tmdbId": 1234, - "originalLanguage": { - "id": 1, - "name": "English" - }, - "sizeOnDisk": 3543348019, - "status": "Downloaded", - "overview": "Blah blah blah", - "path": "/nfs/movies", - "studio": "21st Century Alex", - "genres": ["cool", "family", "fun"], - "year": 2023, - "monitored": true, - "hasFile": true, - "runtime": 120, - "qualityProfileId": 2222, - "minimumAvailability": "announced", - "certification": "R", - "tags": [1], - "ratings": { - "imdb": { - "value": 9.9 - }, - "tmdb": { - "value": 9.9 - }, - "rottenTomatoes": { - "value": 9.9 - } - }, - "movieFile": { - "relativePath": "Test.mkv", - "path": "/nfs/movies/Test.mkv", - "dateAdded": "2022-12-30T07:37:56Z", - "mediaInfo": { - "audioBitrate": 0, - "audioChannels": 7.1, - "audioCodec": "AAC", - "audioLanguages": "eng", - "audioStreamCount": 1, - "videoBitDepth": 10, - "videoBitrate": 0, - "videoCodec": "x265", - "videoFps": 23.976, - "resolution": "1920x804", - "runTime": "2:00:00", - "scanType": "Progressive" - } - }, - "collection": { - "id": 123, - "title": "Test Collection", - "rootFolderPath": "/nfs/movies", - "searchOnAdd": true, - "monitored": true, - "minimumAvailability": "released", - "overview": "Collection blah blah blah", - "qualityProfileId": 2222, - "movies": [ - { - "title": "Test", - "overview": "Collection blah blah blah", - "year": 2023, - "runtime": 120, - "tmdbId": 1234, - "genres": ["cool", "family", "fun"], - "ratings": { - "imdb": { - "value": 9.9 - }, - "tmdb": { - "value": 9.9 - }, - "rottenTomatoes": { - "value": 9.9 - } - } - } - ] - } - }"#; - - #[rstest] - fn test_resource_movie( - #[values( - RadarrEvent::AddMovie(AddMovieBody::default()), - RadarrEvent::EditMovie(EditMovieParams::default()), - RadarrEvent::GetMovies, - RadarrEvent::GetMovieDetails(0), - RadarrEvent::DeleteMovie(DeleteMovieParams::default()), - RadarrEvent::ToggleMovieMonitoring(0) - )] - event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/movie"); - } - - #[rstest] - fn test_resource_collection( - #[values( - RadarrEvent::GetCollections, - RadarrEvent::EditCollection(EditCollectionParams::default()) - )] - event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/collection"); - } - - #[rstest] - fn test_resource_indexer( - #[values( - RadarrEvent::GetIndexers, - RadarrEvent::DeleteIndexer(0), - RadarrEvent::EditIndexer(EditIndexerParams::default()) - )] - event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/indexer"); - } - - #[rstest] - fn test_resource_all_indexer_settings( - #[values( - RadarrEvent::GetAllIndexerSettings, - RadarrEvent::EditAllIndexerSettings(IndexerSettings::default()) - )] - event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/config/indexer"); - } - - #[rstest] - fn test_resource_root_folder( - #[values( - RadarrEvent::AddRootFolder(AddRootFolderBody::default()), - RadarrEvent::GetRootFolders, - RadarrEvent::DeleteRootFolder(0) - )] - event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/rootfolder"); - } - - #[rstest] - fn test_resource_tag( - #[values( - RadarrEvent::AddTag(String::new()), - RadarrEvent::GetTags, - RadarrEvent::DeleteTag(0) - )] - event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/tag"); - } - - #[rstest] - fn test_resource_release( - #[values( - RadarrEvent::GetReleases(0), - RadarrEvent::DownloadRelease(RadarrReleaseDownloadBody::default()) - )] - event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/release"); - } - - #[rstest] - fn test_resource_queue( - #[values(RadarrEvent::GetDownloads(0), RadarrEvent::DeleteDownload(0))] event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/queue"); - } - - #[rstest] - fn test_resource_host_config( - #[values(RadarrEvent::GetHostConfig, RadarrEvent::GetSecurityConfig)] event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/config/host"); - } - - #[rstest] - fn test_resource_command( - #[values( - RadarrEvent::StartTask(RadarrTaskName::default()), - RadarrEvent::GetQueuedEvents, - RadarrEvent::TriggerAutomaticSearch(0), - RadarrEvent::UpdateAndScan(0), - RadarrEvent::UpdateAllMovies, - RadarrEvent::UpdateDownloads, - RadarrEvent::UpdateCollections - )] - event: RadarrEvent, - ) { - assert_str_eq!(event.resource(), "/command"); - } - - #[rstest] - #[case(RadarrEvent::ClearBlocklist, "/blocklist/bulk")] - #[case(RadarrEvent::DeleteBlocklistItem(1), "/blocklist")] - #[case(RadarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] - #[case(RadarrEvent::GetLogs(500), "/log")] - #[case(RadarrEvent::SearchNewMovie(String::new()), "/movie/lookup")] - #[case(RadarrEvent::GetMovieCredits(0), "/credit")] - #[case(RadarrEvent::GetMovieHistory(0), "/history/movie")] - #[case(RadarrEvent::GetDiskSpace, "/diskspace")] - #[case(RadarrEvent::GetQualityProfiles, "/qualityprofile")] - #[case(RadarrEvent::GetStatus, "/system/status")] - #[case(RadarrEvent::GetTasks, "/system/task")] - #[case(RadarrEvent::GetUpdates, "/update")] - #[case(RadarrEvent::TestIndexer(0), "/indexer/test")] - #[case(RadarrEvent::TestAllIndexers, "/indexer/testall")] - #[case(RadarrEvent::HealthCheck, "/health")] - fn test_resource(#[case] event: RadarrEvent, #[case] expected_uri: String) { - assert_str_eq!(event.resource(), expected_uri); - } - - #[test] - fn test_from_radarr_event() { - assert_eq!( - NetworkEvent::Radarr(RadarrEvent::HealthCheck), - NetworkEvent::from(RadarrEvent::HealthCheck) - ); - } - - #[tokio::test] - async fn test_handle_get_radarr_healthcheck_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - None, - None, - RadarrEvent::HealthCheck, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let _ = network.handle_radarr_event(RadarrEvent::HealthCheck).await; - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_get_radarr_diskspace_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([ - { - "freeSpace": 1111, - "totalSpace": 2222, - }, - { - "freeSpace": 3333, - "totalSpace": 4444 - } - ])), - None, - RadarrEvent::GetDiskSpace, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let disk_space_vec = vec![ - DiskSpace { - free_space: 1111, - total_space: 2222, - }, - DiskSpace { - free_space: 3333, - total_space: 4444, - }, - ]; - - if let RadarrSerdeable::DiskSpaces(disk_space) = network - .handle_radarr_event(RadarrEvent::GetDiskSpace) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.disk_space_vec, - disk_space_vec - ); - assert_eq!(disk_space, disk_space_vec); - } - } - - #[tokio::test] - async fn test_handle_get_status_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!({ - "version": "v1", - "startTime": "2023-02-25T20:16:43Z" - })), - None, - RadarrEvent::GetStatus, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let date_time = DateTime::from(DateTime::parse_from_rfc3339("2023-02-25T20:16:43Z").unwrap()); - - if let RadarrSerdeable::SystemStatus(status) = network - .handle_radarr_event(RadarrEvent::GetStatus) - .await - .unwrap() - { - async_server.assert_async().await; - assert_str_eq!(app_arc.lock().await.data.radarr_data.version, "v1"); - assert_eq!(app_arc.lock().await.data.radarr_data.start_time, date_time); - assert_eq!( - status, - SystemStatus { - version: "v1".to_owned(), - start_time: date_time - } - ); - } - } - - #[rstest] - #[tokio::test] - async fn test_handle_get_movies_event(#[values(true, false)] use_custom_sorting: bool) { - let mut movie_1: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - let mut movie_2: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - *movie_1.get_mut("id").unwrap() = json!(1); - *movie_1.get_mut("title").unwrap() = json!("z test"); - *movie_2.get_mut("id").unwrap() = json!(2); - *movie_2.get_mut("title").unwrap() = json!("A test"); - let expected_movies = vec![ - Movie { - id: 1, - title: "z test".into(), - ..movie() - }, - Movie { - id: 2, - title: "A test".into(), - ..movie() - }, - ]; - let mut expected_sorted_movies = vec![ - Movie { - id: 1, - title: "z test".into(), - ..movie() - }, - Movie { - id: 2, - title: "A test".into(), - ..movie() - }, - ]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([movie_1, movie_2])), - None, - RadarrEvent::GetMovies, - None, - None, - ) - .await; - app_arc.lock().await.data.radarr_data.movies.sort_asc = true; - if use_custom_sorting { - let cmp_fn = |a: &Movie, b: &Movie| { - a.title - .text - .to_lowercase() - .cmp(&b.title.text.to_lowercase()) - }; - expected_sorted_movies.sort_by(cmp_fn); - let title_sort_option = SortOption { - name: "Title", - cmp_fn: Some(cmp_fn), - }; - app_arc - .lock() - .await - .data - .radarr_data - .movies - .sorting(vec![title_sort_option]); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Movies(movies) = network - .handle_radarr_event(RadarrEvent::GetMovies) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.movies.items, - expected_sorted_movies - ); - assert!(app_arc.lock().await.data.radarr_data.movies.sort_asc); - assert_eq!(movies, expected_movies); - } - } - - #[tokio::test] - async fn test_handle_get_movies_event_no_op_while_user_is_selecting_sort_options() { - let mut movie_1: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - let mut movie_2: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - *movie_1.get_mut("id").unwrap() = json!(1); - *movie_1.get_mut("title").unwrap() = json!("z test"); - *movie_2.get_mut("id").unwrap() = json!(2); - *movie_2.get_mut("title").unwrap() = json!("A test"); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([movie_1, movie_2])), - None, - RadarrEvent::GetMovies, - None, - None, - ) - .await; - app_arc - .lock() - .await - .push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); - app_arc.lock().await.data.radarr_data.movies.sort_asc = true; - let cmp_fn = |a: &Movie, b: &Movie| { - a.title - .text - .to_lowercase() - .cmp(&b.title.text.to_lowercase()) - }; - let title_sort_option = SortOption { - name: "Title", - cmp_fn: Some(cmp_fn), - }; - app_arc - .lock() - .await - .data - .radarr_data - .movies - .sorting(vec![title_sort_option]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::GetMovies) - .await - .is_ok()); - - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .radarr_data - .movies - .items - .is_empty()); - assert!(app_arc.lock().await.data.radarr_data.movies.sort_asc); - } - - #[tokio::test] - async fn test_handle_get_releases_event() { - let release_json = json!([{ - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "id": 1, "name": "English" } ], - "quality": { "quality": { "name": "HD - 1080p" }} - }]); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(release_json), - None, - RadarrEvent::GetReleases(1), - None, - Some("movieId=1"), - ) - .await; - app_arc.lock().await.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Releases(releases_vec) = network - .handle_radarr_event(RadarrEvent::GetReleases(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .items, - vec![release()] - ); - assert_eq!(releases_vec, vec![release()]); - } - } - - #[tokio::test] - async fn test_handle_get_releases_event_empty_movie_details_modal() { - let release_json = json!([{ - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "id": 1, "name": "English" } ], - "quality": { "quality": { "name": "HD - 1080p" }} - }]); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(release_json), - None, - RadarrEvent::GetReleases(1), - None, - Some("movieId=1"), - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::GetReleases(1)) - .await - .is_ok()); - - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_releases - .items, - vec![release()] - ); - } - - #[tokio::test] - async fn test_handle_search_new_movie_event() { - let add_movie_search_result_json = json!([{ - "tmdbId": 1234, - "title": "Test", - "originalLanguage": { "id": 1, "name": "English" }, - "status": "released", - "overview": "New movie blah blah blah", - "genres": ["cool", "family", "fun"], - "year": 2023, - "runtime": 120, - "ratings": { - "imdb": { - "value": 9.9 - }, - "tmdb": { - "value": 9.9 - }, - "rottenTomatoes": { - "value": 9.9 - } - } - }]); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(add_movie_search_result_json), - None, - RadarrEvent::SearchNewMovie("test term".into()), - None, - Some("term=test%20term"), - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::AddMovieSearchResults(add_movie_search_results) = network - .handle_radarr_event(RadarrEvent::SearchNewMovie("test term".into())) - .await - .unwrap() - { - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .radarr_data - .add_searched_movies - .is_some()); - assert_eq!( - app_arc - .lock() - .await - .data - .radarr_data - .add_searched_movies - .as_ref() - .unwrap() - .items, - vec![add_movie_search_result()] - ); - assert_eq!(add_movie_search_results, vec![add_movie_search_result()]); - } - } - - #[tokio::test] - async fn test_handle_start_radarr_task_event() { - 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(RadarrTaskName::ApplicationCheckUpdate), - 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( - RadarrTaskName::ApplicationCheckUpdate, - )) - .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( - RequestMethod::Get, - None, - Some(json!([])), - None, - RadarrEvent::SearchNewMovie("test term".into()), - None, - Some("term=test%20term"), - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::SearchNewMovie("test term".into())) - .await - .is_ok()); - - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .radarr_data - .add_searched_movies - .is_none()); - assert_eq!( - app_arc.lock().await.get_current_route(), - ActiveRadarrBlock::AddMovieEmptySearchResults.into() - ); - } - - #[tokio::test] - async fn test_handle_test_radarr_indexer_event_error() { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let response_json = json!([ - { - "isWarning": false, - "propertyName": "", - "errorMessage": "test failure", - "severity": "error" - }]); - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json.clone()), - None, - RadarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_test_server = server - .mock( - "POST", - format!("/api/v3{}", RadarrEvent::TestIndexer(1).resource()).as_str(), - ) - .with_status(400) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(indexer_details_json.clone())) - .with_body(response_json.to_string()) - .create_async() - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Value(value) = network - .handle_radarr_event(RadarrEvent::TestIndexer(1)) - .await - .unwrap() - { - async_details_server.assert_async().await; - async_test_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.indexer_test_errors, - Some("\"test failure\"".to_owned()) - ); - assert_eq!(value, response_json) - } - } - - #[tokio::test] - async fn test_handle_test_radarr_indexer_event_success() { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json.clone()), - None, - RadarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_test_server = server - .mock( - "POST", - format!("/api/v3{}", RadarrEvent::TestIndexer(1).resource()).as_str(), - ) - .with_status(200) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(indexer_details_json.clone())) - .with_body("{}") - .create_async() - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Value(value) = network - .handle_radarr_event(RadarrEvent::TestIndexer(1)) - .await - .unwrap() - { - async_details_server.assert_async().await; - async_test_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.indexer_test_errors, - Some(String::new()) - ); - assert_eq!(value, json!({})); - } - } - - #[tokio::test] - async fn test_handle_test_all_radarr_indexers_event() { - let indexers = vec![ - Indexer { - id: 1, - name: Some("Test 1".to_owned()), - ..Indexer::default() - }, - Indexer { - id: 2, - name: Some("Test 2".to_owned()), - ..Indexer::default() - }, - ]; - let indexer_test_results_modal_items = vec![ - IndexerTestResultModalItem { - name: "Test 1".to_owned(), - is_valid: true, - validation_failures: HorizontallyScrollableText::default(), - }, - IndexerTestResultModalItem { - name: "Test 2".to_owned(), - is_valid: false, - validation_failures: "Failure for field 'test field 1': test error message, Failure for field 'test field 2': test error message 2".into(), - }, - ]; - let response_json = json!([ - { - "id": 1, - "isValid": true, - "validationFailures": [] - }, - { - "id": 2, - "isValid": false, - "validationFailures": [ - { - "propertyName": "test field 1", - "errorMessage": "test error message", - "severity": "error" - }, - { - "propertyName": "test field 2", - "errorMessage": "test error message 2", - "severity": "error" - }, - ] - }]); - let response: Vec = serde_json::from_value(response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - None, - Some(response_json), - Some(400), - RadarrEvent::TestAllIndexers, - None, - None, - ) - .await; - app_arc - .lock() - .await - .data - .radarr_data - .indexers - .set_items(indexers); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::IndexerTestResults(results) = network - .handle_radarr_event(RadarrEvent::TestAllIndexers) - .await - .unwrap() - { - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .radarr_data - .indexer_test_all_results - .is_some()); - assert_eq!( - app_arc - .lock() - .await - .data - .radarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .items, - indexer_test_results_modal_items - ); - assert_eq!(results, response); - } - } - - #[tokio::test] - async fn test_handle_toggle_movies_monitoring_event() { - let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - *expected_body.get_mut("monitored").unwrap() = json!(false); - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(MOVIE_JSON).unwrap()), - None, - RadarrEvent::GetMovieDetails(1), - Some("/1"), - None, - ) - .await; - let async_toggle_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - RadarrEvent::ToggleMovieMonitoring(1).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - { - let mut app = app_arc.lock().await; - app.data.radarr_data.movies.set_items(vec![movie()]); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::ToggleMovieMonitoring(1)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_toggle_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_trigger_automatic_movie_search_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "MoviesSearch", - "movieIds": [ 1 ] - })), - Some(json!({})), - None, - RadarrEvent::TriggerAutomaticSearch(1), - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::TriggerAutomaticSearch(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_update_and_scan_movie_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "RefreshMovie", - "movieIds": [ 1 ] - })), - Some(json!({})), - None, - RadarrEvent::UpdateAndScan(1), - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::UpdateAndScan(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_update_all_movies_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "RefreshMovie", - "movieIds": [] - })), - Some(json!({})), - None, - RadarrEvent::UpdateAllMovies, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::UpdateAllMovies) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_update_radarr_downloads_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "RefreshMonitoredDownloads" - })), - Some(json!({})), - None, - RadarrEvent::UpdateDownloads, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::UpdateDownloads) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_update_collections_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "RefreshCollections" - })), - Some(json!({})), - None, - RadarrEvent::UpdateCollections, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::UpdateCollections) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_get_movie_details_event() { - let response: Movie = serde_json::from_str(MOVIE_JSON).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(MOVIE_JSON).unwrap()), - None, - RadarrEvent::GetMovieDetails(1), - Some("/1"), - None, - ) - .await; - app_arc.lock().await.data.radarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Movie(movie) = network - .handle_radarr_event(RadarrEvent::GetMovieDetails(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .radarr_data - .movie_details_modal - .is_some()); - assert_eq!(movie, response); - - let app = app_arc.lock().await; - let movie_details_modal = app.data.radarr_data.movie_details_modal.as_ref().unwrap(); - assert_str_eq!( - movie_details_modal.movie_details.get_text(), - formatdoc!( - "Title: Test - Year: 2023 - Runtime: 2h 0m - Rating: R - Collection: Test Collection - Status: Downloaded - Description: Blah blah blah - TMDB: 99% - IMDB: 9.9 - Rotten Tomatoes: - Quality Profile: HD - 1080p - Size: 3.30 GB - Path: /nfs/movies - Studio: 21st Century Alex - Genres: cool, family, fun" - ) - ); - assert_str_eq!( - movie_details_modal.file_details, - formatdoc!( - "Relative Path: Test.mkv - Absolute Path: /nfs/movies/Test.mkv - Size: 3.30 GB - Date Added: 2022-12-30 07:37:56 UTC" - ) - ); - assert_str_eq!( - movie_details_modal.audio_details, - formatdoc!( - "Bitrate: 0 - Channels: 7.1 - Codec: AAC - Languages: eng - Stream Count: 1" - ) - ); - assert_str_eq!( - movie_details_modal.video_details, - formatdoc!( - "Bit Depth: 10 - Bitrate: 0 - Codec: x265 - FPS: 23.976 - Resolution: 1920x804 - Scan Type: Progressive - Runtime: 2:00:00" - ) - ); - } - } - - #[tokio::test] - async fn test_handle_get_movie_details_event_empty_options_give_correct_defaults() { - let movie_json_with_missing_fields = json!({ - "id": 1, - "title": "Test", - "originalLanguage": { - "id": 1, - "name": "English" - }, - "sizeOnDisk": 0, - "status": "Downloaded", - "overview": "Blah blah blah", - "path": "/nfs/movies", - "studio": "21st Century Alex", - "genres": ["cool", "family", "fun"], - "year": 2023, - "monitored": true, - "hasFile": false, - "runtime": 120, - "tmdbId": 1234, - "qualityProfileId": 2222, - "tags": [1], - "minimumAvailability": "released", - "ratings": {} - }); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(movie_json_with_missing_fields), - None, - RadarrEvent::GetMovieDetails(1), - Some("/1"), - None, - ) - .await; - app_arc.lock().await.data.radarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::GetMovieDetails(1)) - .await - .is_ok()); - - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .radarr_data - .movie_details_modal - .is_some()); - - let app = app_arc.lock().await; - let movie_details_modal = app.data.radarr_data.movie_details_modal.as_ref().unwrap(); - assert_str_eq!( - movie_details_modal.movie_details.get_text(), - formatdoc!( - "Title: Test - Year: 2023 - Runtime: 2h 0m - Rating: - Collection: - Status: Missing - Description: Blah blah blah - TMDB: - IMDB: - Rotten Tomatoes: - Quality Profile: HD - 1080p - Size: 0.00 GB - Path: /nfs/movies - Studio: 21st Century Alex - Genres: cool, family, fun" - ) - ); - assert!(movie_details_modal.file_details.is_empty()); - assert!(movie_details_modal.audio_details.is_empty()); - assert!(movie_details_modal.video_details.is_empty()); - } - - #[tokio::test] - async fn test_handle_get_movie_history_event() { - let movie_history_item_json = json!([{ - "sourceTitle": "Test", - "quality": { "quality": { "name": "HD - 1080p" }}, - "languages": [ { "id": 1, "name": "English" } ], - "date": "2022-12-30T07:37:56Z", - "eventType": "grabbed" - }]); - let response: Vec = - serde_json::from_value(movie_history_item_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(movie_history_item_json), - None, - RadarrEvent::GetMovieHistory(1), - None, - Some("movieId=1"), - ) - .await; - app_arc.lock().await.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::MovieHistoryItems(history) = network - .handle_radarr_event(RadarrEvent::GetMovieHistory(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .items, - vec![movie_history_item()] - ); - assert_eq!(history, response); - } - } - - #[tokio::test] - async fn test_handle_get_movie_history_event_empty_movie_details_modal() { - let movie_history_item_json = json!([{ - "sourceTitle": "Test", - "quality": { "quality": { "name": "HD - 1080p" }}, - "languages": [ { "id": 1, "name": "English" } ], - "date": "2022-12-30T07:37:56Z", - "eventType": "grabbed" - }]); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(movie_history_item_json), - None, - RadarrEvent::GetMovieHistory(1), - None, - Some("movieId=1"), - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::GetMovieHistory(1)) - .await - .is_ok()); - - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .radarr_data - .movie_details_modal - .as_ref() - .unwrap() - .movie_history - .items, - vec![movie_history_item()] - ); - } - - #[rstest] - #[tokio::test] - async fn test_handle_get_radarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { - let blocklist_json = json!({"records": [{ - "id": 123, - "movieId": 1007, - "sourceTitle": "z movie", - "languages": [{"id": 1, "name": "English"}], - "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"id": 1, "name": "English"}], - "date": "2024-02-10T07:28:45Z", - "protocol": "usenet", - "indexer": "DrunkenSlug (Prowlarr)", - "message": "test message", - "movie": { - "id": 1007, - "title": "z movie", - "tmdbId": 1234, - "originalLanguage": {"id": 1, "name": "English"}, - "sizeOnDisk": 3543348019i64, - "status": "Downloaded", - "overview": "Blah blah blah", - "path": "/nfs/movies", - "studio": "21st Century Alex", - "genres": ["cool", "family", "fun"], - "year": 2023, - "monitored": true, - "hasFile": true, - "runtime": 120, - "qualityProfileId": 2222, - "minimumAvailability": "announced", - "certification": "R", - "tags": [1], - "ratings": { - "imdb": {"value": 9.9}, - "tmdb": {"value": 9.9}, - "rottenTomatoes": {"value": 9.9} - }, - }, - }, { - "id": 456, - "movieId": 2001, - "sourceTitle": "A Movie", - "languages": [{"id": 1, "name": "English"}], - "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"id": 1, "name": "English"}], - "date": "2024-02-10T07:28:45Z", - "protocol": "usenet", - "indexer": "DrunkenSlug (Prowlarr)", - "message": "test message", - "movie": { - "id": 2001, - "title": "A Movie", - "tmdbId": 1234, - "originalLanguage": {"id": 1, "name": "English"}, - "sizeOnDisk": 3543348019i64, - "status": "Downloaded", - "overview": "Blah blah blah", - "path": "/nfs/movies", - "studio": "21st Century Alex", - "genres": ["cool", "family", "fun"], - "year": 2023, - "monitored": true, - "hasFile": true, - "runtime": 120, - "qualityProfileId": 2222, - "minimumAvailability": "announced", - "certification": "R", - "tags": [1], - "ratings": { - "imdb": {"value": 9.9}, - "tmdb": {"value": 9.9}, - "rottenTomatoes": {"value": 9.9} - }, - }, - }]}); - let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap(); - let mut expected_blocklist = vec![ - BlocklistItem { - id: 123, - movie_id: 1007, - source_title: "z movie".into(), - movie: BlocklistItemMovie { - title: "z movie".into(), - }, - ..blocklist_item() - }, - BlocklistItem { - id: 456, - movie_id: 2001, - source_title: "A Movie".into(), - movie: BlocklistItemMovie { - title: "A Movie".into(), - }, - ..blocklist_item() - }, - ]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(blocklist_json), - None, - RadarrEvent::GetBlocklist, - None, - None, - ) - .await; - app_arc.lock().await.data.radarr_data.blocklist.sort_asc = true; - if use_custom_sorting { - let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { - a.source_title - .to_lowercase() - .cmp(&b.source_title.to_lowercase()) - }; - expected_blocklist.sort_by(cmp_fn); - - let blocklist_sort_option = SortOption { - name: "Source Title", - cmp_fn: Some(cmp_fn), - }; - app_arc - .lock() - .await - .data - .radarr_data - .blocklist - .sorting(vec![blocklist_sort_option]); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::BlocklistResponse(blocklist) = network - .handle_radarr_event(RadarrEvent::GetBlocklist) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.blocklist.items, - expected_blocklist - ); - assert!(app_arc.lock().await.data.radarr_data.blocklist.sort_asc); - assert_eq!(blocklist, response); - } - } - - #[tokio::test] - async fn test_handle_get_blocklist_event_no_op_when_user_is_selecting_sort_options() { - let blocklist_json = json!({"records": [{ - "id": 123, - "movieId": 1007, - "sourceTitle": "z movie", - "languages": [{"id": 1, "name": "English"}], - "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"id": 1, "name": "English"}], - "date": "2024-02-10T07:28:45Z", - "protocol": "usenet", - "indexer": "DrunkenSlug (Prowlarr)", - "message": "test message", - "movie": { - "id": 1007, - "title": "z movie", - "tmdbId": 1234, - "originalLanguage": {"id": 1, "name": "English"}, - "sizeOnDisk": 3543348019i64, - "status": "Downloaded", - "overview": "Blah blah blah", - "path": "/nfs/movies", - "studio": "21st Century Alex", - "genres": ["cool", "family", "fun"], - "year": 2023, - "monitored": true, - "hasFile": true, - "runtime": 120, - "qualityProfileId": 2222, - "minimumAvailability": "announced", - "certification": "R", - "tags": [1], - "ratings": { - "imdb": {"value": 9.9}, - "tmdb": {"value": 9.9}, - "rottenTomatoes": {"value": 9.9} - }, - }, - }, { - "id": 456, - "movieId": 2001, - "sourceTitle": "A Movie", - "languages": [{"id": 1, "name": "English"}], - "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"id": 1, "name": "English"}], - "date": "2024-02-10T07:28:45Z", - "protocol": "usenet", - "indexer": "DrunkenSlug (Prowlarr)", - "message": "test message", - "movie": { - "id": 2001, - "title": "A Movie", - "tmdbId": 1234, - "originalLanguage": {"id": 1, "name": "English"}, - "sizeOnDisk": 3543348019i64, - "status": "Downloaded", - "overview": "Blah blah blah", - "path": "/nfs/movies", - "studio": "21st Century Alex", - "genres": ["cool", "family", "fun"], - "year": 2023, - "monitored": true, - "hasFile": true, - "runtime": 120, - "qualityProfileId": 2222, - "minimumAvailability": "announced", - "certification": "R", - "tags": [1], - "ratings": { - "imdb": {"value": 9.9}, - "tmdb": {"value": 9.9}, - "rottenTomatoes": {"value": 9.9} - }, - }, - }]}); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(blocklist_json), - None, - RadarrEvent::GetBlocklist, - None, - None, - ) - .await; - app_arc.lock().await.data.radarr_data.blocklist.sort_asc = true; - app_arc - .lock() - .await - .push_navigation_stack(ActiveRadarrBlock::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 - .radarr_data - .blocklist - .sorting(vec![blocklist_sort_option]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::GetBlocklist) - .await - .is_ok()); - - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .radarr_data - .blocklist - .items - .is_empty()); - assert!(app_arc.lock().await.data.radarr_data.blocklist.sort_asc); - } - - #[rstest] - #[tokio::test] - async fn test_handle_get_collections_event(#[values(true, false)] use_custom_sorting: bool) { - let collections_json = json!([{ - "id": 123, - "title": "z Collection", - "rootFolderPath": "/nfs/movies", - "searchOnAdd": true, - "monitored": true, - "minimumAvailability": "released", - "overview": "Collection blah blah blah", - "qualityProfileId": 2222, - "movies": [{ - "title": "Test", - "overview": "Collection blah blah blah", - "year": 2023, - "runtime": 120, - "tmdbId": 1234, - "genres": ["cool", "family", "fun"], - "ratings": { - "imdb": { - "value": 9.9 - }, - "tmdb": { - "value": 9.9 - }, - "rottenTomatoes": { - "value": 9.9 - } - } - }], - }, - { - "id": 456, - "title": "A Collection", - "rootFolderPath": "/nfs/movies", - "searchOnAdd": true, - "monitored": true, - "minimumAvailability": "released", - "overview": "Collection blah blah blah", - "qualityProfileId": 2222, - "movies": [{ - "title": "Test", - "overview": "Collection blah blah blah", - "year": 2023, - "runtime": 120, - "tmdbId": 1234, - "genres": ["cool", "family", "fun"], - "ratings": { - "imdb": { - "value": 9.9 - }, - "tmdb": { - "value": 9.9 - }, - "rottenTomatoes": { - "value": 9.9 - } - } - }], - }]); - let response: Vec = serde_json::from_value(collections_json.clone()).unwrap(); - let mut expected_collections = vec![ - Collection { - id: 123, - title: "z Collection".into(), - ..collection() - }, - Collection { - id: 456, - title: "A Collection".into(), - ..collection() - }, - ]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(collections_json), - None, - RadarrEvent::GetCollections, - None, - None, - ) - .await; - app_arc.lock().await.data.radarr_data.collections.sort_asc = true; - if use_custom_sorting { - let cmp_fn = |a: &Collection, b: &Collection| { - a.title - .text - .to_lowercase() - .cmp(&b.title.text.to_lowercase()) - }; - expected_collections.sort_by(cmp_fn); - - let collection_sort_option = SortOption { - name: "Collection", - cmp_fn: Some(cmp_fn), - }; - app_arc - .lock() - .await - .data - .radarr_data - .collections - .sorting(vec![collection_sort_option]); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Collections(collections) = network - .handle_radarr_event(RadarrEvent::GetCollections) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.collections.items, - expected_collections - ); - assert!(app_arc.lock().await.data.radarr_data.collections.sort_asc); - assert_eq!(collections, response); - } - } - - #[tokio::test] - async fn test_handle_get_collections_event_no_op_when_user_is_selecting_sort_options() { - let collections_json = json!([{ - "id": 123, - "title": "z Collection", - "rootFolderPath": "/nfs/movies", - "searchOnAdd": true, - "monitored": true, - "minimumAvailability": "released", - "overview": "Collection blah blah blah", - "qualityProfileId": 2222, - "movies": [{ - "title": "Test", - "overview": "Collection blah blah blah", - "year": 2023, - "runtime": 120, - "tmdbId": 1234, - "genres": ["cool", "family", "fun"], - "ratings": { - "imdb": { - "value": 9.9 - }, - "tmdb": { - "value": 9.9 - }, - "rottenTomatoes": { - "value": 9.9 - } - } - }], - }, - { - "id": 456, - "title": "A Collection", - "rootFolderPath": "/nfs/movies", - "searchOnAdd": true, - "monitored": true, - "minimumAvailability": "released", - "overview": "Collection blah blah blah", - "qualityProfileId": 2222, - "movies": [{ - "title": "Test", - "overview": "Collection blah blah blah", - "year": 2023, - "runtime": 120, - "tmdbId": 1234, - "genres": ["cool", "family", "fun"], - "ratings": { - "imdb": { - "value": 9.9 - }, - "tmdb": { - "value": 9.9 - }, - "rottenTomatoes": { - "value": 9.9 - } - } - }], - }]); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(collections_json), - None, - RadarrEvent::GetCollections, - None, - None, - ) - .await; - app_arc.lock().await.data.radarr_data.collections.sort_asc = true; - app_arc - .lock() - .await - .push_navigation_stack(ActiveRadarrBlock::CollectionsSortPrompt.into()); - let cmp_fn = |a: &Collection, b: &Collection| { - a.title - .text - .to_lowercase() - .cmp(&b.title.text.to_lowercase()) - }; - let collection_sort_option = SortOption { - name: "Collection", - cmp_fn: Some(cmp_fn), - }; - app_arc - .lock() - .await - .data - .radarr_data - .collections - .sorting(vec![collection_sort_option]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::GetCollections) - .await - .is_ok()); - - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .radarr_data - .collections - .items - .is_empty()); - assert!(app_arc.lock().await.data.radarr_data.collections.sort_asc); - } - - #[tokio::test] - async fn test_handle_get_radarr_downloads_event() { - let downloads_response_json = json!({ - "records": [{ - "title": "Test Download Title", - "status": "downloading", - "id": 1, - "movieId": 1, - "size": 3543348019u64, - "sizeleft": 1771674009, - "outputPath": "/nfs/movies/Test", - "indexer": "kickass torrents", - "downloadClient": "transmission", - }] - }); - let response: DownloadsResponse = - serde_json::from_value(downloads_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(downloads_response_json), - None, - RadarrEvent::GetDownloads(500), - None, - Some("pageSize=500"), - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::DownloadsResponse(downloads) = network - .handle_radarr_event(RadarrEvent::GetDownloads(500)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.downloads.items, - downloads_response().records - ); - assert_eq!(downloads, response); - } - } - - #[tokio::test] - async fn test_handle_get_radarr_host_config_event() { - let host_config_response = json!({ - "bindAddress": "*", - "port": 7878, - "urlBase": "some.test.site/radarr", - "instanceName": "Radarr", - "applicationUrl": "https://some.test.site:7878/radarr", - "enableSsl": true, - "sslPort": 9898, - "sslCertPath": "/app/radarr.pfx", - "sslCertPassword": "test" - }); - let response: HostConfig = serde_json::from_value(host_config_response.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(host_config_response), - None, - RadarrEvent::GetHostConfig, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::HostConfig(host_config) = network - .handle_radarr_event(RadarrEvent::GetHostConfig) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(host_config, response); - } - } - - #[tokio::test] - async fn test_handle_get_radarr_indexers_event() { - let indexers_response_json = json!([{ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "supportsRss": true, - "supportsSearch": true, - "protocol": "torrent", - "priority": 25, - "downloadClientId": 0, - "name": "Test Indexer", - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "implementationName": "Torznab", - "implementation": "Torznab", - "configContract": "TorznabSettings", - "tags": [1], - "id": 1 - }]); - let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexers_response_json), - None, - RadarrEvent::GetIndexers, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Indexers(indexers) = network - .handle_radarr_event(RadarrEvent::GetIndexers) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.indexers.items, - vec![indexer()] - ); - assert_eq!(indexers, response); - } - } - - #[tokio::test] - async fn test_handle_get_all_indexer_settings_event() { - let indexer_settings_response_json = json!({ - "minimumAge": 0, - "maximumSize": 0, - "retention": 0, - "rssSyncInterval": 60, - "preferIndexerFlags": false, - "availabilityDelay": 0, - "allowHardcodedSubs": true, - "whitelistedHardcodedSubs": "", - "id": 1 - }); - let response: IndexerSettings = - serde_json::from_value(indexer_settings_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_settings_response_json), - None, - RadarrEvent::GetAllIndexerSettings, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::IndexerSettings(settings) = network - .handle_radarr_event(RadarrEvent::GetAllIndexerSettings) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.indexer_settings, - Some(indexer_settings()) - ); - assert_eq!(settings, response); - } - } - - #[tokio::test] - async fn test_handle_get_all_indexer_settings_event_no_op_if_already_present() { - let indexer_settings_response_json = json!({ - "minimumAge": 0, - "maximumSize": 0, - "retention": 0, - "rssSyncInterval": 60, - "preferIndexerFlags": false, - "availabilityDelay": 0, - "allowHardcodedSubs": true, - "whitelistedHardcodedSubs": "", - "id": 1 - }); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_settings_response_json), - None, - RadarrEvent::GetAllIndexerSettings, - None, - None, - ) - .await; - app_arc.lock().await.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::GetAllIndexerSettings) - .await - .is_ok()); - - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.indexer_settings, - Some(IndexerSettings::default()) - ); - } - - #[tokio::test] - async fn test_handle_get_queued_radarr_events_event() { - let queued_events_json = json!([{ - "name": "RefreshMonitoredDownloads", - "commandName": "Refresh Monitored Downloads", - "status": "completed", - "queued": "2023-05-20T21:29:16Z", - "started": "2023-05-20T21:29:16Z", - "ended": "2023-05-20T21:29:16Z", - "duration": "00:00:00.5111547", - "trigger": "scheduled", - }]); - let response: Vec = serde_json::from_value(queued_events_json.clone()).unwrap(); - let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); - let expected_event = QueueEvent { - name: "RefreshMonitoredDownloads".to_owned(), - command_name: "Refresh Monitored Downloads".to_owned(), - status: "completed".to_owned(), - queued: timestamp, - started: Some(timestamp), - ended: Some(timestamp), - duration: Some("00:00:00.5111547".to_owned()), - trigger: "scheduled".to_owned(), - }; - - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(queued_events_json), - None, - RadarrEvent::GetQueuedEvents, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::QueueEvents(events) = network - .handle_radarr_event(RadarrEvent::GetQueuedEvents) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.queued_events.items, - vec![expected_event] - ); - assert_eq!(events, response); - } - } - - #[tokio::test] - async fn test_handle_get_radarr_logs_event() { - let expected_logs = vec![ - HorizontallyScrollableText::from( - "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", - ), - HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), - ]; - let logs_response_json = json!({ - "page": 1, - "pageSize": 500, - "sortKey": "time", - "sortDirection": "descending", - "totalRecords": 2, - "records": [ - { - "time": "2023-05-20T21:29:16Z", - "level": "info", - "logger": "TestLogger", - "message": "test message", - "id": 1 - }, - { - "time": "2023-05-20T21:29:16Z", - "level": "fatal", - "logger": "RadarrError", - "exception": "test exception", - "exceptionType": "Some.Big.Bad.Exception", - "id": 2 - } - ] - }); - let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(logs_response_json), - None, - RadarrEvent::GetLogs(500), - None, - Some("pageSize=500&sortDirection=descending&sortKey=time"), - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::LogResponse(logs) = network - .handle_radarr_event(RadarrEvent::GetLogs(500)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.logs.items, - expected_logs - ); - assert!(app_arc - .lock() - .await - .data - .radarr_data - .logs - .current_selection() - .text - .contains("INFO")); - assert_eq!(logs, response); - } - } - - #[tokio::test] - async fn test_handle_get_radarr_quality_profiles_event() { - let quality_profile_json = json!([{ - "id": 2222, - "name": "HD - 1080p" - }]); - let response: Vec = - serde_json::from_value(quality_profile_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(quality_profile_json), - None, - RadarrEvent::GetQualityProfiles, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::QualityProfiles(quality_profiles) = network - .handle_radarr_event(RadarrEvent::GetQualityProfiles) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.quality_profile_map, - BiMap::from_iter([(2222i64, "HD - 1080p".to_owned())]) - ); - assert_eq!(quality_profiles, response); - } - } - - #[tokio::test] - async fn test_handle_get_radarr_tags_event() { - let tags_json = json!([{ - "id": 2222, - "label": "usenet" - }]); - let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(tags_json), - None, - RadarrEvent::GetTags, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Tags(tags) = network - .handle_radarr_event(RadarrEvent::GetTags) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.tags_map, - BiMap::from_iter([(2222i64, "usenet".to_owned())]) - ); - assert_eq!(tags, response); - } - } - - #[tokio::test] - async fn test_handle_get_radarr_tasks_event() { - let tasks_json = json!([{ - "name": "Application Check Update", - "taskName": "ApplicationCheckUpdate", - "interval": 360, - "lastExecution": "2023-05-20T21:29:16Z", - "nextExecution": "2023-05-20T21:29:16Z", - "lastDuration": "00:00:00.5111547", - }, - { - "name": "Backup", - "taskName": "Backup", - "interval": 10080, - "lastExecution": "2023-05-20T21:29:16Z", - "nextExecution": "2023-05-20T21:29:16Z", - "lastDuration": "00:00:00.5111547", - }]); - let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); - let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); - let expected_tasks = vec![ - RadarrTask { - name: "Application Check Update".to_owned(), - task_name: RadarrTaskName::ApplicationCheckUpdate, - interval: 360, - last_execution: timestamp, - next_execution: timestamp, - last_duration: "00:00:00.5111547".to_owned(), - }, - RadarrTask { - name: "Backup".to_owned(), - task_name: RadarrTaskName::Backup, - interval: 10080, - last_execution: timestamp, - next_execution: timestamp, - last_duration: "00:00:00.5111547".to_owned(), - }, - ]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(tasks_json), - None, - RadarrEvent::GetTasks, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Tasks(tasks) = network - .handle_radarr_event(RadarrEvent::GetTasks) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.tasks.items, - expected_tasks - ); - assert_eq!(tasks, response); - } - } - - #[tokio::test] - async fn test_handle_get_radarr_updates_event() { - let updates_json = json!([{ - "version": "4.3.2.1", - "releaseDate": "2023-04-15T02:02:53Z", - "installed": true, - "installedOn": "2023-04-15T02:02:53Z", - "latest": true, - "changes": { - "new": [ - "Cool new thing" - ], - "fixed": [ - "Some bugs killed" - ] - }, - }, - { - "version": "3.2.1.0", - "releaseDate": "2023-04-15T02:02:53Z", - "installed": false, - "installedOn": "2023-04-15T02:02:53Z", - "latest": false, - "changes": { - "new": [ - "Cool new thing (old)", - "Other cool new thing (old)" - ], - }, - }, - { - "version": "2.1.0", - "releaseDate": "2023-04-15T02:02:53Z", - "installed": false, - "latest": false, - "changes": { - "fixed": [ - "Killed bug 1", - "Fixed bug 2" - ] - }, - }]); - let response: Vec = serde_json::from_value(updates_json.clone()).unwrap(); - let line_break = "-".repeat(200); - let expected_text = ScrollableText::with_string(formatdoc!( - " - The latest version of Radarr is already installed - - 4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) - {line_break} - New: - * Cool new thing - Fixed: - * Some bugs killed - - - 3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) - {line_break} - New: - * Cool new thing (old) - * Other cool new thing (old) - - - 2.1.0 - 2023-04-15 02:02:53 UTC - {line_break} - Fixed: - * Killed bug 1 - * Fixed bug 2" - )); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(updates_json), - None, - RadarrEvent::GetUpdates, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Updates(updates) = network - .handle_radarr_event(RadarrEvent::GetUpdates) - .await - .unwrap() - { - async_server.assert_async().await; - assert_str_eq!( - app_arc.lock().await.data.radarr_data.updates.get_text(), - expected_text.get_text() - ); - assert_eq!(updates, response); - } - } - - #[tokio::test] - async fn test_handle_add_radarr_tag() { - let tag_json = json!({ "id": 3, "label": "testing" }); - let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ "label": "testing" })), - Some(tag_json), - None, - RadarrEvent::AddTag(String::new()), - None, - None, - ) - .await; - app_arc.lock().await.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Tag(tag) = network - .handle_radarr_event(RadarrEvent::AddTag("testing".to_owned())) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.tags_map, - BiMap::from_iter([ - (1, "usenet".to_owned()), - (2, "test".to_owned()), - (3, "testing".to_owned()) - ]) - ); - assert_eq!(tag, response); - } - } - - #[tokio::test] - async fn test_handle_delete_radarr_tag_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - RadarrEvent::DeleteTag(1), - Some("/1"), - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::DeleteTag(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_get_radarr_root_folders_event() { - let root_folder_json = json!([{ - "id": 1, - "path": "/nfs", - "accessible": true, - "freeSpace": 219902325555200u64, - }]); - let response: Vec = serde_json::from_value(root_folder_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(root_folder_json), - None, - RadarrEvent::GetRootFolders, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::RootFolders(root_folders) = network - .handle_radarr_event(RadarrEvent::GetRootFolders) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.radarr_data.root_folders.items, - vec![root_folder()] - ); - assert_eq!(root_folders, response); - } - } - - #[tokio::test] - async fn test_handle_get_radarr_security_config_event() { - let security_config_response = json!({ - "authenticationMethod": "forms", - "authenticationRequired": "disabledForLocalAddresses", - "username": "test", - "password": "some password", - "apiKey": "someApiKey12345", - "certificateValidation": "disabledForLocalAddresses", - }); - let response: SecurityConfig = - serde_json::from_value(security_config_response.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(security_config_response), - None, - RadarrEvent::GetSecurityConfig, - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::SecurityConfig(security_config) = network - .handle_radarr_event(RadarrEvent::GetSecurityConfig) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(security_config, response); - } - } - - #[tokio::test] - async fn test_handle_get_movie_credits_event() { - let credits_json = json!([ - { - "personName": "Madison Clarke", - "character": "Johnny Blaze", - "type": "cast", - }, - { - "personName": "Alex Clarke", - "department": "Music", - "job": "Composition", - "type": "crew", - } - ]); - let response: Vec = serde_json::from_value(credits_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(credits_json), - None, - RadarrEvent::GetMovieCredits(1), - None, - Some("movieId=1"), - ) - .await; - app_arc.lock().await.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Credits(credits) = network - .handle_radarr_event(RadarrEvent::GetMovieCredits(1)) - .await - .unwrap() - { - let app = app_arc.lock().await; - let movie_details_modal = app.data.radarr_data.movie_details_modal.as_ref().unwrap(); - - async_server.assert_async().await; - assert_eq!(movie_details_modal.movie_cast.items, vec![cast_credit()]); - assert_eq!(movie_details_modal.movie_crew.items, vec![crew_credit()]); - assert_eq!(credits, response); - } - } - - #[tokio::test] - async fn test_handle_get_movie_credits_event_empty_movie_details_modal() { - let credits_json = json!([ - { - "personName": "Madison Clarke", - "character": "Johnny Blaze", - "type": "cast", - }, - { - "personName": "Alex Clarke", - "department": "Music", - "job": "Composition", - "type": "crew", - } - ]); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(credits_json), - None, - RadarrEvent::GetMovieCredits(1), - None, - Some("movieId=1"), - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::GetMovieCredits(1)) - .await - .is_ok()); - - let app = app_arc.lock().await; - let movie_details_modal = app.data.radarr_data.movie_details_modal.as_ref().unwrap(); - - async_server.assert_async().await; - assert_eq!(movie_details_modal.movie_cast.items, vec![cast_credit()]); - assert_eq!(movie_details_modal.movie_crew.items, vec![crew_credit()]); - } - - #[tokio::test] - async fn test_handle_delete_movie_event() { - let delete_movie_params = DeleteMovieParams { - id: 1, - delete_movie_files: true, - add_list_exclusion: true, - }; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - RadarrEvent::DeleteMovie(delete_movie_params.clone()), - Some("/1"), - Some("deleteFiles=true&addImportExclusion=true"), - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::DeleteMovie(delete_movie_params)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_clear_radarr_blocklist_event() { - let blocklist_items = vec![ - BlocklistItem { - id: 1, - ..blocklist_item() - }, - BlocklistItem { - id: 2, - ..blocklist_item() - }, - BlocklistItem { - id: 3, - ..blocklist_item() - }, - ]; - let expected_request_json = json!({ "ids": [1, 2, 3]}); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - Some(expected_request_json), - None, - None, - RadarrEvent::ClearBlocklist, - None, - None, - ) - .await; - app_arc - .lock() - .await - .data - .radarr_data - .blocklist - .set_items(blocklist_items); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::ClearBlocklist) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_radarr_blocklist_item_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - RadarrEvent::DeleteBlocklistItem(1), - Some("/1"), - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::DeleteBlocklistItem(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_radarr_download_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - RadarrEvent::DeleteDownload(1), - Some("/1"), - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::DeleteDownload(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_radarr_indexer_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - RadarrEvent::DeleteIndexer(1), - Some("/1"), - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::DeleteIndexer(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_radarr_root_folder_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - RadarrEvent::DeleteRootFolder(1), - Some("/1"), - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::DeleteRootFolder(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_add_movie_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "tmdbId": 1234, - "title": "Test", - "rootFolderPath": "/nfs2", - "minimumAvailability": "announced", - "monitored": true, - "qualityProfileId": 2222, - "tags": [1, 2], - "addOptions": { - "monitor": "movieOnly", - "searchForMovie": true - } - })), - Some(json!({})), - None, - RadarrEvent::AddMovie(AddMovieBody::default()), - None, - None, - ) - .await; - app_arc.lock().await.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - let add_movie_body = AddMovieBody { - tmdb_id: 1234, - title: "Test".to_owned(), - root_folder_path: "/nfs2".to_owned(), - minimum_availability: "announced".to_owned(), - monitored: true, - quality_profile_id: 2222, - tags: vec![1, 2], - tag_input_string: Some("usenet, testing".into()), - add_options: AddMovieOptions { - monitor: "movieOnly".to_owned(), - search_for_movie: true, - }, - }; - - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::AddMovie(add_movie_body)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_add_movie_event_does_not_overwrite_tags_field_if_tag_input_string_is_none() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "tmdbId": 1234, - "title": "Test", - "rootFolderPath": "/nfs2", - "minimumAvailability": "announced", - "monitored": true, - "qualityProfileId": 2222, - "tags": [1, 2], - "addOptions": { - "monitor": "movieOnly", - "searchForMovie": true - } - })), - Some(json!({})), - None, - RadarrEvent::AddMovie(AddMovieBody::default()), - None, - None, - ) - .await; - app_arc.lock().await.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - let add_movie_body = AddMovieBody { - tmdb_id: 1234, - title: "Test".to_owned(), - root_folder_path: "/nfs2".to_owned(), - minimum_availability: "announced".to_owned(), - monitored: true, - quality_profile_id: 2222, - tags: vec![1, 2], - tag_input_string: None, - add_options: AddMovieOptions { - monitor: "movieOnly".to_owned(), - search_for_movie: true, - }, - }; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::AddMovie(add_movie_body)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_add_radarr_root_folder_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "path": "/nfs/test" - })), - Some(json!({})), - None, - RadarrEvent::AddRootFolder(AddRootFolderBody::default()), - None, - None, - ) - .await; - let add_root_folder_body = AddRootFolderBody { - path: "/nfs/test".to_owned(), - }; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::AddRootFolder(add_root_folder_body)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_all_radarr_indexer_settings_event() { - let indexer_settings_json = json!({ - "minimumAge": 0, - "maximumSize": 0, - "retention": 0, - "rssSyncInterval": 60, - "preferIndexerFlags": false, - "availabilityDelay": 0, - "allowHardcodedSubs": true, - "whitelistedHardcodedSubs": "", - "id": 1 - }); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Put, - Some(indexer_settings_json), - None, - None, - RadarrEvent::EditAllIndexerSettings(indexer_settings()), - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditAllIndexerSettings(indexer_settings())) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_collection_event() { - let detailed_collection_body = json!({ - "id": 123, - "title": "Test Collection", - "rootFolderPath": "/nfs/movies", - "searchOnAdd": true, - "monitored": true, - "minimumAvailability": "released", - "overview": "Collection blah blah blah", - "qualityProfileId": 2222, - "movies": [ - { - "title": "Test", - "overview": "Collection blah blah blah", - "year": 2023, - "runtime": 120, - "tmdbId": 1234, - "genres": ["cool", "family", "fun"], - "ratings": { - "imdb": { - "value": 9.9 - }, - "tmdb": { - "value": 9.9 - }, - "rottenTomatoes": { - "value": 9.9 - } - } - } - ] - }); - let mut expected_body = detailed_collection_body.clone(); - *expected_body.get_mut("monitored").unwrap() = json!(false); - *expected_body.get_mut("minimumAvailability").unwrap() = json!("announced"); - *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); - *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/Test Path"); - *expected_body.get_mut("searchOnAdd").unwrap() = json!(false); - let edit_collection_params = EditCollectionParams { - collection_id: 123, - monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), - root_folder_path: Some("/nfs/Test Path".to_owned()), - search_on_add: Some(false), - }; - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(detailed_collection_body), - None, - RadarrEvent::GetCollections, - Some("/123"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/123", - RadarrEvent::EditCollection(edit_collection_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditCollection(edit_collection_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_collection_event_defaults_to_previous_values_when_no_params_are_provided( - ) { - let detailed_collection_body = json!({ - "id": 123, - "title": "Test Collection", - "rootFolderPath": "/nfs/movies", - "searchOnAdd": true, - "monitored": true, - "minimumAvailability": "released", - "overview": "Collection blah blah blah", - "qualityProfileId": 2222, - "movies": [ - { - "title": "Test", - "overview": "Collection blah blah blah", - "year": 2023, - "runtime": 120, - "tmdbId": 1234, - "genres": ["cool", "family", "fun"], - "ratings": { - "imdb": { - "value": 9.9 - }, - "tmdb": { - "value": 9.9 - }, - "rottenTomatoes": { - "value": 9.9 - } - } - } - ] - }); - let mut expected_body = detailed_collection_body.clone(); - *expected_body.get_mut("monitored").unwrap() = json!(true); - *expected_body.get_mut("minimumAvailability").unwrap() = json!("released"); - *expected_body.get_mut("qualityProfileId").unwrap() = json!(2222); - *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/movies"); - *expected_body.get_mut("searchOnAdd").unwrap() = json!(true); - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(detailed_collection_body), - None, - RadarrEvent::GetCollections, - Some("/123"), - None, - ) - .await; - let edit_collection_params = EditCollectionParams { - collection_id: 123, - ..EditCollectionParams::default() - }; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/123", - RadarrEvent::EditCollection(edit_collection_params).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - let edit_collection_params = EditCollectionParams { - collection_id: 123, - ..EditCollectionParams::default() - }; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditCollection(edit_collection_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_radarr_indexer_event() { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let expected_indexer_edit_body_json = json!({ - "enableRss": false, - "enableAutomaticSearch": false, - "enableInteractiveSearch": false, - "name": "Test Update", - "priority": 0, - "fields": [ - { - "name": "baseUrl", - "value": "https://localhost:9696/1/", - }, - { - "name": "apiKey", - "value": "test1234", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.3", - }, - ], - "tags": [1, 2], - "id": 1 - }); - let edit_indexer_params = EditIndexerParams { - indexer_id: 1, - name: Some("Test Update".to_owned()), - enable_rss: Some(false), - enable_automatic_search: Some(false), - enable_interactive_search: Some(false), - url: Some("https://localhost:9696/1/".to_owned()), - api_key: Some("test1234".to_owned()), - seed_ratio: Some("1.3".to_owned()), - tag_input_string: Some("usenet, testing".to_owned()), - priority: Some(0), - ..EditIndexerParams::default() - }; - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - RadarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_indexer_edit_body_json)) - .create_async() - .await; - app_arc.lock().await.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_radarr_indexer_event_does_not_overwrite_tags_vec_if_tag_input_string_is_none( - ) { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let expected_indexer_edit_body_json = json!({ - "enableRss": false, - "enableAutomaticSearch": false, - "enableInteractiveSearch": false, - "name": "Test Update", - "priority": 0, - "fields": [ - { - "name": "baseUrl", - "value": "https://localhost:9696/1/", - }, - { - "name": "apiKey", - "value": "test1234", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.3", - }, - ], - "tags": [1, 2], - "id": 1 - }); - let edit_indexer_params = EditIndexerParams { - indexer_id: 1, - name: Some("Test Update".to_owned()), - enable_rss: Some(false), - enable_automatic_search: Some(false), - enable_interactive_search: Some(false), - url: Some("https://localhost:9696/1/".to_owned()), - api_key: Some("test1234".to_owned()), - seed_ratio: Some("1.3".to_owned()), - tags: Some(vec![1, 2]), - priority: Some(0), - ..EditIndexerParams::default() - }; - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - RadarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_indexer_edit_body_json)) - .create_async() - .await; - app_arc.lock().await.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_radarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( - ) { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - ], - "tags": [1], - "id": 1 - }); - let expected_indexer_edit_body_json = json!({ - "enableRss": false, - "enableAutomaticSearch": false, - "enableInteractiveSearch": false, - "name": "Test Update", - "priority": 0, - "fields": [ - { - "name": "baseUrl", - "value": "https://localhost:9696/1/", - }, - { - "name": "apiKey", - "value": "test1234", - }, - ], - "tags": [1, 2], - "id": 1 - }); - let edit_indexer_params = EditIndexerParams { - indexer_id: 1, - name: Some("Test Update".to_owned()), - enable_rss: Some(false), - enable_automatic_search: Some(false), - enable_interactive_search: Some(false), - url: Some("https://localhost:9696/1/".to_owned()), - api_key: Some("test1234".to_owned()), - seed_ratio: Some("1.3".to_owned()), - tag_input_string: Some("usenet, testing".to_owned()), - priority: Some(0), - ..EditIndexerParams::default() - }; - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - RadarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_indexer_edit_body_json)) - .create_async() - .await; - app_arc.lock().await.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_radarr_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( - ) { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - }, - ], - "tags": [1], - "id": 1 - }); - let expected_indexer_edit_body_json = json!({ - "enableRss": false, - "enableAutomaticSearch": false, - "enableInteractiveSearch": false, - "name": "Test Update", - "priority": 0, - "fields": [ - { - "name": "baseUrl", - "value": "https://localhost:9696/1/", - }, - { - "name": "apiKey", - "value": "test1234", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.3", - }, - ], - "tags": [1, 2], - "id": 1 - }); - let edit_indexer_params = EditIndexerParams { - indexer_id: 1, - name: Some("Test Update".to_owned()), - enable_rss: Some(false), - enable_automatic_search: Some(false), - enable_interactive_search: Some(false), - url: Some("https://localhost:9696/1/".to_owned()), - api_key: Some("test1234".to_owned()), - seed_ratio: Some("1.3".to_owned()), - tag_input_string: Some("usenet, testing".to_owned()), - priority: Some(0), - ..EditIndexerParams::default() - }; - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - RadarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_indexer_edit_body_json)) - .create_async() - .await; - app_arc.lock().await.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_radarr_indexer_event_defaults_to_previous_values() { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let edit_indexer_params = EditIndexerParams { - indexer_id: 1, - ..EditIndexerParams::default() - }; - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json.clone()), - None, - RadarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(indexer_details_json)) - .create_async() - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_radarr_indexer_event_clears_tags_when_clear_tags_is_true() { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1, 2], - "id": 1 - }); - let expected_edit_indexer_body = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [], - "id": 1 - }); - let edit_indexer_params = EditIndexerParams { - indexer_id: 1, - clear_tags: true, - ..EditIndexerParams::default() - }; - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - RadarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - RadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_edit_indexer_body)) - .create_async() - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditIndexer(edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_movie_event() { - let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - *expected_body.get_mut("monitored").unwrap() = json!(false); - *expected_body.get_mut("minimumAvailability").unwrap() = json!("announced"); - *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); - *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); - *expected_body.get_mut("tags").unwrap() = json!([1, 2]); - let edit_movie_params = EditMovieParams { - movie_id: 1, - monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), - root_folder_path: Some("/nfs/Test Path".to_owned()), - tag_input_string: Some("usenet, testing".into()), - ..EditMovieParams::default() - }; - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(MOVIE_JSON).unwrap()), - None, - RadarrEvent::GetMovieDetails(1), - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - RadarrEvent::EditMovie(edit_movie_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - app_arc.lock().await.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditMovie(edit_movie_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_movie_event_does_not_overwrite_tags_vec_if_tag_input_string_is_none() { - let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - *expected_body.get_mut("monitored").unwrap() = json!(false); - *expected_body.get_mut("minimumAvailability").unwrap() = json!("announced"); - *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); - *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); - *expected_body.get_mut("tags").unwrap() = json!([1, 2]); - let edit_movie_params = EditMovieParams { - movie_id: 1, - monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), - root_folder_path: Some("/nfs/Test Path".to_owned()), - tags: Some(vec![1, 2]), - ..EditMovieParams::default() - }; - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(MOVIE_JSON).unwrap()), - None, - RadarrEvent::GetMovieDetails(1), - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - RadarrEvent::EditMovie(edit_movie_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - app_arc.lock().await.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditMovie(edit_movie_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_movie_event_defaults_to_previous_values() { - let expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(MOVIE_JSON).unwrap()), - None, - RadarrEvent::GetMovieDetails(1), - Some("/1"), - None, - ) - .await; - let edit_movie_params = EditMovieParams { - movie_id: 1, - ..EditMovieParams::default() - }; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - RadarrEvent::EditMovie(edit_movie_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditMovie(edit_movie_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_movie_event_uses_provided_parameters_returns_empty_tags_vec_when_clear_tags_is_true( - ) { - let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - *expected_body.get_mut("tags").unwrap() = json!([]); - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(MOVIE_JSON).unwrap()), - None, - RadarrEvent::GetMovieDetails(1), - Some("/1"), - None, - ) - .await; - let edit_movie_params = EditMovieParams { - movie_id: 1, - clear_tags: true, - ..EditMovieParams::default() - }; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - RadarrEvent::EditMovie(edit_movie_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::EditMovie(edit_movie_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_download_radarr_release_event() { - let expected_body = RadarrReleaseDownloadBody { - guid: "1234".to_owned(), - indexer_id: 2, - movie_id: 1, - }; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "guid": "1234", - "indexerId": 2, - "movieId": 1 - })), - Some(json!({})), - None, - RadarrEvent::DownloadRelease(expected_body.clone()), - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_radarr_event(RadarrEvent::DownloadRelease(expected_body)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_extract_and_add_radarr_tag_ids_vec() { - let app_arc = Arc::new(Mutex::new(App::test_default())); - let tags = " test,HI ,, usenet "; - { - let mut app = app_arc.lock().await; - app.data.radarr_data.tags_map = BiMap::from_iter([ - (1, "usenet".to_owned()), - (2, "test".to_owned()), - (3, "hi".to_owned()), - ]); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert_eq!( - network.extract_and_add_radarr_tag_ids_vec(tags).await, - vec![2, 3, 1] - ); - } - - #[tokio::test] - async fn test_extract_and_add_radarr_tag_ids_vec_add_missing_tags_first() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ "label": "TESTING" })), - Some(json!({ "id": 3, "label": "testing" })), - None, - RadarrEvent::GetTags, - None, - None, - ) - .await; - let tags = "usenet, test, TESTING"; - { - let mut app = app_arc.lock().await; - app.data.radarr_data.edit_movie_modal = Some(EditMovieModal { - tags: tags.into(), - ..EditMovieModal::default() - }); - app.data.radarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let tag_ids_vec = network.extract_and_add_radarr_tag_ids_vec(tags).await; - - async_server.assert_async().await; - assert_eq!(tag_ids_vec, vec![1, 2, 3]); - assert_eq!( - app_arc.lock().await.data.radarr_data.tags_map, - BiMap::from_iter([ - (1, "usenet".to_owned()), - (2, "test".to_owned()), - (3, "testing".to_owned()) - ]) - ); - } - - #[test] - fn test_get_movie_status_downloaded() { - assert_str_eq!(get_movie_status(true, &[], 0), "Downloaded"); - } - - #[test] - fn test_get_movie_status_missing() { - let download_record = DownloadRecord { - movie_id: 1, - ..DownloadRecord::default() - }; - - assert_str_eq!( - get_movie_status(false, slice::from_ref(&download_record), 0), - "Missing" - ); - - assert_str_eq!(get_movie_status(false, &[download_record], 1), "Missing"); - } - - #[test] - fn test_get_movie_status_downloading() { - assert_str_eq!( - get_movie_status( - false, - &[DownloadRecord { - movie_id: 1, - status: "downloading".to_owned(), - ..DownloadRecord::default() - }], - 1 - ), - "Downloading" - ); - } - - #[test] - fn test_get_movie_status_awaiting_import() { - assert_str_eq!( - get_movie_status( - false, - &[DownloadRecord { - movie_id: 1, - status: "completed".to_owned(), - ..DownloadRecord::default() - }], - 1 - ), - "Awaiting Import" - ); - } - - fn language() -> Language { - Language { - id: 1, - name: "English".to_owned(), - } - } - - fn genres() -> Vec { - vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()] - } - - fn rating() -> Rating { - Rating { - value: Number::from_f64(9.9).unwrap(), - } - } - - fn ratings_list() -> RatingsList { - RatingsList { - imdb: Some(rating()), - tmdb: Some(rating()), - rotten_tomatoes: Some(rating()), - } - } - - fn media_info() -> MediaInfo { - MediaInfo { - audio_bitrate: 0, - audio_channels: Number::from_f64(7.1).unwrap(), - audio_codec: Some("AAC".to_owned()), - audio_languages: Some("eng".to_owned()), - audio_stream_count: 1, - video_bit_depth: 10, - video_bitrate: 0, - video_codec: Some("x265".to_owned()), - video_fps: Number::from_f64(23.976).unwrap(), - resolution: "1920x804".to_owned(), - run_time: "2:00:00".to_owned(), - scan_type: "Progressive".to_owned(), - } - } - - fn movie_file() -> MovieFile { - MovieFile { - relative_path: "Test.mkv".to_owned(), - path: "/nfs/movies/Test.mkv".to_owned(), - date_added: DateTime::from(DateTime::parse_from_rfc3339("2022-12-30T07:37:56Z").unwrap()), - media_info: Some(media_info()), - } - } - - fn collection_movie() -> CollectionMovie { - CollectionMovie { - title: "Test".to_owned().into(), - overview: "Collection blah blah blah".to_owned(), - year: 2023, - runtime: 120, - tmdb_id: 1234, - genres: genres(), - ratings: ratings_list(), - } - } - - fn blocklist_item() -> BlocklistItem { - BlocklistItem { - id: 1, - movie_id: 1, - source_title: "z movie".to_owned(), - languages: vec![language()], - quality: quality_wrapper(), - custom_formats: Some(vec![language()]), - date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), - protocol: "usenet".to_owned(), - indexer: "DrunkenSlug (Prowlarr)".to_owned(), - message: "test message".to_owned(), - movie: blocklist_item_movie(), - } - } - - fn blocklist_item_movie() -> BlocklistItemMovie { - BlocklistItemMovie { - title: "Test".into(), - } - } - - fn collection() -> Collection { - Collection { - id: 123, - title: "Test Collection".to_owned().into(), - root_folder_path: Some("/nfs/movies".to_owned()), - search_on_add: true, - monitored: true, - minimum_availability: MinimumAvailability::Released, - overview: Some("Collection blah blah blah".to_owned()), - quality_profile_id: 2222, - movies: Some(vec![collection_movie()]), - } - } - - fn movie() -> Movie { - Movie { - id: 1, - title: "Test".to_owned().into(), - original_language: language(), - size_on_disk: 3543348019, - status: "Downloaded".to_owned(), - overview: "Blah blah blah".to_owned(), - path: "/nfs/movies".to_owned(), - studio: Some("21st Century Alex".to_owned()), - genres: genres(), - year: 2023, - monitored: true, - has_file: true, - runtime: 120, - tmdb_id: 1234, - quality_profile_id: 2222, - minimum_availability: MinimumAvailability::Announced, - certification: Some("R".to_owned()), - tags: vec![Number::from(1)], - ratings: ratings_list(), - movie_file: Some(movie_file()), - collection: Some(movie_collection()), - } - } - - fn movie_collection() -> MovieCollection { - MovieCollection { - title: Some("Test Collection".to_owned()), - } - } - - fn rejections() -> Vec { - vec![ - "Unknown quality profile".to_owned(), - "Release is already mapped".to_owned(), - ] - } - - fn quality() -> Quality { - Quality { - name: "HD - 1080p".to_owned(), - } - } - - fn quality_wrapper() -> QualityWrapper { - QualityWrapper { quality: quality() } - } - - fn release() -> RadarrRelease { - RadarrRelease { - guid: "1234".to_owned(), - protocol: "torrent".to_owned(), - age: 1, - title: HorizontallyScrollableText::from("Test Release"), - indexer: "kickass torrents".to_owned(), - indexer_id: 2, - size: 1234, - rejected: true, - rejections: Some(rejections()), - seeders: Some(Number::from(2)), - leechers: Some(Number::from(1)), - languages: Some(vec![language()]), - quality: quality_wrapper(), - } - } - - fn add_movie_search_result() -> AddMovieSearchResult { - AddMovieSearchResult { - tmdb_id: 1234, - title: HorizontallyScrollableText::from("Test"), - original_language: language(), - status: "released".to_owned(), - overview: "New movie blah blah blah".to_owned(), - genres: genres(), - year: 2023, - runtime: 120, - ratings: ratings_list(), - } - } - - fn movie_history_item() -> MovieHistoryItem { - MovieHistoryItem { - source_title: HorizontallyScrollableText::from("Test"), - quality: quality_wrapper(), - languages: vec![language()], - date: DateTime::from(DateTime::parse_from_rfc3339("2022-12-30T07:37:56Z").unwrap()), - event_type: "grabbed".to_owned(), - } - } - - fn download_record() -> DownloadRecord { - DownloadRecord { - title: "Test Download Title".to_owned(), - status: "downloading".to_owned(), - id: 1, - movie_id: 1, - size: 3543348019, - sizeleft: 1771674009, - output_path: Some(HorizontallyScrollableText::from("/nfs/movies/Test")), - indexer: "kickass torrents".to_owned(), - download_client: "transmission".to_owned(), - } - } - - fn downloads_response() -> DownloadsResponse { - DownloadsResponse { - records: vec![download_record()], - } - } - - fn root_folder() -> RootFolder { - RootFolder { - id: 1, - path: "/nfs".to_owned(), - accessible: true, - free_space: 219902325555200, - unmapped_folders: None, - } - } - - fn cast_credit() -> Credit { - Credit { - person_name: "Madison Clarke".to_owned(), - character: Some("Johnny Blaze".to_owned()), - department: None, - job: None, - credit_type: CreditType::Cast, - } - } - - fn crew_credit() -> Credit { - Credit { - person_name: "Alex Clarke".to_owned(), - character: None, - department: Some("Music".to_owned()), - job: Some("Composition".to_owned()), - credit_type: CreditType::Crew, - } - } - - fn indexer() -> Indexer { - Indexer { - enable_rss: true, - enable_automatic_search: true, - enable_interactive_search: true, - supports_rss: true, - supports_search: true, - protocol: "torrent".to_owned(), - priority: 25, - download_client_id: 0, - name: Some("Test Indexer".to_owned()), - implementation_name: Some("Torznab".to_owned()), - implementation: Some("Torznab".to_owned()), - config_contract: Some("TorznabSettings".to_owned()), - tags: vec![Number::from(1)], - id: 1, - fields: Some(vec![ - IndexerField { - name: Some("baseUrl".to_owned()), - value: Some(json!("https://test.com")), - }, - IndexerField { - name: Some("apiKey".to_owned()), - value: Some(json!("")), - }, - IndexerField { - name: Some("seedCriteria.seedRatio".to_owned()), - value: Some(json!("1.2")), - }, - ]), - } - } - - fn indexer_settings() -> IndexerSettings { - IndexerSettings { - rss_sync_interval: 60, - allow_hardcoded_subs: true, - id: 1, - ..IndexerSettings::default() - } - } -} diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs deleted file mode 100644 index 8088c30..0000000 --- a/src/network/sonarr_network.rs +++ /dev/null @@ -1,2419 +0,0 @@ -use anyhow::Result; -use indoc::formatdoc; -use log::{debug, info, warn}; -use serde_json::{json, Number, Value}; -use urlencoding::encode; - -use super::{Network, NetworkEvent, NetworkResource}; -use crate::models::servarr_models::IndexerTestResult; -use crate::models::sonarr_models::{DownloadStatus, MonitorEpisodeBody}; -use crate::{ - models::{ - servarr_data::{ - modals::IndexerTestResultModalItem, - sonarr::{ - modals::{EpisodeDetailsModal, SeasonDetailsModal}, - sonarr_data::ActiveSonarrBlock, - }, - }, - servarr_models::{ - AddRootFolderBody, CommandBody, DiskSpace, EditIndexerParams, HostConfig, Indexer, Language, - LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, - }, - sonarr_models::{ - AddSeriesBody, AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DeleteSeriesParams, - DownloadRecord, DownloadsResponse, EditSeriesParams, Episode, EpisodeFile, IndexerSettings, - Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, - SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, - }, - stateful_table::StatefulTable, - HorizontallyScrollableText, Route, Scrollable, ScrollableText, - }, - network::RequestMethod, - utils::convert_to_gb, -}; -#[cfg(test)] -#[path = "sonarr_network_tests.rs"] -mod sonarr_network_tests; - -#[derive(Debug, Eq, PartialEq, Clone)] -pub enum SonarrEvent { - AddRootFolder(AddRootFolderBody), - AddSeries(AddSeriesBody), - AddTag(String), - ClearBlocklist, - DeleteBlocklistItem(i64), - DeleteDownload(i64), - DeleteEpisodeFile(i64), - DeleteIndexer(i64), - DeleteRootFolder(i64), - DeleteSeries(DeleteSeriesParams), - DeleteTag(i64), - DownloadRelease(SonarrReleaseDownloadBody), - EditAllIndexerSettings(IndexerSettings), - EditIndexer(EditIndexerParams), - EditSeries(EditSeriesParams), - GetAllIndexerSettings, - GetBlocklist, - GetDownloads(u64), - GetHistory(u64), - GetHostConfig, - GetIndexers, - GetEpisodeDetails(i64), - GetEpisodes(i64), - GetEpisodeFiles(i64), - GetEpisodeHistory(i64), - GetLanguageProfiles, - GetLogs(u64), - GetDiskSpace, - GetQualityProfiles, - GetQueuedEvents, - GetRootFolders, - GetEpisodeReleases(i64), - GetSeasonHistory((i64, i64)), - GetSeasonReleases((i64, i64)), - GetSecurityConfig, - GetSeriesDetails(i64), - GetSeriesHistory(i64), - GetStatus, - GetUpdates, - GetTags, - GetTasks, - HealthCheck, - ListSeries, - MarkHistoryItemAsFailed(i64), - SearchNewSeries(String), - StartTask(SonarrTaskName), - TestIndexer(i64), - TestAllIndexers, - ToggleSeasonMonitoring((i64, i64)), - ToggleSeriesMonitoring(i64), - ToggleEpisodeMonitoring(i64), - TriggerAutomaticEpisodeSearch(i64), - TriggerAutomaticSeasonSearch((i64, i64)), - TriggerAutomaticSeriesSearch(i64), - UpdateAllSeries, - UpdateAndScanSeries(i64), - UpdateDownloads, -} - -impl NetworkResource for SonarrEvent { - fn resource(&self) -> &'static str { - match &self { - SonarrEvent::AddTag(_) | SonarrEvent::DeleteTag(_) | SonarrEvent::GetTags => "/tag", - SonarrEvent::ClearBlocklist => "/blocklist/bulk", - SonarrEvent::DownloadRelease(_) => "/release", - SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", - SonarrEvent::GetAllIndexerSettings | SonarrEvent::EditAllIndexerSettings(_) => { - "/config/indexer" - } - SonarrEvent::GetEpisodeFiles(_) | SonarrEvent::DeleteEpisodeFile(_) => "/episodefile", - SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", - SonarrEvent::GetDownloads(_) | SonarrEvent::DeleteDownload(_) => "/queue", - SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", - SonarrEvent::GetHistory(_) | SonarrEvent::GetEpisodeHistory(_) => "/history", - SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", - SonarrEvent::GetIndexers | SonarrEvent::DeleteIndexer(_) | SonarrEvent::EditIndexer(_) => { - "/indexer" - } - SonarrEvent::GetLanguageProfiles => "/language", - SonarrEvent::GetLogs(_) => "/log", - SonarrEvent::GetDiskSpace => "/diskspace", - SonarrEvent::GetQualityProfiles => "/qualityprofile", - SonarrEvent::GetQueuedEvents - | SonarrEvent::StartTask(_) - | SonarrEvent::TriggerAutomaticSeriesSearch(_) - | SonarrEvent::TriggerAutomaticSeasonSearch(_) - | SonarrEvent::TriggerAutomaticEpisodeSearch(_) - | SonarrEvent::UpdateAllSeries - | SonarrEvent::UpdateAndScanSeries(_) - | SonarrEvent::UpdateDownloads => "/command", - SonarrEvent::GetRootFolders - | SonarrEvent::DeleteRootFolder(_) - | SonarrEvent::AddRootFolder(_) => "/rootfolder", - SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", - SonarrEvent::GetSeriesHistory(_) | SonarrEvent::GetSeasonHistory(_) => "/history/series", - SonarrEvent::GetStatus => "/system/status", - SonarrEvent::GetTasks => "/system/task", - SonarrEvent::GetUpdates => "/update", - SonarrEvent::HealthCheck => "/health", - SonarrEvent::AddSeries(_) - | SonarrEvent::ListSeries - | SonarrEvent::GetSeriesDetails(_) - | SonarrEvent::DeleteSeries(_) - | SonarrEvent::EditSeries(_) - | SonarrEvent::ToggleSeasonMonitoring(_) - | SonarrEvent::ToggleSeriesMonitoring(_) => "/series", - SonarrEvent::SearchNewSeries(_) => "/series/lookup", - SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", - SonarrEvent::TestIndexer(_) => "/indexer/test", - SonarrEvent::TestAllIndexers => "/indexer/testall", - SonarrEvent::ToggleEpisodeMonitoring(_) => "/episode/monitor", - } - } -} - -impl From for NetworkEvent { - fn from(sonarr_event: SonarrEvent) -> Self { - NetworkEvent::Sonarr(sonarr_event) - } -} - -impl Network<'_, '_> { - pub async fn handle_sonarr_event( - &mut self, - sonarr_event: SonarrEvent, - ) -> Result { - match sonarr_event { - SonarrEvent::AddRootFolder(path) => self - .add_sonarr_root_folder(path) - .await - .map(SonarrSerdeable::from), - SonarrEvent::AddSeries(body) => self - .add_sonarr_series(body) - .await - .map(SonarrSerdeable::from), - SonarrEvent::AddTag(tag) => self.add_sonarr_tag(tag).await.map(SonarrSerdeable::from), - SonarrEvent::ClearBlocklist => self - .clear_sonarr_blocklist() - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetAllIndexerSettings => self - .get_all_sonarr_indexer_settings() - .await - .map(SonarrSerdeable::from), - SonarrEvent::DeleteBlocklistItem(blocklist_item_id) => self - .delete_sonarr_blocklist_item(blocklist_item_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::DeleteDownload(download_id) => self - .delete_sonarr_download(download_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::DeleteEpisodeFile(episode_file_id) => self - .delete_sonarr_episode_file(episode_file_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::DeleteIndexer(indexer_id) => self - .delete_sonarr_indexer(indexer_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::DeleteRootFolder(root_folder_id) => self - .delete_sonarr_root_folder(root_folder_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::DeleteSeries(params) => { - self.delete_series(params).await.map(SonarrSerdeable::from) - } - SonarrEvent::DeleteTag(tag_id) => self - .delete_sonarr_tag(tag_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::DownloadRelease(sonarr_release_download_body) => self - .download_sonarr_release(sonarr_release_download_body) - .await - .map(SonarrSerdeable::from), - SonarrEvent::EditAllIndexerSettings(params) => self - .edit_all_sonarr_indexer_settings(params) - .await - .map(SonarrSerdeable::from), - SonarrEvent::EditIndexer(params) => self - .edit_sonarr_indexer(params) - .await - .map(SonarrSerdeable::from), - SonarrEvent::EditSeries(params) => self - .edit_sonarr_series(params) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), - SonarrEvent::GetDownloads(count) => self - .get_sonarr_downloads(count) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetEpisodes(series_id) => self - .get_episodes(series_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetEpisodeFiles(series_id) => self - .get_episode_files(series_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetEpisodeDetails(episode_id) => self - .get_episode_details(episode_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetEpisodeHistory(episode_id) => self - .get_sonarr_episode_history(episode_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetHistory(events) => self - .get_sonarr_history(events) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetHostConfig => self - .get_sonarr_host_config() - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetIndexers => self.get_sonarr_indexers().await.map(SonarrSerdeable::from), - SonarrEvent::GetLanguageProfiles => self - .get_sonarr_language_profiles() - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetLogs(events) => self - .get_sonarr_logs(events) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetDiskSpace => self.get_sonarr_diskspace().await.map(SonarrSerdeable::from), - SonarrEvent::GetQualityProfiles => self - .get_sonarr_quality_profiles() - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetQueuedEvents => self - .get_queued_sonarr_events() - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetRootFolders => self - .get_sonarr_root_folders() - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetEpisodeReleases(params) => self - .get_episode_releases(params) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetSeasonHistory(params) => self - .get_sonarr_season_history(params) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetSeasonReleases(params) => self - .get_season_releases(params) - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetSecurityConfig => self - .get_sonarr_security_config() - .await - .map(SonarrSerdeable::from), - SonarrEvent::GetSeriesDetails(series_id) => self - .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::GetTags => self.get_sonarr_tags().await.map(SonarrSerdeable::from), - SonarrEvent::GetTasks => self.get_sonarr_tasks().await.map(SonarrSerdeable::from), - SonarrEvent::GetUpdates => self.get_sonarr_updates().await.map(SonarrSerdeable::from), - SonarrEvent::HealthCheck => self - .get_sonarr_healthcheck() - .await - .map(SonarrSerdeable::from), - SonarrEvent::ListSeries => self.list_series().await.map(SonarrSerdeable::from), - SonarrEvent::MarkHistoryItemAsFailed(history_item_id) => self - .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 - .map(SonarrSerdeable::from), - SonarrEvent::TestIndexer(indexer_id) => self - .test_sonarr_indexer(indexer_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::TestAllIndexers => self - .test_all_sonarr_indexers() - .await - .map(SonarrSerdeable::from), - SonarrEvent::ToggleEpisodeMonitoring(episode_id) => self - .toggle_sonarr_episode_monitoring(episode_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::ToggleSeasonMonitoring(params) => self - .toggle_sonarr_season_monitoring(params) - .await - .map(SonarrSerdeable::from), - SonarrEvent::ToggleSeriesMonitoring(series_id) => self - .toggle_sonarr_series_monitoring(series_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::TriggerAutomaticSeasonSearch(params) => self - .trigger_automatic_season_search(params) - .await - .map(SonarrSerdeable::from), - SonarrEvent::TriggerAutomaticSeriesSearch(series_id) => self - .trigger_automatic_series_search(series_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::TriggerAutomaticEpisodeSearch(episode_id) => self - .trigger_automatic_episode_search(episode_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::UpdateAllSeries => self.update_all_series().await.map(SonarrSerdeable::from), - SonarrEvent::UpdateAndScanSeries(series_id) => self - .update_and_scan_series(series_id) - .await - .map(SonarrSerdeable::from), - SonarrEvent::UpdateDownloads => self - .update_sonarr_downloads() - .await - .map(SonarrSerdeable::from), - } - } - - async fn add_sonarr_root_folder( - &mut self, - add_root_folder_body: AddRootFolderBody, - ) -> Result { - info!("Adding new root folder to Sonarr"); - let event = SonarrEvent::AddRootFolder(AddRootFolderBody::default()); - - debug!("Add root folder body: {add_root_folder_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Post, - Some(add_root_folder_body), - None, - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn add_sonarr_series(&mut self, mut add_series_body: AddSeriesBody) -> Result { - info!("Adding new series to Sonarr"); - let event = SonarrEvent::AddSeries(AddSeriesBody::default()); - if let Some(tag_input_str) = add_series_body.tag_input_string.as_ref() { - let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tag_input_str).await; - add_series_body.tags = tag_ids_vec; - } - - debug!("Add series body: {add_series_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Post, - Some(add_series_body), - None, - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn add_sonarr_tag(&mut self, tag: String) -> Result { - info!("Adding a new Sonarr tag"); - let event = SonarrEvent::AddTag(String::new()); - - let request_props = self - .request_props_from( - event, - RequestMethod::Post, - Some(json!({ "label": tag })), - None, - None, - ) - .await; - - self - .handle_request::(request_props, |tag, mut app| { - app.data.sonarr_data.tags_map.insert(tag.id, tag.label); - }) - .await - } - - async fn clear_sonarr_blocklist(&mut self) -> Result<()> { - info!("Clearing Sonarr blocklist"); - let event = SonarrEvent::ClearBlocklist; - - let ids = self - .app - .lock() - .await - .data - .sonarr_data - .blocklist - .items - .iter() - .map(|item| item.id) - .collect::>(); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - Some(json!({"ids": ids})), - None, - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn delete_sonarr_blocklist_item(&mut self, blocklist_item_id: i64) -> Result<()> { - let event = SonarrEvent::DeleteBlocklistItem(blocklist_item_id); - info!("Deleting Sonarr blocklist item for item with id: {blocklist_item_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{blocklist_item_id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_sonarr_episode_file(&mut self, episode_file_id: i64) -> Result<()> { - let event = SonarrEvent::DeleteEpisodeFile(episode_file_id); - info!("Deleting Sonarr episode file for episode file with id: {episode_file_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{episode_file_id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_sonarr_download(&mut self, download_id: i64) -> Result<()> { - let event = SonarrEvent::DeleteDownload(download_id); - info!("Deleting Sonarr download for download with id: {download_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{download_id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_sonarr_indexer(&mut self, indexer_id: i64) -> Result<()> { - let event = SonarrEvent::DeleteIndexer(indexer_id); - info!("Deleting Sonarr indexer for indexer with id: {indexer_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{indexer_id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_sonarr_root_folder(&mut self, root_folder_id: i64) -> Result<()> { - let event = SonarrEvent::DeleteRootFolder(root_folder_id); - info!("Deleting Sonarr root folder for folder with id: {root_folder_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{root_folder_id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_series(&mut self, delete_series_params: DeleteSeriesParams) -> Result<()> { - let event = SonarrEvent::DeleteSeries(DeleteSeriesParams::default()); - let DeleteSeriesParams { - id, - delete_series_files, - add_list_exclusion, - } = delete_series_params; - - info!("Deleting Sonarr series with ID: {id} with deleteFiles={delete_series_files} and addImportExclusion={add_list_exclusion}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{id}")), - Some(format!( - "deleteFiles={delete_series_files}&addImportExclusion={add_list_exclusion}" - )), - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn delete_sonarr_tag(&mut self, id: i64) -> Result<()> { - info!("Deleting Sonarr tag with id: {id}"); - let event = SonarrEvent::DeleteTag(id); - - let request_props = self - .request_props_from( - event, - RequestMethod::Delete, - None::<()>, - Some(format!("/{id}")), - None, - ) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn download_sonarr_release( - &mut self, - sonarr_release_download_body: SonarrReleaseDownloadBody, - ) -> Result { - let event = SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody::default()); - info!("Downloading Sonarr release with params: {sonarr_release_download_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Post, - Some(sonarr_release_download_body), - None, - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn edit_all_sonarr_indexer_settings(&mut self, params: IndexerSettings) -> Result { - info!("Updating Sonarr indexer settings"); - let event = SonarrEvent::EditAllIndexerSettings(IndexerSettings::default()); - debug!("Indexer settings body: {params:?}"); - - let request_props = self - .request_props_from(event, RequestMethod::Put, Some(params), None, None) - .await; - - self - .handle_request::(request_props, |_, _| {}) - .await - } - - async fn edit_sonarr_indexer( - &mut self, - mut edit_indexer_params: EditIndexerParams, - ) -> Result<()> { - if let Some(tag_input_str) = edit_indexer_params.tag_input_string.as_ref() { - let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tag_input_str).await; - edit_indexer_params.tags = Some(tag_ids_vec); - } - let detail_event = SonarrEvent::GetIndexers; - let event = SonarrEvent::EditIndexer(EditIndexerParams::default()); - let id = edit_indexer_params.indexer_id; - info!("Updating Sonarr indexer with ID: {id}"); - info!("Fetching indexer details for indexer with ID: {id}"); - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{id}")), - None, - ) - .await; - - let mut response = String::new(); - - self - .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { - response = detailed_indexer_body.to_string() - }) - .await?; - - info!("Constructing edit indexer body"); - - let mut detailed_indexer_body: Value = serde_json::from_str(&response)?; - - let ( - name, - enable_rss, - enable_automatic_search, - enable_interactive_search, - url, - api_key, - seed_ratio, - tags, - priority, - ) = { - let priority = detailed_indexer_body["priority"] - .as_i64() - .expect("Unable to deserialize 'priority'"); - let seed_ratio_field_option = detailed_indexer_body["fields"] - .as_array() - .unwrap() - .iter() - .find(|field| field["name"] == "seedCriteria.seedRatio"); - let name = edit_indexer_params.name.unwrap_or( - detailed_indexer_body["name"] - .as_str() - .expect("Unable to deserialize 'name'") - .to_owned(), - ); - let enable_rss = edit_indexer_params.enable_rss.unwrap_or( - detailed_indexer_body["enableRss"] - .as_bool() - .expect("Unable to deserialize 'enableRss'"), - ); - let enable_automatic_search = edit_indexer_params.enable_automatic_search.unwrap_or( - detailed_indexer_body["enableAutomaticSearch"] - .as_bool() - .expect("Unable to deserialize 'enableAutomaticSearch"), - ); - let enable_interactive_search = edit_indexer_params.enable_interactive_search.unwrap_or( - detailed_indexer_body["enableInteractiveSearch"] - .as_bool() - .expect("Unable to deserialize 'enableInteractiveSearch'"), - ); - let url = edit_indexer_params.url.unwrap_or( - detailed_indexer_body["fields"] - .as_array() - .expect("Unable to deserialize 'fields'") - .iter() - .find(|field| field["name"] == "baseUrl") - .expect("Field 'baseUrl' was not found in the 'fields' array") - .get("value") - .unwrap_or(&json!("")) - .as_str() - .expect("Unable to deserialize 'baseUrl value'") - .to_owned(), - ); - let api_key = edit_indexer_params.api_key.unwrap_or( - detailed_indexer_body["fields"] - .as_array() - .expect("Unable to deserialize 'fields'") - .iter() - .find(|field| field["name"] == "apiKey") - .expect("Field 'apiKey' was not found in the 'fields' array") - .get("value") - .unwrap_or(&json!("")) - .as_str() - .expect("Unable to deserialize 'apiKey value'") - .to_owned(), - ); - let seed_ratio = edit_indexer_params.seed_ratio.unwrap_or_else(|| { - if let Some(seed_ratio_field) = seed_ratio_field_option { - return seed_ratio_field - .get("value") - .unwrap_or(&json!("")) - .as_str() - .expect("Unable to deserialize 'seedCriteria.seedRatio value'") - .to_owned(); - } - - String::new() - }); - let tags = if edit_indexer_params.clear_tags { - vec![] - } else { - edit_indexer_params.tags.unwrap_or( - detailed_indexer_body["tags"] - .as_array() - .expect("Unable to deserialize 'tags'") - .iter() - .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) - .collect(), - ) - }; - let priority = edit_indexer_params.priority.unwrap_or(priority); - - ( - name, - enable_rss, - enable_automatic_search, - enable_interactive_search, - url, - api_key, - seed_ratio, - tags, - priority, - ) - }; - - *detailed_indexer_body.get_mut("name").unwrap() = json!(name); - *detailed_indexer_body.get_mut("priority").unwrap() = json!(priority); - *detailed_indexer_body.get_mut("enableRss").unwrap() = json!(enable_rss); - *detailed_indexer_body - .get_mut("enableAutomaticSearch") - .unwrap() = json!(enable_automatic_search); - *detailed_indexer_body - .get_mut("enableInteractiveSearch") - .unwrap() = json!(enable_interactive_search); - *detailed_indexer_body - .get_mut("fields") - .unwrap() - .as_array_mut() - .unwrap() - .iter_mut() - .find(|field| field["name"] == "baseUrl") - .unwrap() - .get_mut("value") - .unwrap() = json!(url); - *detailed_indexer_body - .get_mut("fields") - .unwrap() - .as_array_mut() - .unwrap() - .iter_mut() - .find(|field| field["name"] == "apiKey") - .unwrap() - .get_mut("value") - .unwrap() = json!(api_key); - *detailed_indexer_body.get_mut("tags").unwrap() = json!(tags); - let seed_ratio_field_option = detailed_indexer_body - .get_mut("fields") - .unwrap() - .as_array_mut() - .unwrap() - .iter_mut() - .find(|field| field["name"] == "seedCriteria.seedRatio"); - if let Some(seed_ratio_field) = seed_ratio_field_option { - seed_ratio_field - .as_object_mut() - .unwrap() - .insert("value".to_string(), json!(seed_ratio)); - } - - debug!("Edit indexer body: {detailed_indexer_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Put, - Some(detailed_indexer_body), - Some(format!("/{id}")), - Some("forceSave=true".to_owned()), - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn edit_sonarr_series(&mut self, mut edit_series_params: EditSeriesParams) -> Result<()> { - info!("Editing Sonarr series"); - if let Some(tag_input_str) = edit_series_params.tag_input_string.as_ref() { - let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tag_input_str).await; - edit_series_params.tags = Some(tag_ids_vec); - } - let series_id = edit_series_params.series_id; - let detail_event = SonarrEvent::GetSeriesDetails(series_id); - let event = SonarrEvent::EditSeries(EditSeriesParams::default()); - info!("Fetching series details for series with ID: {series_id}"); - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{series_id}")), - None, - ) - .await; - - let mut response = String::new(); - - self - .handle_request::<(), Value>(request_props, |detailed_series_body, _| { - response = detailed_series_body.to_string() - }) - .await?; - - info!("Constructing edit series body"); - - let mut detailed_series_body: Value = serde_json::from_str(&response)?; - let ( - monitored, - use_season_folders, - series_type, - quality_profile_id, - language_profile_id, - root_folder_path, - tags, - ) = { - let monitored = edit_series_params.monitored.unwrap_or( - detailed_series_body["monitored"] - .as_bool() - .expect("Unable to deserialize 'monitored'"), - ); - let use_season_folders = edit_series_params.use_season_folders.unwrap_or( - detailed_series_body["seasonFolder"] - .as_bool() - .expect("Unable to deserialize 'season_folder'"), - ); - let series_type = edit_series_params - .series_type - .unwrap_or_else(|| { - serde_json::from_value(detailed_series_body["seriesType"].clone()) - .expect("Unable to deserialize 'seriesType'") - }) - .to_string(); - let quality_profile_id = edit_series_params.quality_profile_id.unwrap_or_else(|| { - detailed_series_body["qualityProfileId"] - .as_i64() - .expect("Unable to deserialize 'qualityProfileId'") - }); - let language_profile_id = edit_series_params.language_profile_id.unwrap_or_else(|| { - detailed_series_body["languageProfileId"] - .as_i64() - .expect("Unable to deserialize 'languageProfileId'") - }); - let root_folder_path = edit_series_params.root_folder_path.unwrap_or_else(|| { - detailed_series_body["path"] - .as_str() - .expect("Unable to deserialize 'path'") - .to_owned() - }); - let tags = if edit_series_params.clear_tags { - vec![] - } else { - edit_series_params.tags.unwrap_or( - detailed_series_body["tags"] - .as_array() - .expect("Unable to deserialize 'tags'") - .iter() - .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) - .collect(), - ) - }; - - ( - monitored, - use_season_folders, - series_type, - quality_profile_id, - language_profile_id, - root_folder_path, - tags, - ) - }; - - *detailed_series_body.get_mut("monitored").unwrap() = json!(monitored); - *detailed_series_body.get_mut("seasonFolder").unwrap() = json!(use_season_folders); - *detailed_series_body.get_mut("seriesType").unwrap() = json!(series_type); - *detailed_series_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); - *detailed_series_body.get_mut("languageProfileId").unwrap() = json!(language_profile_id); - *detailed_series_body.get_mut("path").unwrap() = json!(root_folder_path); - *detailed_series_body.get_mut("tags").unwrap() = json!(tags); - - debug!("Edit series body: {detailed_series_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Put, - Some(detailed_series_body), - Some(format!("/{series_id}")), - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn toggle_sonarr_season_monitoring( - &mut self, - series_id_season_number_tuple: (i64, i64), - ) -> Result<()> { - let event = SonarrEvent::ToggleSeasonMonitoring(series_id_season_number_tuple); - let (series_id, season_number) = series_id_season_number_tuple; - - let detail_event = SonarrEvent::GetSeriesDetails(series_id); - info!("Toggling season monitoring for season {season_number} in series with ID: {series_id}"); - info!("Fetching series details for series with ID: {series_id}"); - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{series_id}")), - None, - ) - .await; - - let mut response = String::new(); - - self - .handle_request::<(), Value>(request_props, |detailed_series_body, _| { - response = detailed_series_body.to_string() - }) - .await?; - - info!("Constructing toggle season monitoring body"); - - match serde_json::from_str::(&response) { - Ok(mut detailed_series_body) => { - let monitored = detailed_series_body - .get("seasons") - .unwrap() - .as_array() - .unwrap() - .iter() - .find(|season| season["seasonNumber"] == season_number) - .unwrap() - .get("monitored") - .unwrap() - .as_bool() - .unwrap(); - - *detailed_series_body - .get_mut("seasons") - .unwrap() - .as_array_mut() - .unwrap() - .iter_mut() - .find(|season| season["seasonNumber"] == season_number) - .unwrap() - .get_mut("monitored") - .unwrap() = json!(!monitored); - - debug!("Toggle season monitoring body: {detailed_series_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Put, - Some(detailed_series_body), - Some(format!("/{series_id}")), - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - Err(_) => { - warn!("Request for detailed series body was interrupted"); - Ok(()) - } - } - } - - async fn toggle_sonarr_series_monitoring(&mut self, series_id: i64) -> Result<()> { - let event = SonarrEvent::ToggleSeriesMonitoring(series_id); - - let detail_event = SonarrEvent::GetSeriesDetails(series_id); - info!("Toggling series monitoring for series with ID: {series_id}"); - info!("Fetching series details for series with ID: {series_id}"); - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{series_id}")), - None, - ) - .await; - - let mut response = String::new(); - - self - .handle_request::<(), Value>(request_props, |detailed_series_body, _| { - response = detailed_series_body.to_string() - }) - .await?; - - info!("Constructing toggle series monitoring body"); - - match serde_json::from_str::(&response) { - Ok(mut detailed_series_body) => { - let monitored = detailed_series_body - .get("monitored") - .unwrap() - .as_bool() - .unwrap(); - - *detailed_series_body.get_mut("monitored").unwrap() = json!(!monitored); - - debug!("Toggle series monitoring body: {detailed_series_body:?}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Put, - Some(detailed_series_body), - Some(format!("/{series_id}")), - None, - ) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - Err(_) => { - warn!("Request for detailed series body was interrupted"); - Ok(()) - } - } - } - - async fn get_all_sonarr_indexer_settings(&mut self) -> Result { - info!("Fetching Sonarr indexer settings"); - let event = SonarrEvent::GetAllIndexerSettings; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), IndexerSettings>(request_props, |indexer_settings, mut app| { - if app.data.sonarr_data.indexer_settings.is_none() { - app.data.sonarr_data.indexer_settings = Some(indexer_settings); - } else { - debug!("Indexer Settings are being modified. Ignoring update..."); - } - }) - .await - } - - async fn get_sonarr_healthcheck(&mut self) -> Result<()> { - info!("Performing Sonarr health check"); - let event = SonarrEvent::HealthCheck; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn get_sonarr_blocklist(&mut self) -> Result { - info!("Fetching Sonarr blocklist"); - let event = SonarrEvent::GetBlocklist; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| { - if !matches!( - app.get_current_route(), - Route::Sonarr(ActiveSonarrBlock::BlocklistSortPrompt, _) - ) { - let mut blocklist_vec: Vec = blocklist_resp - .records - .into_iter() - .map(|item| { - if let Some(series) = app - .data - .sonarr_data - .series - .items - .iter() - .find(|it| it.id == item.series_id) - { - BlocklistItem { - series_title: Some(series.title.text.clone()), - ..item - } - } else { - item - } - }) - .collect(); - blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); - app.data.sonarr_data.blocklist.set_items(blocklist_vec); - app.data.sonarr_data.blocklist.apply_sorting_toggle(false); - } - }) - .await - } - - async fn get_sonarr_downloads(&mut self, count: u64) -> Result { - info!("Fetching Sonarr downloads"); - let event = SonarrEvent::GetDownloads(count); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("pageSize={count}")), - ) - .await; - - self - .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { - app - .data - .sonarr_data - .downloads - .set_items(queue_response.records); - }) - .await - } - - async fn get_episodes(&mut self, series_id: i64) -> Result> { - let event = SonarrEvent::GetEpisodes(series_id); - info!("Fetching episodes for Sonarr series with ID: {series_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("seriesId={series_id}")), - ) - .await; - - self - .handle_request::<(), Vec>(request_props, |mut episode_vec, mut app| { - episode_vec.sort_by(|a, b| a.id.cmp(&b.id)); - if !matches!( - app.get_current_route(), - Route::Sonarr(ActiveSonarrBlock::EpisodesSortPrompt, _) - ) { - if app.data.sonarr_data.season_details_modal.is_none() { - app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); - } - - let season_episodes_vec = if !app.data.sonarr_data.seasons.is_empty() { - let season_number = app - .data - .sonarr_data - .seasons - .current_selection() - .season_number; - - episode_vec - .into_iter() - .filter(|episode| episode.season_number == season_number) - .collect() - } else { - episode_vec - }; - - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episodes - .set_items(season_episodes_vec); - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episodes - .apply_sorting_toggle(false); - } - }) - .await - } - - async fn get_episode_files(&mut self, series_id: i64) -> Result> { - let event = SonarrEvent::GetEpisodeFiles(series_id); - info!("Fetching episodes files for Sonarr series with ID: {series_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("seriesId={series_id}")), - ) - .await; - - self - .handle_request::<(), Vec>(request_props, |episode_file_vec, mut app| { - if app.data.sonarr_data.season_details_modal.is_none() { - app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); - } - - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_files - .set_items(episode_file_vec); - }) - .await - } - - async fn get_sonarr_episode_history(&mut self, episode_id: i64) -> Result { - info!("Fetching Sonarr history for episode with ID: {episode_id}"); - let event = SonarrEvent::GetEpisodeHistory(episode_id); - - let params = - format!("episodeId={episode_id}&pageSize=1000&sortDirection=descending&sortKey=date"); - 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 app.data.sonarr_data.season_details_modal.is_none() { - app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); - } - - if app - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .is_none() - { - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal = Some(EpisodeDetailsModal::default()); - } - - let mut history_vec = history_response.records; - history_vec.sort_by(|a, b| a.id.cmp(&b.id)); - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal - .as_mut() - .unwrap() - .episode_history - .set_items(history_vec); - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal - .as_mut() - .unwrap() - .episode_history - .apply_sorting_toggle(false); - }) - .await - } - - async fn get_episode_details(&mut self, episode_id: i64) -> Result { - info!("Fetching Sonarr episode details"); - let event = SonarrEvent::GetEpisodeDetails(episode_id); - - info!("Fetching episode details for episode with ID: {episode_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - Some(format!("/{episode_id}")), - None, - ) - .await; - - self - .handle_request::<(), Episode>(request_props, |episode_response, mut app| { - if app.cli_mode { - app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); - } - - if app - .data - .sonarr_data - .season_details_modal - .as_mut() - .expect("Season details modal is empty") - .episode_details_modal - .is_none() - { - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal = Some(EpisodeDetailsModal::default()); - } - - let Episode { - id, - title, - air_date_utc, - overview, - has_file, - season_number, - episode_number, - episode_file, - .. - } = episode_response; - let status = get_episode_status(has_file, &app.data.sonarr_data.downloads.items, id); - let air_date = if let Some(air_date) = air_date_utc { - format!("{air_date}") - } else { - String::new() - }; - let episode_details_modal = app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal - .as_mut() - .unwrap(); - episode_details_modal.episode_details = ScrollableText::with_string(formatdoc!( - " - Title: {} - Season: {season_number} - Episode Number: {episode_number} - Air Date: {air_date} - Status: {status} - Description: {}", - title, - overview.unwrap_or_default(), - )); - if let Some(file) = episode_file { - let size = convert_to_gb(file.size); - episode_details_modal.file_details = formatdoc!( - " - Relative Path: {} - Absolute Path: {} - Size: {size:.2} GB - Language: {} - Date Added: {}", - file.relative_path, - file.path, - file.languages.first().unwrap_or(&Language::default()).name, - file.date_added, - ); - - if let Some(media_info) = file.media_info { - episode_details_modal.audio_details = formatdoc!( - " - Bitrate: {} - Channels: {:.1} - Codec: {} - Languages: {} - Stream Count: {}", - media_info.audio_bitrate, - media_info.audio_channels.as_f64().unwrap(), - media_info.audio_codec.unwrap_or_default(), - media_info.audio_languages.unwrap_or_default(), - media_info.audio_stream_count - ); - - episode_details_modal.video_details = formatdoc!( - " - Bit Depth: {} - Bitrate: {} - Codec: {} - FPS: {} - Resolution: {} - Scan Type: {} - Runtime: {} - Subtitles: {}", - media_info.video_bit_depth, - media_info.video_bitrate, - media_info.video_codec.unwrap_or_default(), - media_info.video_fps.as_f64().unwrap(), - media_info.resolution, - media_info.scan_type, - media_info.run_time, - media_info.subtitles.unwrap_or_default() - ); - } - }; - }) - .await - } - - async fn get_sonarr_host_config(&mut self) -> Result { - info!("Fetching Sonarr host config"); - let event = SonarrEvent::GetHostConfig; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), HostConfig>(request_props, |_, _| ()) - .await - } - - async fn get_sonarr_history(&mut self, events: u64) -> Result { - info!("Fetching all Sonarr history events"); - let event = SonarrEvent::GetHistory(events); - - let params = format!("pageSize={events}&sortDirection=descending&sortKey=date"); - 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; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |indexers, mut app| { - app.data.sonarr_data.indexers.set_items(indexers); - }) - .await - } - - async fn get_sonarr_language_profiles(&mut self) -> Result> { - info!("Fetching Sonarr language profiles"); - let event = SonarrEvent::GetLanguageProfiles; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |language_profiles_vec, mut app| { - app.data.sonarr_data.language_profiles_map = language_profiles_vec - .into_iter() - .map(|language| (language.id, language.name)) - .collect(); - }) - .await - } - - async fn get_sonarr_logs(&mut self, events: u64) -> Result { - info!("Fetching Sonarr logs"); - let event = SonarrEvent::GetLogs(events); - - let params = format!("pageSize={events}&sortDirection=descending&sortKey=time"); - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) - .await; - - self - .handle_request::<(), LogResponse>(request_props, |log_response, mut app| { - let mut logs = log_response.records; - logs.reverse(); - - let log_lines = logs - .into_iter() - .map(|log| { - if log.exception.is_some() { - HorizontallyScrollableText::from(format!( - "{}|{}|{}|{}|{}", - log.time, - log.level.to_uppercase(), - log.logger.as_ref().unwrap(), - log.exception_type.as_ref().unwrap(), - log.exception.as_ref().unwrap() - )) - } else { - HorizontallyScrollableText::from(format!( - "{}|{}|{}|{}", - log.time, - log.level.to_uppercase(), - log.logger.as_ref().unwrap(), - log.message.as_ref().unwrap() - )) - } - }) - .collect(); - - app.data.sonarr_data.logs.set_items(log_lines); - app.data.sonarr_data.logs.scroll_to_bottom(); - }) - .await - } - - async fn get_sonarr_diskspace(&mut self) -> Result> { - info!("Fetching Sonarr disk space"); - let event = SonarrEvent::GetDiskSpace; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { - app.data.sonarr_data.disk_space_vec = disk_space_vec; - }) - .await - } - - async fn get_sonarr_quality_profiles(&mut self) -> Result> { - info!("Fetching Sonarr quality profiles"); - let event = SonarrEvent::GetQualityProfiles; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { - app.data.sonarr_data.quality_profile_map = quality_profiles - .into_iter() - .map(|profile| (profile.id, profile.name)) - .collect(); - }) - .await - } - - async fn get_queued_sonarr_events(&mut self) -> Result> { - info!("Fetching Sonarr queued events"); - let event = SonarrEvent::GetQueuedEvents; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |queued_events_vec, mut app| { - app - .data - .sonarr_data - .queued_events - .set_items(queued_events_vec); - }) - .await - } - - async fn get_sonarr_root_folders(&mut self) -> Result> { - info!("Fetching Sonarr root folders"); - let event = SonarrEvent::GetRootFolders; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |root_folders, mut app| { - app.data.sonarr_data.root_folders.set_items(root_folders); - }) - .await - } - - async fn get_episode_releases(&mut self, episode_id: i64) -> Result> { - let event = SonarrEvent::GetEpisodeReleases(episode_id); - info!("Fetching releases for episode with ID: {episode_id}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("episodeId={episode_id}")), - ) - .await; - - self - .handle_request::<(), Vec>(request_props, |release_vec, mut app| { - if app.data.sonarr_data.season_details_modal.is_none() { - app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); - } - - if app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal - .is_none() - { - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal = Some(EpisodeDetailsModal::default()); - } - - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal - .as_mut() - .unwrap() - .episode_releases - .set_items(release_vec); - }) - .await - } - - async fn get_season_releases( - &mut self, - series_season_id_tuple: (i64, i64), - ) -> Result> { - let event = SonarrEvent::GetSeasonReleases(series_season_id_tuple); - let (series_id, season_number) = series_season_id_tuple; - info!("Fetching releases for series with ID: {series_id} and season number: {season_number}"); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("seriesId={series_id}&seasonNumber={season_number}")), - ) - .await; - - self - .handle_request::<(), Vec>(request_props, |release_vec, mut app| { - if app.data.sonarr_data.season_details_modal.is_none() { - app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); - } - - let season_releases_vec = release_vec - .into_iter() - .filter(|release| release.full_season) - .collect(); - - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .season_releases - .set_items(season_releases_vec); - }) - .await - } - - async fn get_sonarr_season_history( - &mut self, - series_season_id_tuple: (i64, i64), - ) -> Result> { - let event = SonarrEvent::GetSeasonHistory(series_season_id_tuple); - let (series_id, season_number) = series_season_id_tuple; - info!("Fetching history for series with ID: {series_id} and season number: {season_number}"); - - let params = format!("seriesId={series_id}&seasonNumber={season_number}",); - 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.sonarr_data.season_details_modal.is_none() { - app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); - } - - let mut history_vec = history_items; - history_vec.sort_by(|a, b| a.id.cmp(&b.id)); - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .season_history - .set_items(history_vec); - app - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .season_history - .apply_sorting_toggle(false); - }) - .await - } - - async fn get_sonarr_security_config(&mut self) -> Result { - info!("Fetching Sonarr security config"); - let event = SonarrEvent::GetSecurityConfig; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) - .await - } - - async fn get_series_details(&mut self, series_id: i64) -> Result { - info!("Fetching details for Sonarr series with ID: {series_id}"); - let event = SonarrEvent::GetSeriesDetails(series_id); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - Some(format!("/{series_id}")), - None, - ) - .await; - - self - .handle_request::<(), Series>(request_props, |_, _| ()) - .await - } - - async fn get_sonarr_series_history(&mut self, series_id: i64) -> Result> { - info!("Fetching Sonarr series history for series with ID: {series_id}"); - let event = SonarrEvent::GetSeriesHistory(series_id); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("seriesId={series_id}")), - ) - .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; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |mut series_vec, mut app| { - if !matches!( - app.get_current_route(), - Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, _) - ) { - series_vec.sort_by(|a, b| a.id.cmp(&b.id)); - app.data.sonarr_data.series.set_items(series_vec); - app.data.sonarr_data.series.apply_sorting_toggle(false); - } - }) - .await - } - - async fn get_sonarr_status(&mut self) -> Result { - info!("Fetching Sonarr system status"); - let event = SonarrEvent::GetStatus; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { - app.data.sonarr_data.version = system_status.version; - app.data.sonarr_data.start_time = system_status.start_time; - }) - .await - } - - async fn get_sonarr_tags(&mut self) -> Result> { - info!("Fetching Sonarr tags"); - let event = SonarrEvent::GetTags; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { - app.data.sonarr_data.tags_map = tags_vec - .into_iter() - .map(|tag| (tag.id, tag.label)) - .collect(); - }) - .await - } - - async fn get_sonarr_tasks(&mut self) -> Result> { - info!("Fetching Sonarr tasks"); - let event = SonarrEvent::GetTasks; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { - app.data.sonarr_data.tasks.set_items(tasks_vec); - }) - .await - } - - async fn get_sonarr_updates(&mut self) -> Result> { - info!("Fetching Sonarr updates"); - let event = SonarrEvent::GetUpdates; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |updates_vec, mut app| { - let latest_installed = if updates_vec - .iter() - .any(|update| update.latest && update.installed_on.is_some()) - { - "already".to_owned() - } else { - "not".to_owned() - }; - let updates = updates_vec - .into_iter() - .map(|update| { - let install_status = if update.installed_on.is_some() { - if update.installed { - "(Currently Installed)".to_owned() - } else { - "(Previously Installed)".to_owned() - } - } else { - String::new() - }; - let vec_to_bullet_points = |vec: Vec| { - vec - .iter() - .map(|change| format!(" * {change}")) - .collect::>() - .join("\n") - }; - - let mut update_info = formatdoc!( - "{} - {} {install_status} - {}", - update.version, - update.release_date, - "-".repeat(200) - ); - - if let Some(new_changes) = update.changes.new { - let changes = vec_to_bullet_points(new_changes); - update_info = formatdoc!( - "{update_info} - New: - {changes}" - ) - } - - if let Some(fixes) = update.changes.fixed { - let fixes = vec_to_bullet_points(fixes); - update_info = formatdoc!( - "{update_info} - Fixed: - {fixes}" - ); - } - - update_info - }) - .reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}")) - .unwrap(); - - app.data.sonarr_data.updates = ScrollableText::with_string(formatdoc!( - "The latest version of Sonarr is {latest_installed} installed - - {updates}" - )); - }) - .await - } - - async fn mark_sonarr_history_item_as_failed(&mut self, history_item_id: i64) -> Result { - info!("Marking the Sonarr history item with ID: {history_item_id} as 'failed'"); - let event = SonarrEvent::MarkHistoryItemAsFailed(history_item_id); - - let request_props = self - .request_props_from( - event, - RequestMethod::Post, - None, - Some(format!("/{history_item_id}")), - None, - ) - .await; - - self - .handle_request::<(), Value>(request_props, |_, _| ()) - .await - } - - async fn search_sonarr_series(&mut self, query: String) -> Result> { - info!("Searching for specific Sonarr series"); - let event = SonarrEvent::SearchNewSeries(String::new()); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("term={}", encode(&query))), - ) - .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 - } - - async fn start_sonarr_task(&mut self, task: SonarrTaskName) -> Result { - let event = SonarrEvent::StartTask(task); - let task_name = task.to_string(); - info!("Starting Sonarr task: {task_name}"); - - let body = CommandBody { name: task_name }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn test_sonarr_indexer(&mut self, indexer_id: i64) -> Result { - let detail_event = SonarrEvent::GetIndexers; - let event = SonarrEvent::TestIndexer(indexer_id); - info!("Testing Sonarr indexer with ID: {indexer_id}"); - - info!("Fetching indexer details for indexer with ID: {indexer_id}"); - - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{indexer_id}")), - None, - ) - .await; - - let mut test_body: Value = Value::default(); - - self - .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { - test_body = detailed_indexer_body; - }) - .await?; - - info!("Testing indexer"); - - let mut request_props = self - .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) - .await; - request_props.ignore_status_code = true; - - self - .handle_request::(request_props, |test_results, mut app| { - if test_results.as_object().is_none() { - app.data.sonarr_data.indexer_test_errors = Some( - test_results.as_array().unwrap()[0] - .get("errorMessage") - .unwrap() - .to_string(), - ); - } else { - app.data.sonarr_data.indexer_test_errors = Some(String::new()); - }; - }) - .await - } - - async fn test_all_sonarr_indexers(&mut self) -> Result> { - info!("Testing all Sonarr indexers"); - let event = SonarrEvent::TestAllIndexers; - - let mut request_props = self - .request_props_from(event, RequestMethod::Post, None, None, None) - .await; - request_props.ignore_status_code = true; - - self - .handle_request::<(), Vec>(request_props, |test_results, mut app| { - let mut test_all_indexer_results = StatefulTable::default(); - let indexers = app.data.sonarr_data.indexers.items.clone(); - let modal_test_results = test_results - .iter() - .map(|result| { - let name = indexers - .iter() - .filter(|&indexer| indexer.id == result.id) - .map(|indexer| indexer.name.clone()) - .nth(0) - .unwrap_or_default(); - let validation_failures = result - .validation_failures - .iter() - .map(|failure| { - format!( - "Failure for field '{}': {}", - failure.property_name, failure.error_message - ) - }) - .collect::>() - .join(", "); - - IndexerTestResultModalItem { - name: name.unwrap_or_default(), - is_valid: result.is_valid, - validation_failures: validation_failures.into(), - } - }) - .collect(); - test_all_indexer_results.set_items(modal_test_results); - app.data.sonarr_data.indexer_test_all_results = Some(test_all_indexer_results); - }) - .await - } - - async fn toggle_sonarr_episode_monitoring(&mut self, episode_id: i64) -> Result<()> { - let event = SonarrEvent::ToggleEpisodeMonitoring(episode_id); - let detail_event = SonarrEvent::GetEpisodeDetails(0); - - let monitored = { - info!("Fetching episode details for episode id: {episode_id}"); - let request_props = self - .request_props_from( - detail_event, - RequestMethod::Get, - None::<()>, - Some(format!("/{episode_id}")), - None, - ) - .await; - - let mut monitored = false; - - self - .handle_request::<(), Value>(request_props, |detailed_episode_body, _| { - monitored = detailed_episode_body - .get("monitored") - .unwrap() - .as_bool() - .unwrap(); - }) - .await?; - - monitored - }; - - info!("Toggling monitoring for episode id: {episode_id}"); - - let body = MonitorEpisodeBody { - episode_ids: vec![episode_id], - monitored: !monitored, - }; - - let request_props = self - .request_props_from(event, RequestMethod::Put, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn trigger_automatic_series_search(&mut self, series_id: i64) -> Result { - let event = SonarrEvent::TriggerAutomaticSeriesSearch(series_id); - info!("Searching indexers for series with ID: {series_id}"); - - let body = SonarrCommandBody { - name: "SeriesSearch".to_owned(), - series_id: Some(series_id), - ..SonarrCommandBody::default() - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn trigger_automatic_season_search( - &mut self, - series_season_id_tuple: (i64, i64), - ) -> Result { - let event = SonarrEvent::TriggerAutomaticSeasonSearch(series_season_id_tuple); - let (series_id, season_number) = series_season_id_tuple; - info!("Searching indexers for series with ID: {series_id} and season number: {season_number}"); - - let body = SonarrCommandBody { - name: "SeasonSearch".to_owned(), - season_number: Some(season_number), - series_id: Some(series_id), - ..SonarrCommandBody::default() - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn trigger_automatic_episode_search(&mut self, episode_id: i64) -> Result { - let event = SonarrEvent::TriggerAutomaticEpisodeSearch(episode_id); - info!("Searching indexers for episode with ID: {episode_id}"); - - let body = SonarrCommandBody { - name: "EpisodeSearch".to_owned(), - episode_ids: Some(vec![episode_id]), - ..SonarrCommandBody::default() - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn update_all_series(&mut self) -> Result { - info!("Updating all series"); - let event = SonarrEvent::UpdateAllSeries; - let body = SonarrCommandBody { - name: "RefreshSeries".to_owned(), - ..SonarrCommandBody::default() - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn update_and_scan_series(&mut self, series_id: i64) -> Result { - let event = SonarrEvent::UpdateAndScanSeries(series_id); - info!("Updating and scanning series with ID: {series_id}"); - let body = SonarrCommandBody { - name: "RefreshSeries".to_owned(), - series_id: Some(series_id), - ..SonarrCommandBody::default() - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn update_sonarr_downloads(&mut self) -> Result { - info!("Updating Sonarr downloads"); - let event = SonarrEvent::UpdateDownloads; - let body = CommandBody { - name: "RefreshMonitoredDownloads".to_owned(), - }; - - let request_props = self - .request_props_from(event, RequestMethod::Post, Some(body), None, None) - .await; - - self - .handle_request::(request_props, |_, _| ()) - .await - } - - async fn extract_and_add_sonarr_tag_ids_vec(&mut self, edit_tags: &str) -> Vec { - let missing_tags_vec = { - let tags_map = &self.app.lock().await.data.sonarr_data.tags_map; - edit_tags - .split(',') - .filter(|&tag| { - !tag.is_empty() && tags_map.get_by_right(tag.to_lowercase().trim()).is_none() - }) - .collect::>() - }; - - for tag in missing_tags_vec { - self - .add_sonarr_tag(tag.trim().to_owned()) - .await - .expect("Unable to add tag"); - } - - let app = self.app.lock().await; - edit_tags - .split(',') - .filter(|tag| !tag.is_empty()) - .map(|tag| { - *app - .data - .sonarr_data - .tags_map - .get_by_right(tag.to_lowercase().trim()) - .unwrap() - }) - .collect() - } -} - -fn get_episode_status(has_file: bool, downloads_vec: &[DownloadRecord], episode_id: i64) -> String { - if !has_file { - let default_episode_id = Number::from(-1i64); - if let Some(download) = downloads_vec.iter().find(|&download| { - download - .episode_id - .as_ref() - .unwrap_or(&default_episode_id) - .as_i64() - .unwrap() - == episode_id - }) { - if download.status == DownloadStatus::Downloading { - return "Downloading".to_owned(); - } - - if download.status == DownloadStatus::Completed { - return "Awaiting Import".to_owned(); - } - } - - return "Missing".to_owned(); - } - - "Downloaded".to_owned() -} diff --git a/src/network/sonarr_network/blocklist/mod.rs b/src/network/sonarr_network/blocklist/mod.rs new file mode 100644 index 0000000..648b853 --- /dev/null +++ b/src/network/sonarr_network/blocklist/mod.rs @@ -0,0 +1,112 @@ +use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::sonarr_models::{BlocklistItem, BlocklistResponse}; +use crate::models::Route; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; +use serde_json::{json, Value}; + +#[cfg(test)] +#[path = "sonarr_blocklist_network_tests.rs"] +mod sonarr_blocklist_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn clear_sonarr_blocklist(&mut self) -> Result<()> { + info!("Clearing Sonarr blocklist"); + let event = SonarrEvent::ClearBlocklist; + + let ids = self + .app + .lock() + .await + .data + .sonarr_data + .blocklist + .items + .iter() + .map(|item| item.id) + .collect::>(); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + Some(json!({"ids": ids})), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn delete_sonarr_blocklist_item( + &mut self, + blocklist_item_id: i64, + ) -> Result<()> { + let event = SonarrEvent::DeleteBlocklistItem(blocklist_item_id); + info!("Deleting Sonarr blocklist item for item with id: {blocklist_item_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{blocklist_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_blocklist( + &mut self, + ) -> Result { + info!("Fetching Sonarr blocklist"); + let event = SonarrEvent::GetBlocklist; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| { + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::BlocklistSortPrompt, _) + ) { + let mut blocklist_vec: Vec = blocklist_resp + .records + .into_iter() + .map(|item| { + if let Some(series) = app + .data + .sonarr_data + .series + .items + .iter() + .find(|it| it.id == item.series_id) + { + BlocklistItem { + series_title: Some(series.title.text.clone()), + ..item + } + } else { + item + } + }) + .collect(); + blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.sonarr_data.blocklist.set_items(blocklist_vec); + app.data.sonarr_data.blocklist.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/sonarr_network/blocklist/sonarr_blocklist_network_tests.rs b/src/network/sonarr_network/blocklist/sonarr_blocklist_network_tests.rs new file mode 100644 index 0000000..75ec0c8 --- /dev/null +++ b/src/network/sonarr_network/blocklist/sonarr_blocklist_network_tests.rs @@ -0,0 +1,270 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::sonarr_models::{BlocklistItem, BlocklistResponse, Series, SonarrSerdeable}; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{ + blocklist_item, series, + }; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, RequestMethod}; + use pretty_assertions::assert_eq; + use reqwest::Client; + use rstest::rstest; + use serde_json::{json, Number}; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_clear_sonarr_blocklist_event() { + let blocklist_items = vec![ + BlocklistItem { + id: 1, + ..blocklist_item() + }, + BlocklistItem { + id: 2, + ..blocklist_item() + }, + BlocklistItem { + id: 3, + ..blocklist_item() + }, + ]; + let expected_request_json = json!({ "ids": [1, 2, 3]}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + Some(expected_request_json), + None, + None, + SonarrEvent::ClearBlocklist, + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .set_items(blocklist_items); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ClearBlocklist) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_blocklist_item_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteBlocklistItem(1), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .set_items(vec![blocklist_item()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteBlocklistItem(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_sonarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { + let blocklist_json = json!({"records": [{ + "seriesId": 1007, + "episodeIds": [42020], + "sourceTitle": "z series", + "languages": [{ "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", + "languages": [{ "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 mut expected_blocklist = vec![ + BlocklistItem { + id: 123, + series_id: 1007, + series_title: Some("Z Series".into()), + source_title: "z series".into(), + episode_ids: vec![Number::from(42020)], + ..blocklist_item() + }, + BlocklistItem { + id: 456, + series_id: 2001, + source_title: "A Series".into(), + episode_ids: vec![Number::from(42018)], + ..blocklist_item() + }, + ]; + 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 + .series + .set_items(vec![Series { + id: 1007, + title: "Z Series".into(), + ..series() + }]); + app_arc.lock().await.data.sonarr_data.blocklist.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + expected_blocklist.sort_by(cmp_fn); + + 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]); + } + app_arc.lock().await.server_tabs.next(); + 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_eq!( + app_arc.lock().await.data.sonarr_data.blocklist.items, + expected_blocklist + ); + assert!(app_arc.lock().await.data.sonarr_data.blocklist.sort_asc); + assert_eq!(blocklist, response); + } + } + + #[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", + "languages": [{ "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", + "languages": [{ "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]); + app_arc.lock().await.server_tabs.next(); + 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); + } + } +} diff --git a/src/network/sonarr_network/downloads/mod.rs b/src/network/sonarr_network/downloads/mod.rs new file mode 100644 index 0000000..2d78edc --- /dev/null +++ b/src/network/sonarr_network/downloads/mod.rs @@ -0,0 +1,81 @@ +use crate::models::servarr_models::CommandBody; +use crate::models::sonarr_models::DownloadsResponse; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; +use serde_json::Value; + +#[cfg(test)] +#[path = "sonarr_downloads_network_tests.rs"] +mod sonarr_downloads_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn delete_sonarr_download( + &mut self, + download_id: i64, + ) -> Result<()> { + let event = SonarrEvent::DeleteDownload(download_id); + info!("Deleting Sonarr download for download with id: {download_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{download_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_downloads( + &mut self, + count: u64, + ) -> Result { + info!("Fetching Sonarr downloads"); + let event = SonarrEvent::GetDownloads(count); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("pageSize={count}")), + ) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .sonarr_data + .downloads + .set_items(queue_response.records); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn update_sonarr_downloads( + &mut self, + ) -> Result { + info!("Updating Sonarr downloads"); + let event = SonarrEvent::UpdateDownloads; + let body = CommandBody { + name: "RefreshMonitoredDownloads".to_owned(), + }; + + 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/sonarr_network/downloads/sonarr_downloads_network_tests.rs b/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs new file mode 100644 index 0000000..4500b45 --- /dev/null +++ b/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs @@ -0,0 +1,113 @@ +#[cfg(test)] +mod tests { + use crate::models::sonarr_models::{DownloadsResponse, SonarrSerdeable}; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{ + download_record, downloads_response, + }; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, RequestMethod}; + use pretty_assertions::assert_eq; + use reqwest::Client; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_delete_sonarr_download_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteDownload(1), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .downloads + .set_items(vec![download_record()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteDownload(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_sonarr_downloads_event() { + let downloads_response_json = json!({ + "records": [{ + "title": "Test Download Title", + "status": "downloading", + "id": 1, + "episodeId": 1, + "size": 3543348019f64, + "sizeleft": 1771674009f64, + "outputPath": "/nfs/tv/Test show/season 1/", + "indexer": "kickass torrents", + "downloadClient": "transmission", + }] + }); + let response: DownloadsResponse = + serde_json::from_value(downloads_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(downloads_response_json), + None, + SonarrEvent::GetDownloads(500), + None, + Some("pageSize=500"), + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::DownloadsResponse(downloads) = network + .handle_sonarr_event(SonarrEvent::GetDownloads(500)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.downloads.items, + downloads_response().records + ); + assert_eq!(downloads, response); + } + } + + #[tokio::test] + async fn test_handle_update_sonarr_downloads_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshMonitoredDownloads" + })), + Some(json!({})), + None, + SonarrEvent::UpdateDownloads, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateDownloads) + .await + .is_ok()); + + async_server.assert_async().await; + } +} diff --git a/src/network/sonarr_network/history/mod.rs b/src/network/sonarr_network/history/mod.rs new file mode 100644 index 0000000..20e1fff --- /dev/null +++ b/src/network/sonarr_network/history/mod.rs @@ -0,0 +1,63 @@ +use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::sonarr_models::SonarrHistoryWrapper; +use crate::models::Route; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; +use serde_json::Value; + +#[cfg(test)] +#[path = "sonarr_history_network_tests.rs"] +mod sonarr_history_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn get_sonarr_history( + &mut self, + events: u64, + ) -> Result { + info!("Fetching all Sonarr history events"); + let event = SonarrEvent::GetHistory(events); + + let params = format!("pageSize={events}&sortDirection=descending&sortKey=date"); + 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 + } + + pub(in crate::network::sonarr_network) async fn mark_sonarr_history_item_as_failed( + &mut self, + history_item_id: i64, + ) -> Result { + info!("Marking the Sonarr history item with ID: {history_item_id} as 'failed'"); + let event = SonarrEvent::MarkHistoryItemAsFailed(history_item_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + None, + Some(format!("/{history_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), Value>(request_props, |_, _| ()) + .await + } +} diff --git a/src/network/sonarr_network/history/sonarr_history_network_tests.rs b/src/network/sonarr_network/history/sonarr_history_network_tests.rs new file mode 100644 index 0000000..aa5476f --- /dev/null +++ b/src/network/sonarr_network/history/sonarr_history_network_tests.rs @@ -0,0 +1,210 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::sonarr_models::{SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable}; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::history_item; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, RequestMethod}; + use pretty_assertions::assert_eq; + use reqwest::Client; + use rstest::rstest; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[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": [{ "id": 1, "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": [{ "id": 1, "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(500), + None, + Some("pageSize=500&sortDirection=descending&sortKey=date"), + ) + .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]); + } + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(500)) + .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": [{ "id": 1, "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": [{ "id": 1, "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(500), + None, + Some("pageSize=500&sortDirection=descending&sortKey=date"), + ) + .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]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(500)) + .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_mark_sonarr_history_item_as_failed_event() { + let expected_history_item_id = 1; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + None, + Some(json!({})), + None, + SonarrEvent::MarkHistoryItemAsFailed(expected_history_item_id), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::MarkHistoryItemAsFailed( + expected_history_item_id + )) + .await + .is_ok()); + async_server.assert_async().await; + } +} diff --git a/src/network/sonarr_network/indexers/mod.rs b/src/network/sonarr_network/indexers/mod.rs new file mode 100644 index 0000000..ed19639 --- /dev/null +++ b/src/network/sonarr_network/indexers/mod.rs @@ -0,0 +1,394 @@ +use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; +use crate::models::sonarr_models::IndexerSettings; +use crate::models::stateful_table::StatefulTable; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::{debug, info}; +use serde_json::{json, Value}; + +#[cfg(test)] +#[path = "sonarr_indexers_network_tests.rs"] +mod sonarr_indexers_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn delete_sonarr_indexer( + &mut self, + indexer_id: i64, + ) -> Result<()> { + let event = SonarrEvent::DeleteIndexer(indexer_id); + info!("Deleting Sonarr indexer for indexer with id: {indexer_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{indexer_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn edit_all_sonarr_indexer_settings( + &mut self, + params: IndexerSettings, + ) -> Result { + info!("Updating Sonarr indexer settings"); + let event = SonarrEvent::EditAllIndexerSettings(IndexerSettings::default()); + debug!("Indexer settings body: {params:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Put, Some(params), None, None) + .await; + + self + .handle_request::(request_props, |_, _| {}) + .await + } + + pub(in crate::network::sonarr_network) async fn edit_sonarr_indexer( + &mut self, + mut edit_indexer_params: EditIndexerParams, + ) -> Result<()> { + if let Some(tag_input_str) = edit_indexer_params.tag_input_string.as_ref() { + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tag_input_str).await; + edit_indexer_params.tags = Some(tag_ids_vec); + } + let detail_event = SonarrEvent::GetIndexers; + let event = SonarrEvent::EditIndexer(EditIndexerParams::default()); + let id = edit_indexer_params.indexer_id; + info!("Updating Sonarr indexer with ID: {id}"); + info!("Fetching indexer details for indexer with ID: {id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + response = detailed_indexer_body.to_string() + }) + .await?; + + info!("Constructing edit indexer body"); + + let mut detailed_indexer_body: Value = serde_json::from_str(&response)?; + + let ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) = { + let priority = detailed_indexer_body["priority"] + .as_i64() + .expect("Unable to deserialize 'priority'"); + let seed_ratio_field_option = detailed_indexer_body["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + let name = edit_indexer_params.name.unwrap_or( + detailed_indexer_body["name"] + .as_str() + .expect("Unable to deserialize 'name'") + .to_owned(), + ); + let enable_rss = edit_indexer_params.enable_rss.unwrap_or( + detailed_indexer_body["enableRss"] + .as_bool() + .expect("Unable to deserialize 'enableRss'"), + ); + let enable_automatic_search = edit_indexer_params.enable_automatic_search.unwrap_or( + detailed_indexer_body["enableAutomaticSearch"] + .as_bool() + .expect("Unable to deserialize 'enableAutomaticSearch"), + ); + let enable_interactive_search = edit_indexer_params.enable_interactive_search.unwrap_or( + detailed_indexer_body["enableInteractiveSearch"] + .as_bool() + .expect("Unable to deserialize 'enableInteractiveSearch'"), + ); + let url = edit_indexer_params.url.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .expect("Unable to deserialize 'fields'") + .iter() + .find(|field| field["name"] == "baseUrl") + .expect("Field 'baseUrl' was not found in the 'fields' array") + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'baseUrl value'") + .to_owned(), + ); + let api_key = edit_indexer_params.api_key.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .expect("Unable to deserialize 'fields'") + .iter() + .find(|field| field["name"] == "apiKey") + .expect("Field 'apiKey' was not found in the 'fields' array") + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'apiKey value'") + .to_owned(), + ); + let seed_ratio = edit_indexer_params.seed_ratio.unwrap_or_else(|| { + if let Some(seed_ratio_field) = seed_ratio_field_option { + return seed_ratio_field + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'seedCriteria.seedRatio value'") + .to_owned(); + } + + String::new() + }); + let tags = if edit_indexer_params.clear_tags { + vec![] + } else { + edit_indexer_params.tags.unwrap_or( + detailed_indexer_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + let priority = edit_indexer_params.priority.unwrap_or(priority); + + ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) + }; + + *detailed_indexer_body.get_mut("name").unwrap() = json!(name); + *detailed_indexer_body.get_mut("priority").unwrap() = json!(priority); + *detailed_indexer_body.get_mut("enableRss").unwrap() = json!(enable_rss); + *detailed_indexer_body + .get_mut("enableAutomaticSearch") + .unwrap() = json!(enable_automatic_search); + *detailed_indexer_body + .get_mut("enableInteractiveSearch") + .unwrap() = json!(enable_interactive_search); + *detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "baseUrl") + .unwrap() + .get_mut("value") + .unwrap() = json!(url); + *detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "apiKey") + .unwrap() + .get_mut("value") + .unwrap() = json!(api_key); + *detailed_indexer_body.get_mut("tags").unwrap() = json!(tags); + let seed_ratio_field_option = detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field + .as_object_mut() + .unwrap() + .insert("value".to_string(), json!(seed_ratio)); + } + + debug!("Edit indexer body: {detailed_indexer_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_indexer_body), + Some(format!("/{id}")), + Some("forceSave=true".to_owned()), + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn get_all_sonarr_indexer_settings( + &mut self, + ) -> Result { + info!("Fetching Sonarr indexer settings"); + let event = SonarrEvent::GetAllIndexerSettings; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), IndexerSettings>(request_props, |indexer_settings, mut app| { + if app.data.sonarr_data.indexer_settings.is_none() { + app.data.sonarr_data.indexer_settings = Some(indexer_settings); + } else { + debug!("Indexer Settings are being modified. Ignoring update..."); + } + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_indexers( + &mut self, + ) -> Result> { + info!("Fetching Sonarr indexers"); + let event = SonarrEvent::GetIndexers; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |indexers, mut app| { + app.data.sonarr_data.indexers.set_items(indexers); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn test_sonarr_indexer( + &mut self, + indexer_id: i64, + ) -> Result { + let detail_event = SonarrEvent::GetIndexers; + let event = SonarrEvent::TestIndexer(indexer_id); + info!("Testing Sonarr indexer with ID: {indexer_id}"); + + info!("Fetching indexer details for indexer with ID: {indexer_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{indexer_id}")), + None, + ) + .await; + + let mut test_body: Value = Value::default(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + test_body = detailed_indexer_body; + }) + .await?; + + info!("Testing indexer"); + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::(request_props, |test_results, mut app| { + if test_results.as_object().is_none() { + app.data.sonarr_data.indexer_test_errors = Some( + test_results.as_array().unwrap()[0] + .get("errorMessage") + .unwrap() + .to_string(), + ); + } else { + app.data.sonarr_data.indexer_test_errors = Some(String::new()); + }; + }) + .await + } + + pub(in crate::network::sonarr_network) async fn test_all_sonarr_indexers( + &mut self, + ) -> Result> { + info!("Testing all Sonarr indexers"); + let event = SonarrEvent::TestAllIndexers; + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, None, None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::<(), Vec>(request_props, |test_results, mut app| { + let mut test_all_indexer_results = StatefulTable::default(); + let indexers = app.data.sonarr_data.indexers.items.clone(); + let modal_test_results = test_results + .iter() + .map(|result| { + let name = indexers + .iter() + .filter(|&indexer| indexer.id == result.id) + .map(|indexer| indexer.name.clone()) + .nth(0) + .unwrap_or_default(); + let validation_failures = result + .validation_failures + .iter() + .map(|failure| { + format!( + "Failure for field '{}': {}", + failure.property_name, failure.error_message + ) + }) + .collect::>() + .join(", "); + + IndexerTestResultModalItem { + name: name.unwrap_or_default(), + is_valid: result.is_valid, + validation_failures: validation_failures.into(), + } + }) + .collect(); + test_all_indexer_results.set_items(modal_test_results); + app.data.sonarr_data.indexer_test_all_results = Some(test_all_indexer_results); + }) + .await + } +} diff --git a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs new file mode 100644 index 0000000..4a4ed73 --- /dev/null +++ b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs @@ -0,0 +1,930 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; + use crate::models::sonarr_models::SonarrSerdeable; + use crate::models::HorizontallyScrollableText; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{ + indexer, indexer_settings, + }; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, NetworkResource, RequestMethod}; + use bimap::BiMap; + use mockito::Matcher; + use pretty_assertions::assert_eq; + use reqwest::Client; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_delete_sonarr_indexer_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteIndexer(1), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(vec![indexer()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteIndexer(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_event() { + let indexer_settings_json = json!({ + "id": 1, + "minimumAge": 1, + "maximumSize": 12345, + "retention": 1, + "rssSyncInterval": 60 + }); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Put, + Some(indexer_settings_json), + None, + None, + SonarrEvent::EditAllIndexerSettings(indexer_settings()), + None, + None, + ) + .await; + + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditAllIndexerSettings(indexer_settings())) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event() { + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 0, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(expected_edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + app_arc.lock().await.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(expected_edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_does_not_overwrite_tags_vec_if_tag_input_string_is_none( + ) { + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tags: Some(vec![1, 2]), + priority: Some(0), + ..EditIndexerParams::default() + }; + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 0, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(expected_edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(expected_edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( + ) { + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 0, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + ], + "tags": [1, 2], + "id": 1 + }); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(expected_edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + app_arc.lock().await.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(expected_edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( + ) { + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 0, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(expected_edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + app_arc.lock().await.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(expected_edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_defaults_to_previous_values() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + ..EditIndexerParams::default() + }; + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json)) + .create_async() + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_clears_tags_when_clear_tags_is_true() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let expected_edit_indexer_body = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + clear_tags: true, + ..EditIndexerParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_edit_indexer_body)) + .create_async() + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(edit_indexer_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_sonarr_indexers_event() { + let indexers_response_json = json!([{ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "supportsRss": true, + "supportsSearch": true, + "protocol": "torrent", + "priority": 25, + "downloadClientId": 0, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "implementationName": "Torznab", + "implementation": "Torznab", + "configContract": "TorznabSettings", + "tags": [1], + "id": 1 + }]); + let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexers_response_json), + None, + SonarrEvent::GetIndexers, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Indexers(indexers) = network + .handle_sonarr_event(SonarrEvent::GetIndexers) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.indexers.items, + vec![indexer()] + ); + assert_eq!(indexers, response); + } + } + + #[tokio::test] + async fn test_handle_test_sonarr_indexer_event_error() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let response_json = json!([ + { + "isWarning": false, + "propertyName": "", + "errorMessage": "test failure", + "severity": "error" + }]); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", SonarrEvent::TestIndexer(1).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body(response_json.to_string()) + .create_async() + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(vec![indexer()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::TestIndexer(1)) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.indexer_test_errors, + Some("\"test failure\"".to_owned()) + ); + assert_eq!(value, response_json) + } + } + + #[tokio::test] + async fn test_handle_test_sonarr_indexer_event_success() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", SonarrEvent::TestIndexer(1).resource()).as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body("{}") + .create_async() + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(vec![indexer()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::TestIndexer(1)) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.indexer_test_errors, + Some(String::new()) + ); + assert_eq!(value, json!({})); + } + } + + #[tokio::test] + async fn test_handle_test_all_sonarr_indexers_event() { + let indexers = vec![ + Indexer { + id: 1, + name: Some("Test 1".to_owned()), + ..Indexer::default() + }, + Indexer { + id: 2, + name: Some("Test 2".to_owned()), + ..Indexer::default() + }, + ]; + let indexer_test_results_modal_items = vec![ + IndexerTestResultModalItem { + name: "Test 1".to_owned(), + is_valid: true, + validation_failures: HorizontallyScrollableText::default(), + }, + IndexerTestResultModalItem { + name: "Test 2".to_owned(), + is_valid: false, + validation_failures: "Failure for field 'test field 1': test error message, Failure for field 'test field 2': test error message 2".into(), + }, + ]; + let response_json = json!([ + { + "id": 1, + "isValid": true, + "validationFailures": [] + }, + { + "id": 2, + "isValid": false, + "validationFailures": [ + { + "propertyName": "test field 1", + "errorMessage": "test error message", + "severity": "error" + }, + { + "propertyName": "test field 2", + "errorMessage": "test error message 2", + "severity": "error" + }, + ] + }]); + let response: Vec = serde_json::from_value(response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + None, + Some(response_json), + Some(400), + SonarrEvent::TestAllIndexers, + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(indexers); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::IndexerTestResults(results) = network + .handle_sonarr_event(SonarrEvent::TestAllIndexers) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .indexer_test_all_results + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items, + indexer_test_results_modal_items + ); + assert_eq!(results, response); + } + } +} diff --git a/src/network/sonarr_network/library/episodes/mod.rs b/src/network/sonarr_network/library/episodes/mod.rs new file mode 100644 index 0000000..80c4557 --- /dev/null +++ b/src/network/sonarr_network/library/episodes/mod.rs @@ -0,0 +1,498 @@ +use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal}; +use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::servarr_models::Language; +use crate::models::sonarr_models::{ + DownloadRecord, DownloadStatus, Episode, EpisodeFile, MonitorEpisodeBody, SonarrCommandBody, + SonarrHistoryWrapper, SonarrRelease, +}; +use crate::models::{Route, ScrollableText}; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use crate::utils::convert_to_gb; +use anyhow::Result; +use indoc::formatdoc; +use log::info; +use serde_json::{Number, Value}; + +#[cfg(test)] +#[path = "sonarr_episodes_network_tests.rs"] +mod sonarr_episodes_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn delete_sonarr_episode_file( + &mut self, + episode_file_id: i64, + ) -> Result<()> { + let event = SonarrEvent::DeleteEpisodeFile(episode_file_id); + info!("Deleting Sonarr episode file for episode file with id: {episode_file_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{episode_file_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn get_episodes( + &mut self, + series_id: i64, + ) -> Result> { + let event = SonarrEvent::GetEpisodes(series_id); + info!("Fetching episodes for Sonarr series with ID: {series_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("seriesId={series_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut episode_vec, mut app| { + episode_vec.sort_by(|a, b| a.id.cmp(&b.id)); + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::EpisodesSortPrompt, _) + ) { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + let season_episodes_vec = if !app.data.sonarr_data.seasons.is_empty() { + let season_number = app + .data + .sonarr_data + .seasons + .current_selection() + .season_number; + + episode_vec + .into_iter() + .filter(|episode| episode.season_number == season_number) + .collect() + } else { + episode_vec + }; + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(season_episodes_vec); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .apply_sorting_toggle(false); + } + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_episode_files( + &mut self, + series_id: i64, + ) -> Result> { + let event = SonarrEvent::GetEpisodeFiles(series_id); + info!("Fetching episodes files for Sonarr series with ID: {series_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("seriesId={series_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |episode_file_vec, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_files + .set_items(episode_file_vec); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_episode_history( + &mut self, + episode_id: i64, + ) -> Result { + info!("Fetching Sonarr history for episode with ID: {episode_id}"); + let event = SonarrEvent::GetEpisodeHistory(episode_id); + + let params = + format!("episodeId={episode_id}&pageSize=1000&sortDirection=descending&sortKey=date"); + 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 app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + if app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_none() + { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + } + + let mut history_vec = history_response.records; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .set_items(history_vec); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .apply_sorting_toggle(false); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_episode_details( + &mut self, + episode_id: i64, + ) -> Result { + info!("Fetching Sonarr episode details"); + let event = SonarrEvent::GetEpisodeDetails(episode_id); + + info!("Fetching episode details for episode with ID: {episode_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{episode_id}")), + None, + ) + .await; + + self + .handle_request::<(), Episode>(request_props, |episode_response, mut app| { + if app.cli_mode { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + if app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is empty") + .episode_details_modal + .is_none() + { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + } + + let Episode { + id, + title, + air_date_utc, + overview, + has_file, + season_number, + episode_number, + episode_file, + .. + } = episode_response; + let status = get_episode_status(has_file, &app.data.sonarr_data.downloads.items, id); + let air_date = if let Some(air_date) = air_date_utc { + format!("{air_date}") + } else { + String::new() + }; + let episode_details_modal = app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap(); + episode_details_modal.episode_details = ScrollableText::with_string(formatdoc!( + " + Title: {} + Season: {season_number} + Episode Number: {episode_number} + Air Date: {air_date} + Status: {status} + Description: {}", + title, + overview.unwrap_or_default(), + )); + if let Some(file) = episode_file { + let size = convert_to_gb(file.size); + episode_details_modal.file_details = formatdoc!( + " + Relative Path: {} + Absolute Path: {} + Size: {size:.2} GB + Language: {} + Date Added: {}", + file.relative_path, + file.path, + file.languages.first().unwrap_or(&Language::default()).name, + file.date_added, + ); + + if let Some(media_info) = file.media_info { + episode_details_modal.audio_details = formatdoc!( + " + Bitrate: {} + Channels: {:.1} + Codec: {} + Languages: {} + Stream Count: {}", + media_info.audio_bitrate, + media_info.audio_channels.as_f64().unwrap(), + media_info.audio_codec.unwrap_or_default(), + media_info.audio_languages.unwrap_or_default(), + media_info.audio_stream_count + ); + + episode_details_modal.video_details = formatdoc!( + " + Bit Depth: {} + Bitrate: {} + Codec: {} + FPS: {} + Resolution: {} + Scan Type: {} + Runtime: {} + Subtitles: {}", + media_info.video_bit_depth, + media_info.video_bitrate, + media_info.video_codec.unwrap_or_default(), + media_info.video_fps.as_f64().unwrap(), + media_info.resolution, + media_info.scan_type, + media_info.run_time, + media_info.subtitles.unwrap_or_default() + ); + } + }; + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_episode_releases( + &mut self, + episode_id: i64, + ) -> Result> { + let event = SonarrEvent::GetEpisodeReleases(episode_id); + info!("Fetching releases for episode with ID: {episode_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("episodeId={episode_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + if app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .is_none() + { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + } + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases + .set_items(release_vec); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn toggle_sonarr_episode_monitoring( + &mut self, + episode_id: i64, + ) -> Result<()> { + let event = SonarrEvent::ToggleEpisodeMonitoring(episode_id); + let detail_event = SonarrEvent::GetEpisodeDetails(0); + + let monitored = { + info!("Fetching episode details for episode id: {episode_id}"); + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{episode_id}")), + None, + ) + .await; + + let mut monitored = false; + + self + .handle_request::<(), Value>(request_props, |detailed_episode_body, _| { + monitored = detailed_episode_body + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + }) + .await?; + + monitored + }; + + info!("Toggling monitoring for episode id: {episode_id}"); + + let body = MonitorEpisodeBody { + episode_ids: vec![episode_id], + monitored: !monitored, + }; + + let request_props = self + .request_props_from(event, RequestMethod::Put, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn trigger_automatic_episode_search( + &mut self, + episode_id: i64, + ) -> Result { + let event = SonarrEvent::TriggerAutomaticEpisodeSearch(episode_id); + info!("Searching indexers for episode with ID: {episode_id}"); + + let body = SonarrCommandBody { + name: "EpisodeSearch".to_owned(), + episode_ids: Some(vec![episode_id]), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } +} + +fn get_episode_status(has_file: bool, downloads_vec: &[DownloadRecord], episode_id: i64) -> String { + if !has_file { + let default_episode_id = Number::from(-1i64); + if let Some(download) = downloads_vec.iter().find(|&download| { + download + .episode_id + .as_ref() + .unwrap_or(&default_episode_id) + .as_i64() + .unwrap() + == episode_id + }) { + if download.status == DownloadStatus::Downloading { + return "Downloading".to_owned(); + } + + if download.status == DownloadStatus::Completed { + return "Awaiting Import".to_owned(); + } + } + + return "Missing".to_owned(); + } + + "Downloaded".to_owned() +} diff --git a/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs b/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs new file mode 100644 index 0000000..83a5d2a --- /dev/null +++ b/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs @@ -0,0 +1,1377 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal}; + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::sonarr_models::{ + DownloadRecord, DownloadStatus, Episode, MonitorEpisodeBody, Season, Series, SonarrHistoryItem, + SonarrHistoryWrapper, SonarrSerdeable, + }; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::library::episodes::get_episode_status; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{ + episode, episode_file, history_item, release, EPISODE_JSON, + }; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, NetworkResource, RequestMethod}; + use indoc::formatdoc; + use mockito::Matcher; + use pretty_assertions::{assert_eq, assert_str_eq}; + use reqwest::Client; + use rstest::rstest; + use serde_json::{json, Number}; + use std::slice; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_delete_sonarr_episode_file_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteEpisodeFile(1), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteEpisodeFile(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_episodes_event(#[values(true, false)] use_custom_sorting: bool) { + let episode_1 = Episode { + title: "z test".to_owned(), + episode_file: None, + ..episode() + }; + let episode_2 = Episode { + id: 2, + title: "A test".to_owned(), + episode_file_id: 2, + season_number: 2, + episode_number: 2, + episode_file: None, + ..episode() + }; + let episode_3 = Episode { + id: 3, + title: "A test".to_owned(), + episode_file_id: 3, + season_number: 1, + episode_number: 2, + episode_file: None, + ..episode() + }; + let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()]; + let mut expected_sorted_episodes = vec![episode_1.clone(), episode_3.clone()]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_1, episode_2, episode_3])), + None, + SonarrEvent::GetEpisodes(1), + None, + Some("seriesId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &Episode, b: &Episode| a.title.to_lowercase().cmp(&b.title.to_lowercase()); + expected_sorted_episodes.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + season_details_modal + .episodes + .sorting(vec![title_sort_option]); + } + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .items, + expected_sorted_episodes + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .sort_asc + ); + assert_eq!(episodes, expected_episodes); + } + } + + #[tokio::test] + async fn test_handle_get_episodes_event_empty_seasons_table_returns_all_episodes_by_default() { + let episode_1 = Episode { + title: "z test".to_owned(), + episode_file: None, + ..episode() + }; + let episode_2 = Episode { + id: 2, + title: "A test".to_owned(), + episode_file_id: 2, + season_number: 2, + episode_number: 2, + episode_file: None, + ..episode() + }; + let episode_3 = Episode { + id: 3, + title: "A test".to_owned(), + episode_file_id: 3, + season_number: 1, + episode_number: 2, + episode_file: None, + ..episode() + }; + let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_1, episode_2, episode_3])), + None, + SonarrEvent::GetEpisodes(1), + None, + Some("seriesId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.sort_asc = true; + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .items, + expected_episodes + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .sort_asc + ); + assert_eq!(episodes, expected_episodes); + } + } + + #[tokio::test] + async fn test_handle_get_episodes_event_empty_season_details_modal() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode()])), + None, + SonarrEvent::GetEpisodes(1), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .items, + vec![episode()] + ); + assert_eq!(episodes, vec![episode()]); + } + } + + #[tokio::test] + async fn test_handle_get_episodes_event_no_op_while_user_is_selecting_sort_options_on_table() { + let episodes_json = json!([ + { + "id": 2, + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 2, + "seasonNumber": 2, + "episodeNumber": 2, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + }, + { + "id": 1, + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + } + ]); + let episode_1 = Episode { + episode_file: None, + ..episode() + }; + let episode_2 = Episode { + id: 2, + episode_file_id: 2, + season_number: 2, + episode_number: 2, + episode_file: None, + ..episode() + }; + let mut expected_episodes = vec![episode_2.clone(), episode_1.clone()]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(episodes_json), + None, + SonarrEvent::GetEpisodes(1), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesSortPrompt.into()); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.sort_asc = true; + let cmp_fn = |a: &Episode, b: &Episode| a.title.to_lowercase().cmp(&b.title.to_lowercase()); + expected_episodes.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + season_details_modal + .episodes + .sorting(vec![title_sort_option]); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .is_empty()); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .sort_asc + ); + assert_eq!(episodes, expected_episodes); + } + } + + #[tokio::test] + async fn test_handle_get_episode_files_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_file()])), + None, + SonarrEvent::GetEpisodeFiles(1), + None, + Some("seriesId=1"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::EpisodeFiles(episode_files) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeFiles(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_files + .items, + vec![episode_file()] + ); + assert_eq!(episode_files, vec![episode_file()]); + } + } + + #[tokio::test] + async fn test_handle_get_episode_files_event_empty_season_details_modal() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_file()])), + None, + SonarrEvent::GetEpisodeFiles(1), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::EpisodeFiles(episode_files) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeFiles(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_files + .items, + vec![episode_file()] + ); + assert_eq!(episode_files, vec![episode_file()]); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetEpisodeHistory(1), + None, + Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .sort_asc = true; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event_empty_episode_details_modal() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetEpisodeHistory(1), + None, + Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event_empty_season_details_modal() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetEpisodeHistory(1), + None, + Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_episode_details_event() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(1), + Some("/1"), + None, + ) + .await; + let mut episode_details_modal = EpisodeDetailsModal::default(); + episode_details_modal.episode_details_tabs.next(); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + season_details_modal.episode_details_modal = Some(episode_details_modal); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_details_tabs + .get_active_route(), + ActiveSonarrBlock::EpisodeHistory.into() + ); + assert_eq!(episode, response); + + let app = app_arc.lock().await; + let episode_details_modal = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap(); + assert_str_eq!( + episode_details_modal.episode_details.get_text(), + formatdoc!( + "Title: Something cool + Season: 1 + Episode Number: 1 + Air Date: 2024-02-10 07:28:45 UTC + Status: Downloaded + Description: Okay so this one time at band camp..." + ) + ); + assert_str_eq!( + episode_details_modal.file_details, + formatdoc!( + "Relative Path: /season 1/episode 1.mkv + Absolute Path: /nfs/tv/series/season 1/episode 1.mkv + Size: 3.30 GB + Language: English + Date Added: 2024-02-10 07:28:45 UTC" + ) + ); + assert_str_eq!( + episode_details_modal.audio_details, + formatdoc!( + "Bitrate: 0 + Channels: 7.1 + Codec: AAC + Languages: eng + Stream Count: 1" + ) + ); + assert_str_eq!( + episode_details_modal.video_details, + formatdoc!( + "Bit Depth: 10 + Bitrate: 0 + Codec: x265 + FPS: 23.976 + Resolution: 1920x1080 + Scan Type: Progressive + Runtime: 23:51 + Subtitles: English" + ) + ); + } + } + + #[tokio::test] + async fn test_handle_get_episode_details_event_empty_episode_details_modal() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(1), + Some("/1"), + None, + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_some()); + assert_eq!(episode, response); + + let app = app_arc.lock().await; + let episode_details_modal = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap(); + assert_str_eq!( + episode_details_modal.episode_details.get_text(), + formatdoc!( + "Title: Something cool + Season: 1 + Episode Number: 1 + Air Date: 2024-02-10 07:28:45 UTC + Status: Downloaded + Description: Okay so this one time at band camp..." + ) + ); + assert_str_eq!( + episode_details_modal.file_details, + formatdoc!( + "Relative Path: /season 1/episode 1.mkv + Absolute Path: /nfs/tv/series/season 1/episode 1.mkv + Size: 3.30 GB + Language: English + Date Added: 2024-02-10 07:28:45 UTC" + ) + ); + assert_str_eq!( + episode_details_modal.audio_details, + formatdoc!( + "Bitrate: 0 + Channels: 7.1 + Codec: AAC + Languages: eng + Stream Count: 1" + ) + ); + assert_str_eq!( + episode_details_modal.video_details, + formatdoc!( + "Bit Depth: 10 + Bitrate: 0 + Codec: x265 + FPS: 23.976 + Resolution: 1920x1080 + Scan Type: Progressive + Runtime: 23:51 + Subtitles: English" + ) + ); + } + } + + #[tokio::test] + async fn test_handle_get_episode_details_event_season_details_modal_not_required_in_cli_mode() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(1), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.cli_mode = true; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(episode, response); + } + } + + #[tokio::test] + #[should_panic(expected = "Season details modal is empty")] + async fn test_handle_get_episode_details_event_requires_season_details_modal_to_be_some_when_in_tui_mode( + ) { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(1), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(1)) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_handle_get_episode_releases_event() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(1), + None, + Some("episodeId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + async fn test_handle_get_episode_releases_event_empty_episode_details_modal() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(1), + None, + Some("episodeId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + async fn test_handle_toggle_episode_monitoring_event() { + let expected_body = MonitorEpisodeBody { + episode_ids: vec![2], + monitored: false, + }; + let body = Episode { id: 2, ..episode() }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!(body)), + None, + SonarrEvent::GetEpisodeDetails(2), + Some("/2"), + None, + ) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v3{}", + SonarrEvent::ToggleEpisodeMonitoring(2).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(json!(expected_body))) + .create_async() + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ToggleEpisodeMonitoring(2)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_episode_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "EpisodeSearch", + "episodeIds": [ 1 ] + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticEpisodeSearch(1), + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticEpisodeSearch(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[test] + fn test_get_episode_status_downloaded() { + assert_str_eq!(get_episode_status(true, &[], 0), "Downloaded"); + } + + #[test] + fn test_get_episode_status_missing() { + let download_record = DownloadRecord { + episode_id: Some(Number::from(1i64)), + ..DownloadRecord::default() + }; + + assert_str_eq!( + get_episode_status(false, slice::from_ref(&download_record), 0), + "Missing" + ); + + assert_str_eq!(get_episode_status(false, &[download_record], 1), "Missing"); + } + + #[test] + fn test_get_episode_status_missing_if_episode_id_is_missing() { + let download_record = DownloadRecord::default(); + + assert_str_eq!( + get_episode_status(false, slice::from_ref(&download_record), 0), + "Missing" + ); + + assert_str_eq!(get_episode_status(false, &[download_record], 1), "Missing"); + } + + #[test] + fn test_get_episode_status_downloading() { + assert_str_eq!( + get_episode_status( + false, + &[DownloadRecord { + episode_id: Some(Number::from(1i64)), + status: DownloadStatus::Downloading, + ..DownloadRecord::default() + }], + 1 + ), + "Downloading" + ); + } + + #[test] + fn test_get_episode_status_awaiting_import() { + assert_str_eq!( + get_episode_status( + false, + &[DownloadRecord { + episode_id: Some(Number::from(1i64)), + status: DownloadStatus::Completed, + ..DownloadRecord::default() + }], + 1 + ), + "Awaiting Import" + ); + } +} diff --git a/src/network/sonarr_network/library/mod.rs b/src/network/sonarr_network/library/mod.rs new file mode 100644 index 0000000..15c4a2c --- /dev/null +++ b/src/network/sonarr_network/library/mod.rs @@ -0,0 +1,38 @@ +use crate::models::sonarr_models::SonarrReleaseDownloadBody; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; +use serde_json::Value; + +mod episodes; +mod seasons; +mod series; + +#[cfg(test)] +#[path = "sonarr_library_network_tests.rs"] +mod sonarr_library_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn download_sonarr_release( + &mut self, + sonarr_release_download_body: SonarrReleaseDownloadBody, + ) -> Result { + let event = SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody::default()); + info!("Downloading Sonarr release with params: {sonarr_release_download_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(sonarr_release_download_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } +} diff --git a/src/network/sonarr_network/library/seasons/mod.rs b/src/network/sonarr_network/library/seasons/mod.rs new file mode 100644 index 0000000..5f0695f --- /dev/null +++ b/src/network/sonarr_network/library/seasons/mod.rs @@ -0,0 +1,199 @@ +use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal; +use crate::models::sonarr_models::{SonarrCommandBody, SonarrHistoryItem, SonarrRelease}; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::{debug, info, warn}; +use serde_json::{json, Value}; + +#[cfg(test)] +#[path = "sonarr_seasons_network_tests.rs"] +mod sonarr_seasons_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn toggle_sonarr_season_monitoring( + &mut self, + series_id_season_number_tuple: (i64, i64), + ) -> Result<()> { + let event = SonarrEvent::ToggleSeasonMonitoring(series_id_season_number_tuple); + let (series_id, season_number) = series_id_season_number_tuple; + + let detail_event = SonarrEvent::GetSeriesDetails(series_id); + info!("Toggling season monitoring for season {season_number} in series with ID: {series_id}"); + info!("Fetching series details for series with ID: {series_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{series_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_series_body, _| { + response = detailed_series_body.to_string() + }) + .await?; + + info!("Constructing toggle season monitoring body"); + + match serde_json::from_str::(&response) { + Ok(mut detailed_series_body) => { + let monitored = detailed_series_body + .get("seasons") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|season| season["seasonNumber"] == season_number) + .unwrap() + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + + *detailed_series_body + .get_mut("seasons") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|season| season["seasonNumber"] == season_number) + .unwrap() + .get_mut("monitored") + .unwrap() = json!(!monitored); + + debug!("Toggle season monitoring body: {detailed_series_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_series_body), + Some(format!("/{series_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + Err(_) => { + warn!("Request for detailed series body was interrupted"); + Ok(()) + } + } + } + + pub(in crate::network::sonarr_network) async fn get_season_releases( + &mut self, + series_season_id_tuple: (i64, i64), + ) -> Result> { + let event = SonarrEvent::GetSeasonReleases(series_season_id_tuple); + let (series_id, season_number) = series_season_id_tuple; + info!("Fetching releases for series with ID: {series_id} and season number: {season_number}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("seriesId={series_id}&seasonNumber={season_number}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + let season_releases_vec = release_vec + .into_iter() + .filter(|release| release.full_season) + .collect(); + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .set_items(season_releases_vec); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_season_history( + &mut self, + series_season_id_tuple: (i64, i64), + ) -> Result> { + let event = SonarrEvent::GetSeasonHistory(series_season_id_tuple); + let (series_id, season_number) = series_season_id_tuple; + info!("Fetching history for series with ID: {series_id} and season number: {season_number}"); + + let params = format!("seriesId={series_id}&seasonNumber={season_number}",); + 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.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + let mut history_vec = history_items; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history + .set_items(history_vec); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history + .apply_sorting_toggle(false); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn trigger_automatic_season_search( + &mut self, + series_season_id_tuple: (i64, i64), + ) -> Result { + let event = SonarrEvent::TriggerAutomaticSeasonSearch(series_season_id_tuple); + let (series_id, season_number) = series_season_id_tuple; + info!("Searching indexers for series with ID: {series_id} and season number: {season_number}"); + + let body = SonarrCommandBody { + name: "SeasonSearch".to_owned(), + season_number: Some(season_number), + series_id: Some(series_id), + ..SonarrCommandBody::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/sonarr_network/library/seasons/sonarr_seasons_network_tests.rs b/src/network/sonarr_network/library/seasons/sonarr_seasons_network_tests.rs new file mode 100644 index 0000000..23d8de6 --- /dev/null +++ b/src/network/sonarr_network/library/seasons/sonarr_seasons_network_tests.rs @@ -0,0 +1,515 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal; + use crate::models::sonarr_models::{SonarrHistoryItem, SonarrRelease, SonarrSerdeable}; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{ + history_item, release, season, series, SERIES_JSON, + }; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, NetworkResource, RequestMethod}; + use mockito::Matcher; + use pretty_assertions::assert_eq; + use reqwest::Client; + use serde_json::{json, Value}; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_toggle_season_monitoring_event() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body + .get_mut("seasons") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|season| season["seasonNumber"] == 1) + .unwrap() + .get_mut("monitored") + .unwrap() = json!(false); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(1), + Some("/1"), + None, + ) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + SonarrEvent::ToggleSeasonMonitoring((1, 1)).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.seasons.set_items(vec![season()]); + } + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ToggleSeasonMonitoring((1, 1))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_season_releases_event() { + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_filtered_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; + let expected_raw_sonarr_releases = vec![ + SonarrRelease { + full_season: true, + ..release() + }, + SonarrRelease { + guid: "4567".to_owned(), + ..release() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases((1, 1)), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases((1, 1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![expected_filtered_sonarr_release] + ); + assert_eq!(releases_vec, expected_raw_sonarr_releases); + } + } + + #[tokio::test] + async fn test_handle_get_season_releases_event_empty_season_details_modal() { + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "usenet", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases((1, 1)), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases((1, 1))) + .await + .is_ok()); + + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![expected_sonarr_release] + ); + } + + #[tokio::test] + async fn test_handle_get_sonarr_season_history_event() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetSeasonHistory((1, 1)), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_history + .sort_asc = true; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history) = network + .handle_sonarr_event(SonarrEvent::GetSeasonHistory((1, 1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_season_history_event_empty_season_details_modal() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetSeasonHistory((1, 1)), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history) = network + .handle_sonarr_event(SonarrEvent::GetSeasonHistory((1, 1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_trigger_automatic_season_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeasonSearch", + "seriesId": 1, + "seasonNumber": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeasonSearch((1, 1)), + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch((1, 1))) + .await + .is_ok()); + + async_server.assert_async().await; + } +} diff --git a/src/network/sonarr_network/library/series/mod.rs b/src/network/sonarr_network/library/series/mod.rs new file mode 100644 index 0000000..a9bbf7f --- /dev/null +++ b/src/network/sonarr_network/library/series/mod.rs @@ -0,0 +1,450 @@ +use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::sonarr_models::{ + AddSeriesBody, AddSeriesSearchResult, DeleteSeriesParams, EditSeriesParams, Series, + SonarrCommandBody, SonarrHistoryItem, +}; +use crate::models::stateful_table::StatefulTable; +use crate::models::Route; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::{debug, info, warn}; +use serde_json::{json, Value}; +use urlencoding::encode; + +#[cfg(test)] +#[path = "sonarr_series_network_tests.rs"] +mod sonarr_series_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn add_sonarr_series( + &mut self, + mut add_series_body: AddSeriesBody, + ) -> anyhow::Result { + info!("Adding new series to Sonarr"); + let event = SonarrEvent::AddSeries(AddSeriesBody::default()); + if let Some(tag_input_str) = add_series_body.tag_input_string.as_ref() { + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tag_input_str).await; + add_series_body.tags = tag_ids_vec; + } + + debug!("Add series body: {add_series_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(add_series_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn delete_series( + &mut self, + delete_series_params: DeleteSeriesParams, + ) -> Result<()> { + let event = SonarrEvent::DeleteSeries(DeleteSeriesParams::default()); + let DeleteSeriesParams { + id, + delete_series_files, + add_list_exclusion, + } = delete_series_params; + + info!("Deleting Sonarr series with ID: {id} with deleteFiles={delete_series_files} and addImportExclusion={add_list_exclusion}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + Some(format!( + "deleteFiles={delete_series_files}&addImportExclusion={add_list_exclusion}" + )), + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn edit_sonarr_series( + &mut self, + mut edit_series_params: EditSeriesParams, + ) -> Result<()> { + info!("Editing Sonarr series"); + if let Some(tag_input_str) = edit_series_params.tag_input_string.as_ref() { + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tag_input_str).await; + edit_series_params.tags = Some(tag_ids_vec); + } + let series_id = edit_series_params.series_id; + let detail_event = SonarrEvent::GetSeriesDetails(series_id); + let event = SonarrEvent::EditSeries(EditSeriesParams::default()); + info!("Fetching series details for series with ID: {series_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{series_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_series_body, _| { + response = detailed_series_body.to_string() + }) + .await?; + + info!("Constructing edit series body"); + + let mut detailed_series_body: Value = serde_json::from_str(&response)?; + let ( + monitored, + use_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags, + ) = { + let monitored = edit_series_params.monitored.unwrap_or( + detailed_series_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored'"), + ); + let use_season_folders = edit_series_params.use_season_folders.unwrap_or( + detailed_series_body["seasonFolder"] + .as_bool() + .expect("Unable to deserialize 'season_folder'"), + ); + let series_type = edit_series_params + .series_type + .unwrap_or_else(|| { + serde_json::from_value(detailed_series_body["seriesType"].clone()) + .expect("Unable to deserialize 'seriesType'") + }) + .to_string(); + let quality_profile_id = edit_series_params.quality_profile_id.unwrap_or_else(|| { + detailed_series_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let language_profile_id = edit_series_params.language_profile_id.unwrap_or_else(|| { + detailed_series_body["languageProfileId"] + .as_i64() + .expect("Unable to deserialize 'languageProfileId'") + }); + let root_folder_path = edit_series_params.root_folder_path.unwrap_or_else(|| { + detailed_series_body["path"] + .as_str() + .expect("Unable to deserialize 'path'") + .to_owned() + }); + let tags = if edit_series_params.clear_tags { + vec![] + } else { + edit_series_params.tags.unwrap_or( + detailed_series_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + + ( + monitored, + use_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags, + ) + }; + + *detailed_series_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_series_body.get_mut("seasonFolder").unwrap() = json!(use_season_folders); + *detailed_series_body.get_mut("seriesType").unwrap() = json!(series_type); + *detailed_series_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); + *detailed_series_body.get_mut("languageProfileId").unwrap() = json!(language_profile_id); + *detailed_series_body.get_mut("path").unwrap() = json!(root_folder_path); + *detailed_series_body.get_mut("tags").unwrap() = json!(tags); + + debug!("Edit series body: {detailed_series_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_series_body), + Some(format!("/{series_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn toggle_sonarr_series_monitoring( + &mut self, + series_id: i64, + ) -> Result<()> { + let event = SonarrEvent::ToggleSeriesMonitoring(series_id); + + let detail_event = SonarrEvent::GetSeriesDetails(series_id); + info!("Toggling series monitoring for series with ID: {series_id}"); + info!("Fetching series details for series with ID: {series_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{series_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_series_body, _| { + response = detailed_series_body.to_string() + }) + .await?; + + info!("Constructing toggle series monitoring body"); + + match serde_json::from_str::(&response) { + Ok(mut detailed_series_body) => { + let monitored = detailed_series_body + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + + *detailed_series_body.get_mut("monitored").unwrap() = json!(!monitored); + + debug!("Toggle series monitoring body: {detailed_series_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_series_body), + Some(format!("/{series_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + Err(_) => { + warn!("Request for detailed series body was interrupted"); + Ok(()) + } + } + } + + pub(in crate::network::sonarr_network) async fn get_series_details( + &mut self, + series_id: i64, + ) -> Result { + info!("Fetching details for Sonarr series with ID: {series_id}"); + let event = SonarrEvent::GetSeriesDetails(series_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{series_id}")), + None, + ) + .await; + + self + .handle_request::<(), Series>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_series_history( + &mut self, + series_id: i64, + ) -> Result> { + info!("Fetching Sonarr series history for series with ID: {series_id}"); + let event = SonarrEvent::GetSeriesHistory(series_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("seriesId={series_id}")), + ) + .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 + } + + pub(in crate::network::sonarr_network) async fn list_series(&mut self) -> Result> { + info!("Fetching Sonarr library"); + let event = SonarrEvent::ListSeries; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut series_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, _) + ) { + series_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.sonarr_data.series.set_items(series_vec); + app.data.sonarr_data.series.apply_sorting_toggle(false); + } + }) + .await + } + + pub(in crate::network::sonarr_network) async fn search_sonarr_series( + &mut self, + query: String, + ) -> Result> { + info!("Searching for specific Sonarr series"); + let event = SonarrEvent::SearchNewSeries(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("term={}", encode(&query))), + ) + .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 + } + + pub(in crate::network::sonarr_network) async fn trigger_automatic_series_search( + &mut self, + series_id: i64, + ) -> Result { + let event = SonarrEvent::TriggerAutomaticSeriesSearch(series_id); + info!("Searching indexers for series with ID: {series_id}"); + + let body = SonarrCommandBody { + name: "SeriesSearch".to_owned(), + series_id: Some(series_id), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn update_all_series(&mut self) -> Result { + info!("Updating all series"); + let event = SonarrEvent::UpdateAllSeries; + let body = SonarrCommandBody { + name: "RefreshSeries".to_owned(), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn update_and_scan_series( + &mut self, + series_id: i64, + ) -> Result { + let event = SonarrEvent::UpdateAndScanSeries(series_id); + info!("Updating and scanning series with ID: {series_id}"); + let body = SonarrCommandBody { + name: "RefreshSeries".to_owned(), + series_id: Some(series_id), + ..SonarrCommandBody::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/sonarr_network/library/series/sonarr_series_network_tests.rs b/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs new file mode 100644 index 0000000..de74cfe --- /dev/null +++ b/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs @@ -0,0 +1,1106 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::sonarr_models::{ + AddSeriesBody, AddSeriesOptions, DeleteSeriesParams, EditSeriesParams, Series, SeriesType, + SonarrHistoryItem, SonarrSerdeable, + }; + use crate::models::stateful_table::{SortOption, StatefulTable}; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{ + add_series_search_result, history_item, season, series, SERIES_JSON, + }; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, NetworkResource, RequestMethod}; + use bimap::BiMap; + use mockito::Matcher; + use pretty_assertions::assert_eq; + use reqwest::Client; + use rstest::rstest; + use serde_json::{json, Value}; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_add_sonarr_series_event() { + let expected_add_series_body = AddSeriesBody { + tvdb_id: 1234, + title: "Test".to_owned(), + monitored: true, + root_folder_path: "/nfs2".to_owned(), + quality_profile_id: 2222, + language_profile_id: 2222, + series_type: "standard".to_owned(), + season_folder: true, + tags: Vec::new(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddSeriesOptions { + monitor: "all".to_owned(), + search_for_cutoff_unmet_episodes: true, + search_for_missing_episodes: true, + }, + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 1234, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(expected_add_series_body.clone()), + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(expected_add_series_body)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_add_sonarr_series_event_does_not_overwrite_tags_vec_when_tag_input_string_is_none( + ) { + let expected_add_series_body = AddSeriesBody { + tvdb_id: 1234, + title: "Test".to_owned(), + monitored: true, + root_folder_path: "/nfs2".to_owned(), + quality_profile_id: 2222, + language_profile_id: 2222, + series_type: "standard".to_owned(), + season_folder: true, + tags: vec![1, 2], + tag_input_string: None, + add_options: AddSeriesOptions { + monitor: "all".to_owned(), + search_for_cutoff_unmet_episodes: true, + search_for_missing_episodes: true, + }, + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 1234, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(expected_add_series_body.clone()), + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(expected_add_series_body)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_series_event() { + let delete_series_params = DeleteSeriesParams { + id: 1, + delete_series_files: true, + add_list_exclusion: true, + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteSeries(delete_series_params.clone()), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteSeries(delete_series_params)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("seasonFolder").unwrap() = json!(false); + *expected_body.get_mut("seriesType").unwrap() = json!("standard"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_series_params = EditSeriesParams { + series_id: 1, + monitored: Some(false), + use_season_folders: Some(false), + series_type: Some(SeriesType::Standard), + quality_profile_id: Some(1111), + language_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditSeriesParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(1), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + SonarrEvent::EditSeries(edit_series_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app_arc.lock().await.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(edit_series_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event_does_not_overwrite_tag_ids_vec_when_tag_input_string_is_none( + ) { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("seasonFolder").unwrap() = json!(false); + *expected_body.get_mut("seriesType").unwrap() = json!("standard"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_series_params = EditSeriesParams { + series_id: 1, + monitored: Some(false), + use_season_folders: Some(false), + series_type: Some(SeriesType::Standard), + quality_profile_id: Some(1111), + language_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tags: Some(vec![1, 2]), + ..EditSeriesParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(1), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + SonarrEvent::EditSeries(edit_series_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app_arc.lock().await.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(edit_series_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event_defaults_to_previous_values() { + let edit_series_params = EditSeriesParams { + series_id: 1, + ..EditSeriesParams::default() + }; + let expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(1), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + SonarrEvent::EditSeries(edit_series_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(edit_series_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event_returns_empty_tags_vec_when_clear_tags_is_true() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(1), + Some("/1"), + None, + ) + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + clear_tags: true, + ..EditSeriesParams::default() + }; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + SonarrEvent::EditSeries(edit_series_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(edit_series_params)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_toggle_series_monitoring_event() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(1), + Some("/1"), + None, + ) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1", + SonarrEvent::ToggleSeriesMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.seasons.set_items(vec![season()]); + } + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ToggleSeriesMonitoring(1)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_series_details_event() { + let expected_series: Series = serde_json::from_str(SERIES_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(1), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Series(series) = network + .handle_sonarr_event(SonarrEvent::GetSeriesDetails(1)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(series, expected_series); + } + } + + #[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": [{ "id": 1, "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": [{ "id": 1, "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(1), + 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); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(1)) + .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": [{ "id": 1, "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": [{ "id": 1, "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(1), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(1)) + .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": [{ "id": 1, "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": [{ "id": 1, "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(1), + 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()); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(1)) + .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); + } + } + + #[rstest] + #[tokio::test] + async fn test_handle_list_series_event(#[values(true, false)] use_custom_sorting: bool) { + let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *series_1.get_mut("id").unwrap() = json!(1); + *series_1.get_mut("title").unwrap() = json!("z test"); + *series_2.get_mut("id").unwrap() = json!(2); + *series_2.get_mut("title").unwrap() = json!("A test"); + let expected_series = vec![ + Series { + id: 1, + title: "z test".into(), + ..series() + }, + Series { + id: 2, + title: "A test".into(), + ..series() + }, + ]; + let mut expected_sorted_series = vec![ + Series { + id: 1, + title: "z test".into(), + ..series() + }, + Series { + id: 2, + title: "A test".into(), + ..series() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([series_1, series_2])), + None, + SonarrEvent::ListSeries, + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.series.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &Series, b: &Series| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + expected_sorted_series.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .sorting(vec![title_sort_option]); + } + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SeriesVec(series) = network + .handle_sonarr_event(SonarrEvent::ListSeries) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.series.items, + expected_sorted_series + ); + assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc); + assert_eq!(series, expected_series); + } + } + + #[tokio::test] + async fn test_handle_list_series_event_no_op_while_user_is_selecting_sort_options() { + let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *series_1.get_mut("id").unwrap() = json!(1); + *series_1.get_mut("title").unwrap() = json!("z test"); + *series_2.get_mut("id").unwrap() = json!(2); + *series_2.get_mut("title").unwrap() = json!("A test"); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([series_1, series_2])), + None, + SonarrEvent::ListSeries, + None, + None, + ) + .await; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); + app_arc.lock().await.data.sonarr_data.series.sort_asc = true; + let cmp_fn = |a: &Series, b: &Series| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .sorting(vec![title_sort_option]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ListSeries) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series + .items + .is_empty()); + assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc); + } + + #[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("test term".into()), + None, + Some("term=test%20term"), + ) + .await; + app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); + app_arc.lock().await.server_tabs.next(); + 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("test term".into())) + .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_no_results() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([])), + None, + SonarrEvent::SearchNewSeries("test term".into()), + None, + Some("term=test%20term"), + ) + .await; + app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::SearchNewSeries("test term".into())) + .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_trigger_automatic_series_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeriesSearch", + "seriesId": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeriesSearch(1), + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeriesSearch(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_update_all_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshSeries", + })), + Some(json!({})), + None, + SonarrEvent::UpdateAllSeries, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateAllSeries) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_update_and_scan_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshSeries", + "seriesId": 1, + })), + Some(json!({})), + None, + SonarrEvent::UpdateAndScanSeries(1), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateAndScanSeries(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } +} diff --git a/src/network/sonarr_network/library/sonarr_library_network_tests.rs b/src/network/sonarr_network/library/sonarr_library_network_tests.rs new file mode 100644 index 0000000..7e036a3 --- /dev/null +++ b/src/network/sonarr_network/library/sonarr_library_network_tests.rs @@ -0,0 +1,43 @@ +#[cfg(test)] +mod tests { + use crate::models::sonarr_models::SonarrReleaseDownloadBody; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, RequestMethod}; + use reqwest::Client; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_download_sonarr_release_event_uses_provided_params() { + let params = SonarrReleaseDownloadBody { + guid: "1234".to_owned(), + indexer_id: 2, + series_id: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "guid": "1234", + "indexerId": 2, + "seriesId": 1, + })), + Some(json!({})), + None, + SonarrEvent::DownloadRelease(params.clone()), + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DownloadRelease(params)) + .await + .is_ok()); + + async_server.assert_async().await; + } +} diff --git a/src/network/sonarr_network/mod.rs b/src/network/sonarr_network/mod.rs new file mode 100644 index 0000000..f97fce5 --- /dev/null +++ b/src/network/sonarr_network/mod.rs @@ -0,0 +1,503 @@ +use anyhow::Result; +use log::info; +use serde_json::{json, Value}; + +use super::{Network, NetworkEvent, NetworkResource}; +use crate::{ + models::{ + servarr_models::{AddRootFolderBody, EditIndexerParams, Language, QualityProfile, Tag}, + sonarr_models::{ + AddSeriesBody, DeleteSeriesParams, EditSeriesParams, IndexerSettings, + SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTaskName, + }, + }, + network::RequestMethod, +}; +#[cfg(test)] +#[path = "sonarr_network_tests.rs"] +mod sonarr_network_tests; + +#[cfg(test)] +#[path = "sonarr_network_test_utils.rs"] +mod sonarr_network_test_utils; + +mod blocklist; +mod downloads; +mod history; +mod indexers; +mod library; +mod root_folders; +mod system; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum SonarrEvent { + AddRootFolder(AddRootFolderBody), + AddSeries(AddSeriesBody), + AddTag(String), + ClearBlocklist, + DeleteBlocklistItem(i64), + DeleteDownload(i64), + DeleteEpisodeFile(i64), + DeleteIndexer(i64), + DeleteRootFolder(i64), + DeleteSeries(DeleteSeriesParams), + DeleteTag(i64), + DownloadRelease(SonarrReleaseDownloadBody), + EditAllIndexerSettings(IndexerSettings), + EditIndexer(EditIndexerParams), + EditSeries(EditSeriesParams), + GetAllIndexerSettings, + GetBlocklist, + GetDownloads(u64), + GetHistory(u64), + GetHostConfig, + GetIndexers, + GetEpisodeDetails(i64), + GetEpisodes(i64), + GetEpisodeFiles(i64), + GetEpisodeHistory(i64), + GetLanguageProfiles, + GetLogs(u64), + GetDiskSpace, + GetQualityProfiles, + GetQueuedEvents, + GetRootFolders, + GetEpisodeReleases(i64), + GetSeasonHistory((i64, i64)), + GetSeasonReleases((i64, i64)), + GetSecurityConfig, + GetSeriesDetails(i64), + GetSeriesHistory(i64), + GetStatus, + GetUpdates, + GetTags, + GetTasks, + HealthCheck, + ListSeries, + MarkHistoryItemAsFailed(i64), + SearchNewSeries(String), + StartTask(SonarrTaskName), + TestIndexer(i64), + TestAllIndexers, + ToggleSeasonMonitoring((i64, i64)), + ToggleSeriesMonitoring(i64), + ToggleEpisodeMonitoring(i64), + TriggerAutomaticEpisodeSearch(i64), + TriggerAutomaticSeasonSearch((i64, i64)), + TriggerAutomaticSeriesSearch(i64), + UpdateAllSeries, + UpdateAndScanSeries(i64), + UpdateDownloads, +} + +impl NetworkResource for SonarrEvent { + fn resource(&self) -> &'static str { + match &self { + SonarrEvent::AddTag(_) | SonarrEvent::DeleteTag(_) | SonarrEvent::GetTags => "/tag", + SonarrEvent::ClearBlocklist => "/blocklist/bulk", + SonarrEvent::DownloadRelease(_) => "/release", + SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", + SonarrEvent::GetAllIndexerSettings | SonarrEvent::EditAllIndexerSettings(_) => { + "/config/indexer" + } + SonarrEvent::GetEpisodeFiles(_) | SonarrEvent::DeleteEpisodeFile(_) => "/episodefile", + SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", + SonarrEvent::GetDownloads(_) | SonarrEvent::DeleteDownload(_) => "/queue", + SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", + SonarrEvent::GetHistory(_) | SonarrEvent::GetEpisodeHistory(_) => "/history", + SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", + SonarrEvent::GetIndexers | SonarrEvent::DeleteIndexer(_) | SonarrEvent::EditIndexer(_) => { + "/indexer" + } + SonarrEvent::GetLanguageProfiles => "/language", + SonarrEvent::GetLogs(_) => "/log", + SonarrEvent::GetDiskSpace => "/diskspace", + SonarrEvent::GetQualityProfiles => "/qualityprofile", + SonarrEvent::GetQueuedEvents + | SonarrEvent::StartTask(_) + | SonarrEvent::TriggerAutomaticSeriesSearch(_) + | SonarrEvent::TriggerAutomaticSeasonSearch(_) + | SonarrEvent::TriggerAutomaticEpisodeSearch(_) + | SonarrEvent::UpdateAllSeries + | SonarrEvent::UpdateAndScanSeries(_) + | SonarrEvent::UpdateDownloads => "/command", + SonarrEvent::GetRootFolders + | SonarrEvent::DeleteRootFolder(_) + | SonarrEvent::AddRootFolder(_) => "/rootfolder", + SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", + SonarrEvent::GetSeriesHistory(_) | SonarrEvent::GetSeasonHistory(_) => "/history/series", + SonarrEvent::GetStatus => "/system/status", + SonarrEvent::GetTasks => "/system/task", + SonarrEvent::GetUpdates => "/update", + SonarrEvent::HealthCheck => "/health", + SonarrEvent::AddSeries(_) + | SonarrEvent::ListSeries + | SonarrEvent::GetSeriesDetails(_) + | SonarrEvent::DeleteSeries(_) + | SonarrEvent::EditSeries(_) + | SonarrEvent::ToggleSeasonMonitoring(_) + | SonarrEvent::ToggleSeriesMonitoring(_) => "/series", + SonarrEvent::SearchNewSeries(_) => "/series/lookup", + SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", + SonarrEvent::TestIndexer(_) => "/indexer/test", + SonarrEvent::TestAllIndexers => "/indexer/testall", + SonarrEvent::ToggleEpisodeMonitoring(_) => "/episode/monitor", + } + } +} + +impl From for NetworkEvent { + fn from(sonarr_event: SonarrEvent) -> Self { + NetworkEvent::Sonarr(sonarr_event) + } +} + +impl Network<'_, '_> { + pub async fn handle_sonarr_event( + &mut self, + sonarr_event: SonarrEvent, + ) -> Result { + match sonarr_event { + SonarrEvent::AddRootFolder(path) => self + .add_sonarr_root_folder(path) + .await + .map(SonarrSerdeable::from), + SonarrEvent::AddSeries(body) => self + .add_sonarr_series(body) + .await + .map(SonarrSerdeable::from), + SonarrEvent::AddTag(tag) => self.add_sonarr_tag(tag).await.map(SonarrSerdeable::from), + SonarrEvent::ClearBlocklist => self + .clear_sonarr_blocklist() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetAllIndexerSettings => self + .get_all_sonarr_indexer_settings() + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteBlocklistItem(blocklist_item_id) => self + .delete_sonarr_blocklist_item(blocklist_item_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteDownload(download_id) => self + .delete_sonarr_download(download_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteEpisodeFile(episode_file_id) => self + .delete_sonarr_episode_file(episode_file_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteIndexer(indexer_id) => self + .delete_sonarr_indexer(indexer_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteRootFolder(root_folder_id) => self + .delete_sonarr_root_folder(root_folder_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteSeries(params) => { + self.delete_series(params).await.map(SonarrSerdeable::from) + } + SonarrEvent::DeleteTag(tag_id) => self + .delete_sonarr_tag(tag_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DownloadRelease(sonarr_release_download_body) => self + .download_sonarr_release(sonarr_release_download_body) + .await + .map(SonarrSerdeable::from), + SonarrEvent::EditAllIndexerSettings(params) => self + .edit_all_sonarr_indexer_settings(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::EditIndexer(params) => self + .edit_sonarr_indexer(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::EditSeries(params) => self + .edit_sonarr_series(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), + SonarrEvent::GetDownloads(count) => self + .get_sonarr_downloads(count) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodes(series_id) => self + .get_episodes(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeFiles(series_id) => self + .get_episode_files(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeDetails(episode_id) => self + .get_episode_details(episode_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeHistory(episode_id) => self + .get_sonarr_episode_history(episode_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetHistory(events) => self + .get_sonarr_history(events) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetHostConfig => self + .get_sonarr_host_config() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetIndexers => self.get_sonarr_indexers().await.map(SonarrSerdeable::from), + SonarrEvent::GetLanguageProfiles => self + .get_sonarr_language_profiles() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetLogs(events) => self + .get_sonarr_logs(events) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetDiskSpace => self.get_sonarr_diskspace().await.map(SonarrSerdeable::from), + SonarrEvent::GetQualityProfiles => self + .get_sonarr_quality_profiles() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetQueuedEvents => self + .get_queued_sonarr_events() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetRootFolders => self + .get_sonarr_root_folders() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeReleases(params) => self + .get_episode_releases(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetSeasonHistory(params) => self + .get_sonarr_season_history(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetSeasonReleases(params) => self + .get_season_releases(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetSecurityConfig => self + .get_sonarr_security_config() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetSeriesDetails(series_id) => self + .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::GetTags => self.get_sonarr_tags().await.map(SonarrSerdeable::from), + SonarrEvent::GetTasks => self.get_sonarr_tasks().await.map(SonarrSerdeable::from), + SonarrEvent::GetUpdates => self.get_sonarr_updates().await.map(SonarrSerdeable::from), + SonarrEvent::HealthCheck => self + .get_sonarr_healthcheck() + .await + .map(SonarrSerdeable::from), + SonarrEvent::ListSeries => self.list_series().await.map(SonarrSerdeable::from), + SonarrEvent::MarkHistoryItemAsFailed(history_item_id) => self + .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 + .map(SonarrSerdeable::from), + SonarrEvent::TestIndexer(indexer_id) => self + .test_sonarr_indexer(indexer_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::TestAllIndexers => self + .test_all_sonarr_indexers() + .await + .map(SonarrSerdeable::from), + SonarrEvent::ToggleEpisodeMonitoring(episode_id) => self + .toggle_sonarr_episode_monitoring(episode_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::ToggleSeasonMonitoring(params) => self + .toggle_sonarr_season_monitoring(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::ToggleSeriesMonitoring(series_id) => self + .toggle_sonarr_series_monitoring(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::TriggerAutomaticSeasonSearch(params) => self + .trigger_automatic_season_search(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::TriggerAutomaticSeriesSearch(series_id) => self + .trigger_automatic_series_search(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::TriggerAutomaticEpisodeSearch(episode_id) => self + .trigger_automatic_episode_search(episode_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::UpdateAllSeries => self.update_all_series().await.map(SonarrSerdeable::from), + SonarrEvent::UpdateAndScanSeries(series_id) => self + .update_and_scan_series(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::UpdateDownloads => self + .update_sonarr_downloads() + .await + .map(SonarrSerdeable::from), + } + } + + async fn add_sonarr_tag(&mut self, tag: String) -> Result { + info!("Adding a new Sonarr tag"); + let event = SonarrEvent::AddTag(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(json!({ "label": tag })), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |tag, mut app| { + app.data.sonarr_data.tags_map.insert(tag.id, tag.label); + }) + .await + } + + async fn delete_sonarr_tag(&mut self, id: i64) -> Result<()> { + info!("Deleting Sonarr tag with id: {id}"); + let event = SonarrEvent::DeleteTag(id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn get_sonarr_healthcheck(&mut self) -> Result<()> { + info!("Performing Sonarr health check"); + let event = SonarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn get_sonarr_language_profiles(&mut self) -> Result> { + info!("Fetching Sonarr language profiles"); + let event = SonarrEvent::GetLanguageProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |language_profiles_vec, mut app| { + app.data.sonarr_data.language_profiles_map = language_profiles_vec + .into_iter() + .map(|language| (language.id, language.name)) + .collect(); + }) + .await + } + + async fn get_sonarr_quality_profiles(&mut self) -> Result> { + info!("Fetching Sonarr quality profiles"); + let event = SonarrEvent::GetQualityProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { + app.data.sonarr_data.quality_profile_map = quality_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + async fn get_sonarr_tags(&mut self) -> Result> { + info!("Fetching Sonarr tags"); + let event = SonarrEvent::GetTags; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { + app.data.sonarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id, tag.label)) + .collect(); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn extract_and_add_sonarr_tag_ids_vec( + &mut self, + edit_tags: &str, + ) -> Vec { + let missing_tags_vec = { + let tags_map = &self.app.lock().await.data.sonarr_data.tags_map; + edit_tags + .split(',') + .filter(|&tag| { + !tag.is_empty() && tags_map.get_by_right(tag.to_lowercase().trim()).is_none() + }) + .collect::>() + }; + + for tag in missing_tags_vec { + self + .add_sonarr_tag(tag.trim().to_owned()) + .await + .expect("Unable to add tag"); + } + + let app = self.app.lock().await; + edit_tags + .split(',') + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .sonarr_data + .tags_map + .get_by_right(tag.to_lowercase().trim()) + .unwrap() + }) + .collect() + } +} diff --git a/src/network/sonarr_network/root_folders/mod.rs b/src/network/sonarr_network/root_folders/mod.rs new file mode 100644 index 0000000..277b76e --- /dev/null +++ b/src/network/sonarr_network/root_folders/mod.rs @@ -0,0 +1,75 @@ +use crate::models::servarr_models::{AddRootFolderBody, RootFolder}; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::{debug, info}; +use serde_json::Value; + +#[cfg(test)] +#[path = "sonarr_root_folders_network_tests.rs"] +mod sonarr_root_folders_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn add_sonarr_root_folder( + &mut self, + add_root_folder_body: AddRootFolderBody, + ) -> Result { + info!("Adding new root folder to Sonarr"); + let event = SonarrEvent::AddRootFolder(AddRootFolderBody::default()); + + debug!("Add root folder body: {add_root_folder_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(add_root_folder_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn delete_sonarr_root_folder( + &mut self, + root_folder_id: i64, + ) -> Result<()> { + let event = SonarrEvent::DeleteRootFolder(root_folder_id); + info!("Deleting Sonarr root folder for folder with id: {root_folder_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{root_folder_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_root_folders( + &mut self, + ) -> Result> { + info!("Fetching Sonarr root folders"); + let event = SonarrEvent::GetRootFolders; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |root_folders, mut app| { + app.data.sonarr_data.root_folders.set_items(root_folders); + }) + .await + } +} diff --git a/src/network/sonarr_network/root_folders/sonarr_root_folders_network_tests.rs b/src/network/sonarr_network/root_folders/sonarr_root_folders_network_tests.rs new file mode 100644 index 0000000..b2a79e3 --- /dev/null +++ b/src/network/sonarr_network/root_folders/sonarr_root_folders_network_tests.rs @@ -0,0 +1,107 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_models::{AddRootFolderBody, RootFolder}; + use crate::models::sonarr_models::SonarrSerdeable; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::root_folder; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, RequestMethod}; + use pretty_assertions::assert_eq; + use reqwest::Client; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_add_sonarr_root_folder_event() { + let expected_add_root_folder_body = AddRootFolderBody { + path: "/nfs/test".to_owned(), + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "path": "/nfs/test" + })), + Some(json!({})), + None, + SonarrEvent::AddRootFolder(expected_add_root_folder_body.clone()), + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .edit_root_folder + .is_none()); + } + + #[tokio::test] + async fn test_handle_delete_sonarr_root_folder_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteRootFolder(1), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteRootFolder(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_sonarr_root_folders_event() { + let root_folder_json = json!([{ + "id": 1, + "path": "/nfs", + "accessible": true, + "freeSpace": 219902325555200u64, + }]); + let response: Vec = serde_json::from_value(root_folder_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(root_folder_json), + None, + SonarrEvent::GetRootFolders, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::RootFolders(root_folders) = network + .handle_sonarr_event(SonarrEvent::GetRootFolders) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.root_folders.items, + vec![root_folder()] + ); + assert_eq!(root_folders, response); + } + } +} diff --git a/src/network/sonarr_network/sonarr_network_test_utils.rs b/src/network/sonarr_network/sonarr_network_test_utils.rs new file mode 100644 index 0000000..d3e0d4b --- /dev/null +++ b/src/network/sonarr_network/sonarr_network_test_utils.rs @@ -0,0 +1,395 @@ +#[cfg(test)] +pub(in crate::network::sonarr_network) mod test_utils { + use crate::models::servarr_models::{ + Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + }; + use crate::models::sonarr_models::{ + AddSeriesSearchResult, AddSeriesSearchResultStatistics, BlocklistItem, DownloadRecord, + DownloadStatus, DownloadsResponse, Episode, EpisodeFile, IndexerSettings, MediaInfo, Rating, + Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType, + SonarrHistoryData, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, + }; + use crate::models::HorizontallyScrollableText; + use chrono::DateTime; + use serde_json::{json, Number}; + + pub const SERIES_JSON: &str = r#"{ + "title": "Test", + "status": "continuing", + "ended": false, + "overview": "Blah blah blah", + "network": "HBO", + "seasons": [ + { + "seasonNumber": 1, + "monitored": true, + "statistics": { + "previousAiring": "2022-10-24T01:00:00Z", + "episodeFileCount": 10, + "episodeCount": 10, + "totalEpisodeCount": 10, + "sizeOnDisk": 36708563419, + "percentOfEpisodes": 100.0 + } + } + ], + "year": 2022, + "path": "/nfs/tv/Test", + "qualityProfileId": 6, + "languageProfileId": 1, + "seasonFolder": true, + "monitored": true, + "runtime": 63, + "tvdbId": 371572, + "seriesType": "standard", + "certification": "TV-MA", + "genres": ["cool", "family", "fun"], + "tags": [3], + "ratings": {"votes": 406744, "value": 8.4}, + "statistics": { + "seasonCount": 2, + "episodeFileCount": 18, + "episodeCount": 18, + "totalEpisodeCount": 50, + "sizeOnDisk": 63894022699, + "percentOfEpisodes": 100.0 + }, + "id": 1 + } +"#; + + pub const EPISODE_JSON: &str = r#"{ + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "episodeFile": { + "id": 1, + "relativePath": "/season 1/episode 1.mkv", + "path": "/nfs/tv/series/season 1/episode 1.mkv", + "size": 3543348019, + "dateAdded": "2024-02-10T07:28:45Z", + "languages": [{ "id": 1, "name": "English" }], + "quality": { "quality": { "name": "Bluray-1080p" } }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 7.1, + "audioCodec": "AAC", + "audioLanguages": "eng", + "audioStreamCount": 1, + "videoBitDepth": 10, + "videoBitrate": 0, + "videoCodec": "x265", + "videoFps": 23.976, + "resolution": "1920x1080", + "runTime": "23:51", + "scanType": "Progressive", + "subtitles": "English" + } + }, + "hasFile": true, + "monitored": true, + "id": 1 + }"#; + + pub fn add_series_search_result() -> AddSeriesSearchResult { + AddSeriesSearchResult { + tvdb_id: 1234, + title: HorizontallyScrollableText::from("Test"), + status: Some("continuing".to_owned()), + ended: false, + overview: Some("New series blah blah blah".to_owned()), + genres: genres(), + year: 2023, + network: Some("Prime Video".to_owned()), + runtime: 60, + ratings: Some(rating()), + statistics: Some(add_series_search_result_statistics()), + } + } + + pub fn add_series_search_result_statistics() -> AddSeriesSearchResultStatistics { + AddSeriesSearchResultStatistics { season_count: 3 } + } + + pub fn blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 1, + series_id: 1, + series_title: None, + episode_ids: vec![Number::from(1)], + source_title: "Test Source Title".to_owned(), + languages: vec![language()], + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + protocol: "usenet".to_owned(), + indexer: "NZBgeek (Prowlarr)".to_owned(), + message: "test message".to_owned(), + } + } + + pub fn download_record() -> DownloadRecord { + DownloadRecord { + title: "Test Download Title".to_owned(), + status: DownloadStatus::Downloading, + id: 1, + episode_id: Some(Number::from(1i64)), + size: 3543348019f64, + sizeleft: 1771674009f64, + output_path: Some(HorizontallyScrollableText::from( + "/nfs/tv/Test show/season 1/", + )), + indexer: "kickass torrents".to_owned(), + download_client: Some("transmission".to_owned()), + } + } + + pub fn downloads_response() -> DownloadsResponse { + DownloadsResponse { + records: vec![download_record()], + } + } + + pub fn episode() -> Episode { + Episode { + id: 1, + series_id: 1, + tvdb_id: 1234, + episode_file_id: 1, + season_number: 1, + episode_number: 1, + title: "Something cool".to_owned(), + air_date_utc: Some(DateTime::from( + DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap(), + )), + overview: Some("Okay so this one time at band camp...".to_owned()), + has_file: true, + monitored: true, + episode_file: Some(episode_file()), + } + } + + pub fn episode_file() -> EpisodeFile { + EpisodeFile { + id: 1, + relative_path: "/season 1/episode 1.mkv".to_owned(), + path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), + size: 3543348019, + quality: quality_wrapper(), + languages: vec![language()], + date_added: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + media_info: Some(media_info()), + } + } + + pub fn genres() -> Vec { + vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()] + } + + pub fn history_data() -> SonarrHistoryData { + SonarrHistoryData { + dropped_path: Some("/nfs/nzbget/completed/series/Coolness/something.cool.mkv".to_owned()), + imported_path: Some( + "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv".to_owned(), + ), + ..SonarrHistoryData::default() + } + } + + pub 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: SonarrHistoryEventType::Grabbed, + data: history_data(), + } + } + + pub fn indexer() -> Indexer { + Indexer { + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + supports_rss: true, + supports_search: true, + protocol: "torrent".to_owned(), + priority: 25, + download_client_id: 0, + name: Some("Test Indexer".to_owned()), + implementation_name: Some("Torznab".to_owned()), + implementation: Some("Torznab".to_owned()), + config_contract: Some("TorznabSettings".to_owned()), + tags: vec![Number::from(1)], + id: 1, + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(json!("https://test.com")), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(json!("")), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(json!("1.2")), + }, + ]), + } + } + + pub fn indexer_settings() -> IndexerSettings { + IndexerSettings { + id: 1, + minimum_age: 1, + retention: 1, + maximum_size: 12345, + rss_sync_interval: 60, + } + } + + pub fn language() -> Language { + Language { + id: 1, + name: "English".to_owned(), + } + } + + pub fn media_info() -> MediaInfo { + MediaInfo { + audio_bitrate: 0, + audio_channels: Number::from_f64(7.1).unwrap(), + audio_codec: Some("AAC".to_owned()), + audio_languages: Some("eng".to_owned()), + audio_stream_count: 1, + video_bit_depth: 10, + video_bitrate: 0, + video_codec: Some("x265".to_owned()), + video_fps: Number::from_f64(23.976).unwrap(), + resolution: "1920x1080".to_owned(), + run_time: "23:51".to_owned(), + scan_type: "Progressive".to_owned(), + subtitles: Some("English".to_owned()), + } + } + pub fn quality() -> Quality { + Quality { + name: "Bluray-1080p".to_owned(), + } + } + + pub fn quality_wrapper() -> QualityWrapper { + QualityWrapper { quality: quality() } + } + + pub fn rating() -> Rating { + Rating { + votes: 406744, + value: 8.4, + } + } + + pub fn season() -> Season { + Season { + title: None, + season_number: 1, + monitored: true, + statistics: Some(season_statistics()), + } + } + + pub fn season_statistics() -> SeasonStatistics { + SeasonStatistics { + previous_airing: Some(DateTime::from( + DateTime::parse_from_rfc3339("2022-10-24T01:00:00Z").unwrap(), + )), + next_airing: None, + episode_file_count: 10, + episode_count: 10, + total_episode_count: 10, + size_on_disk: 36708563419, + percent_of_episodes: 100.0, + } + } + + pub fn series() -> Series { + Series { + title: "Test".to_owned().into(), + status: SeriesStatus::Continuing, + ended: false, + overview: Some("Blah blah blah".to_owned()), + network: Some("HBO".to_owned()), + seasons: Some(vec![season()]), + year: 2022, + path: "/nfs/tv/Test".to_owned(), + quality_profile_id: 6, + language_profile_id: 1, + season_folder: true, + monitored: true, + runtime: 63, + tvdb_id: 371572, + series_type: SeriesType::Standard, + certification: Some("TV-MA".to_owned()), + genres: vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()], + tags: vec![Number::from(3)], + ratings: rating(), + statistics: Some(series_statistics()), + id: 1, + } + } + + pub fn series_statistics() -> SeriesStatistics { + SeriesStatistics { + season_count: 2, + episode_file_count: 18, + episode_count: 18, + total_episode_count: 50, + size_on_disk: 63894022699, + percent_of_episodes: 100.0, + } + } + + pub fn rejections() -> Vec { + vec![ + "Unknown quality profile".to_owned(), + "Release is already mapped".to_owned(), + ] + } + + pub fn release() -> SonarrRelease { + SonarrRelease { + guid: "1234".to_owned(), + protocol: "torrent".to_owned(), + age: 1, + title: HorizontallyScrollableText::from("Test Release"), + indexer: "kickass torrents".to_owned(), + indexer_id: 2, + size: 1234, + rejected: true, + rejections: Some(rejections()), + seeders: Some(Number::from(2)), + leechers: Some(Number::from(1)), + languages: Some(vec![language()]), + quality: quality_wrapper(), + full_season: false, + } + } + + pub fn root_folder() -> RootFolder { + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + } + } +} diff --git a/src/network/sonarr_network/sonarr_network_tests.rs b/src/network/sonarr_network/sonarr_network_tests.rs new file mode 100644 index 0000000..23e5d26 --- /dev/null +++ b/src/network/sonarr_network/sonarr_network_tests.rs @@ -0,0 +1,434 @@ +#[cfg(test)] +mod test { + use crate::app::App; + use crate::models::servarr_data::sonarr::modals::AddSeriesModal; + use crate::models::servarr_models::{ + AddRootFolderBody, EditIndexerParams, Language, QualityProfile, Tag, + }; + use crate::models::sonarr_models::{ + AddSeriesBody, EditSeriesParams, IndexerSettings, SonarrTaskName, + }; + use crate::models::sonarr_models::{DeleteSeriesParams, SonarrSerdeable}; + use crate::network::{ + network_tests::test_utils::mock_servarr_api, sonarr_network::SonarrEvent, Network, + NetworkEvent, NetworkResource, RequestMethod, + }; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use reqwest::Client; + use rstest::rstest; + use serde_json::json; + use std::sync::Arc; + use tokio::sync::Mutex; + use tokio_util::sync::CancellationToken; + + #[rstest] + fn test_resource_all_indexer_settings( + #[values( + SonarrEvent::GetAllIndexerSettings, + SonarrEvent::EditAllIndexerSettings(IndexerSettings::default()) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/indexer"); + } + + #[rstest] + fn test_resource_episode( + #[values(SonarrEvent::GetEpisodes(0), SonarrEvent::GetEpisodeDetails(0))] event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/episode"); + } + + #[rstest] + fn test_resource_series( + #[values( + SonarrEvent::AddSeries(AddSeriesBody::default()), + SonarrEvent::ListSeries, + SonarrEvent::GetSeriesDetails(0), + SonarrEvent::DeleteSeries(DeleteSeriesParams::default()), + SonarrEvent::EditSeries(EditSeriesParams::default()), + SonarrEvent::ToggleSeasonMonitoring((0, 0)), + SonarrEvent::ToggleSeriesMonitoring(0), + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/series"); + } + + #[rstest] + fn test_resource_tag( + #[values( + SonarrEvent::AddTag(String::new()), + SonarrEvent::DeleteTag(0), + SonarrEvent::GetTags + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/tag"); + } + + #[rstest] + fn test_resource_host_config( + #[values(SonarrEvent::GetHostConfig, SonarrEvent::GetSecurityConfig)] event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/host"); + } + + #[rstest] + fn test_resource_command( + #[values( + SonarrEvent::GetQueuedEvents, + SonarrEvent::StartTask(SonarrTaskName::default()), + SonarrEvent::TriggerAutomaticEpisodeSearch(0), + SonarrEvent::TriggerAutomaticSeasonSearch((0, 0)), + SonarrEvent::TriggerAutomaticSeriesSearch(0), + SonarrEvent::UpdateAllSeries, + SonarrEvent::UpdateAndScanSeries(0), + SonarrEvent::UpdateDownloads + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/command"); + } + + #[rstest] + fn test_resource_indexer( + #[values( + SonarrEvent::GetIndexers, + SonarrEvent::DeleteIndexer(0), + SonarrEvent::EditIndexer(EditIndexerParams::default()) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/indexer"); + } + + #[rstest] + fn test_resource_history( + #[values(SonarrEvent::GetHistory(0), SonarrEvent::GetEpisodeHistory(0))] event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/history"); + } + + #[rstest] + fn test_resource_series_history( + #[values( + SonarrEvent::GetSeriesHistory(0), + SonarrEvent::GetSeasonHistory((0, 0)) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/history/series"); + } + + #[rstest] + fn test_resource_queue( + #[values(SonarrEvent::GetDownloads(0), SonarrEvent::DeleteDownload(0))] event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/queue"); + } + + #[rstest] + fn test_resource_root_folder( + #[values( + SonarrEvent::GetRootFolders, + SonarrEvent::DeleteRootFolder(0), + SonarrEvent::AddRootFolder(AddRootFolderBody::default()) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/rootfolder"); + } + + #[rstest] + fn test_resource_release( + #[values( + SonarrEvent::GetSeasonReleases((0, 0)), + SonarrEvent::GetEpisodeReleases(0) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/release"); + } + + #[rstest] + fn test_resource_episode_file( + #[values(SonarrEvent::GetEpisodeFiles(0), SonarrEvent::DeleteEpisodeFile(0))] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/episodefile"); + } + + #[rstest] + #[case(SonarrEvent::ClearBlocklist, "/blocklist/bulk")] + #[case(SonarrEvent::DeleteBlocklistItem(0), "/blocklist")] + #[case(SonarrEvent::HealthCheck, "/health")] + #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] + #[case(SonarrEvent::GetDiskSpace, "/diskspace")] + #[case(SonarrEvent::GetLanguageProfiles, "/language")] + #[case(SonarrEvent::GetLogs(500), "/log")] + #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(SonarrEvent::GetStatus, "/system/status")] + #[case(SonarrEvent::GetTasks, "/system/task")] + #[case(SonarrEvent::GetUpdates, "/update")] + #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] + #[case(SonarrEvent::SearchNewSeries(String::new()), "/series/lookup")] + #[case(SonarrEvent::TestIndexer(0), "/indexer/test")] + #[case(SonarrEvent::TestAllIndexers, "/indexer/testall")] + #[case(SonarrEvent::ToggleEpisodeMonitoring(0), "/episode/monitor")] + fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { + assert_str_eq!(event.resource(), expected_uri); + } + + #[test] + fn test_from_sonarr_event() { + assert_eq!( + NetworkEvent::Sonarr(SonarrEvent::HealthCheck), + NetworkEvent::from(SonarrEvent::HealthCheck) + ); + } + + #[tokio::test] + async fn test_handle_add_sonarr_tag() { + let tag_json = json!({ "id": 3, "label": "testing" }); + let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ "label": "testing" })), + Some(tag_json), + None, + SonarrEvent::AddTag(String::new()), + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Tag(tag) = network + .handle_sonarr_event(SonarrEvent::AddTag("testing".to_owned())) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + assert_eq!(tag, response); + } + } + + #[tokio::test] + async fn test_handle_delete_sonarr_tag_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteTag(1), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteTag(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_sonarr_healthcheck_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + None, + None, + SonarrEvent::HealthCheck, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let _ = network.handle_sonarr_event(SonarrEvent::HealthCheck).await; + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_sonarr_language_profiles_event() { + let language_profiles_json = json!([{ + "id": 2222, + "name": "English" + }]); + let response: Vec = serde_json::from_value(language_profiles_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(language_profiles_json), + None, + SonarrEvent::GetLanguageProfiles, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::LanguageProfiles(language_profiles) = network + .handle_sonarr_event(SonarrEvent::GetLanguageProfiles) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.language_profiles_map, + BiMap::from_iter([(2222i64, "English".to_owned())]) + ); + assert_eq!(language_profiles, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_quality_profiles_event() { + let quality_profile_json = json!([{ + "id": 2222, + "name": "HD - 1080p" + }]); + let response: Vec = + serde_json::from_value(quality_profile_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(quality_profile_json), + None, + SonarrEvent::GetQualityProfiles, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::QualityProfiles(quality_profiles) = network + .handle_sonarr_event(SonarrEvent::GetQualityProfiles) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.quality_profile_map, + BiMap::from_iter([(2222i64, "HD - 1080p".to_owned())]) + ); + assert_eq!(quality_profiles, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_tags_event() { + let tags_json = json!([{ + "id": 2222, + "label": "usenet" + }]); + let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(tags_json), + None, + SonarrEvent::GetTags, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Tags(tags) = network + .handle_sonarr_event(SonarrEvent::GetTags) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([(2222i64, "usenet".to_owned())]) + ); + assert_eq!(tags, response); + } + } + + #[tokio::test] + async fn test_extract_and_add_sonarr_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::test_default())); + let tags = " test,HI ,, usenet "; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert_eq!( + network.extract_and_add_sonarr_tag_ids_vec(tags).await, + vec![2, 3, 1] + ); + } + + #[tokio::test] + async fn test_extract_and_add_sonarr_tag_ids_vec_add_missing_tags_first() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ "label": "TESTING" })), + Some(json!({ "id": 3, "label": "testing" })), + None, + SonarrEvent::GetTags, + None, + None, + ) + .await; + let tags = "usenet, test, TESTING"; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal { + tags: tags.into(), + ..AddSeriesModal::default() + }); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let tag_ids_vec = network.extract_and_add_sonarr_tag_ids_vec(tags).await; + + async_server.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } +} diff --git a/src/network/sonarr_network/system/mod.rs b/src/network/sonarr_network/system/mod.rs new file mode 100644 index 0000000..63798f2 --- /dev/null +++ b/src/network/sonarr_network/system/mod.rs @@ -0,0 +1,266 @@ +use crate::models::servarr_models::{ + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, +}; +use crate::models::sonarr_models::{SonarrTask, SonarrTaskName, SystemStatus}; +use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; +use crate::network::sonarr_network::SonarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use indoc::formatdoc; +use log::info; +use serde_json::Value; + +#[cfg(test)] +#[path = "sonarr_system_network_tests.rs"] +mod sonarr_system_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::sonarr_network) async fn get_sonarr_host_config( + &mut self, + ) -> Result { + info!("Fetching Sonarr host config"); + let event = SonarrEvent::GetHostConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), HostConfig>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_logs( + &mut self, + events: u64, + ) -> Result { + info!("Fetching Sonarr logs"); + let event = SonarrEvent::GetLogs(events); + + let params = format!("pageSize={events}&sortDirection=descending&sortKey=time"); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), LogResponse>(request_props, |log_response, mut app| { + let mut logs = log_response.records; + logs.reverse(); + + let log_lines = logs + .into_iter() + .map(|log| { + if log.exception.is_some() { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().unwrap(), + log.exception_type.as_ref().unwrap(), + log.exception.as_ref().unwrap() + )) + } else { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().unwrap(), + log.message.as_ref().unwrap() + )) + } + }) + .collect(); + + app.data.sonarr_data.logs.set_items(log_lines); + app.data.sonarr_data.logs.scroll_to_bottom(); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_diskspace( + &mut self, + ) -> Result> { + info!("Fetching Sonarr disk space"); + let event = SonarrEvent::GetDiskSpace; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { + app.data.sonarr_data.disk_space_vec = disk_space_vec; + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_queued_sonarr_events( + &mut self, + ) -> Result> { + info!("Fetching Sonarr queued events"); + let event = SonarrEvent::GetQueuedEvents; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |queued_events_vec, mut app| { + app + .data + .sonarr_data + .queued_events + .set_items(queued_events_vec); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_security_config( + &mut self, + ) -> Result { + info!("Fetching Sonarr security config"); + let event = SonarrEvent::GetSecurityConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_status( + &mut self, + ) -> Result { + info!("Fetching Sonarr system status"); + let event = SonarrEvent::GetStatus; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { + app.data.sonarr_data.version = system_status.version; + app.data.sonarr_data.start_time = system_status.start_time; + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_tasks( + &mut self, + ) -> Result> { + info!("Fetching Sonarr tasks"); + let event = SonarrEvent::GetTasks; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { + app.data.sonarr_data.tasks.set_items(tasks_vec); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn get_sonarr_updates( + &mut self, + ) -> Result> { + info!("Fetching Sonarr updates"); + let event = SonarrEvent::GetUpdates; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |updates_vec, mut app| { + let latest_installed = if updates_vec + .iter() + .any(|update| update.latest && update.installed_on.is_some()) + { + "already".to_owned() + } else { + "not".to_owned() + }; + let updates = updates_vec + .into_iter() + .map(|update| { + let install_status = if update.installed_on.is_some() { + if update.installed { + "(Currently Installed)".to_owned() + } else { + "(Previously Installed)".to_owned() + } + } else { + String::new() + }; + let vec_to_bullet_points = |vec: Vec| { + vec + .iter() + .map(|change| format!(" * {change}")) + .collect::>() + .join("\n") + }; + + let mut update_info = formatdoc!( + "{} - {} {install_status} + {}", + update.version, + update.release_date, + "-".repeat(200) + ); + + if let Some(new_changes) = update.changes.new { + let changes = vec_to_bullet_points(new_changes); + update_info = formatdoc!( + "{update_info} + New: + {changes}" + ) + } + + if let Some(fixes) = update.changes.fixed { + let fixes = vec_to_bullet_points(fixes); + update_info = formatdoc!( + "{update_info} + Fixed: + {fixes}" + ); + } + + update_info + }) + .reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}")) + .unwrap(); + + app.data.sonarr_data.updates = ScrollableText::with_string(formatdoc!( + "The latest version of Sonarr is {latest_installed} installed + + {updates}" + )); + }) + .await + } + + pub(in crate::network::sonarr_network) async fn start_sonarr_task( + &mut self, + task: SonarrTaskName, + ) -> Result { + let event = SonarrEvent::StartTask(task); + let task_name = task.to_string(); + info!("Starting Sonarr task: {task_name}"); + + let body = CommandBody { name: task_name }; + + 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/sonarr_network/system/sonarr_system_network_tests.rs b/src/network/sonarr_network/system/sonarr_system_network_tests.rs new file mode 100644 index 0000000..4ee3f37 --- /dev/null +++ b/src/network/sonarr_network/system/sonarr_system_network_tests.rs @@ -0,0 +1,492 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_models::{ + DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + }; + use crate::models::sonarr_models::{SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus}; + use crate::models::{HorizontallyScrollableText, ScrollableText}; + use crate::network::network_tests::test_utils::mock_servarr_api; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::{Network, RequestMethod}; + use chrono::DateTime; + use indoc::formatdoc; + use pretty_assertions::{assert_eq, assert_str_eq}; + use reqwest::Client; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + #[tokio::test] + async fn test_handle_get_sonarr_host_config_event() { + let host_config_response = json!({ + "bindAddress": "*", + "port": 7878, + "urlBase": "some.test.site/sonarr", + "instanceName": "Sonarr", + "applicationUrl": "https://some.test.site:7878/sonarr", + "enableSsl": true, + "sslPort": 9898, + "sslCertPath": "/app/sonarr.pfx", + "sslCertPassword": "test" + }); + let response: HostConfig = serde_json::from_value(host_config_response.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(host_config_response), + None, + SonarrEvent::GetHostConfig, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::HostConfig(host_config) = network + .handle_sonarr_event(SonarrEvent::GetHostConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(host_config, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_logs_event() { + let expected_logs = vec![ + HorizontallyScrollableText::from( + "2023-05-20 21:29:16 UTC|FATAL|SonarrError|Some.Big.Bad.Exception|test exception", + ), + HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), + ]; + let logs_response_json = json!({ + "page": 1, + "pageSize": 500, + "sortKey": "time", + "sortDirection": "descending", + "totalRecords": 2, + "records": [ + { + "time": "2023-05-20T21:29:16Z", + "level": "info", + "logger": "TestLogger", + "message": "test message", + "id": 1 + }, + { + "time": "2023-05-20T21:29:16Z", + "level": "fatal", + "logger": "SonarrError", + "exception": "test exception", + "exceptionType": "Some.Big.Bad.Exception", + "id": 2 + } + ] + }); + let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(logs_response_json), + None, + SonarrEvent::GetLogs(500), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::LogResponse(logs) = network + .handle_sonarr_event(SonarrEvent::GetLogs(500)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.logs.items, + expected_logs + ); + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .logs + .current_selection() + .text + .contains("INFO")); + assert_eq!(logs, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_diskspace_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([ + { + "freeSpace": 1111, + "totalSpace": 2222, + }, + { + "freeSpace": 3333, + "totalSpace": 4444 + } + ])), + None, + SonarrEvent::GetDiskSpace, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let disk_space_vec = vec![ + DiskSpace { + free_space: 1111, + total_space: 2222, + }, + DiskSpace { + free_space: 3333, + total_space: 4444, + }, + ]; + + if let SonarrSerdeable::DiskSpaces(disk_space) = network + .handle_sonarr_event(SonarrEvent::GetDiskSpace) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.disk_space_vec, + disk_space_vec + ); + assert_eq!(disk_space, disk_space_vec); + } + } + + #[tokio::test] + async fn test_handle_get_queued_sonarr_events_event() { + let queued_events_json = json!([{ + "name": "RefreshMonitoredDownloads", + "commandName": "Refresh Monitored Downloads", + "status": "completed", + "queued": "2023-05-20T21:29:16Z", + "started": "2023-05-20T21:29:16Z", + "ended": "2023-05-20T21:29:16Z", + "duration": "00:00:00.5111547", + "trigger": "scheduled", + }]); + let response: Vec = serde_json::from_value(queued_events_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_event = QueueEvent { + name: "RefreshMonitoredDownloads".to_owned(), + command_name: "Refresh Monitored Downloads".to_owned(), + status: "completed".to_owned(), + queued: timestamp, + started: Some(timestamp), + ended: Some(timestamp), + duration: Some("00:00:00.5111547".to_owned()), + trigger: "scheduled".to_owned(), + }; + + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(queued_events_json), + None, + SonarrEvent::GetQueuedEvents, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::QueueEvents(events) = network + .handle_sonarr_event(SonarrEvent::GetQueuedEvents) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.queued_events.items, + vec![expected_event] + ); + assert_eq!(events, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_security_config_event() { + let security_config_response = json!({ + "authenticationMethod": "forms", + "authenticationRequired": "disabledForLocalAddresses", + "username": "test", + "password": "some password", + "apiKey": "someApiKey12345", + "certificateValidation": "disabledForLocalAddresses", + }); + let response: SecurityConfig = + serde_json::from_value(security_config_response.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(security_config_response), + None, + SonarrEvent::GetSecurityConfig, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SecurityConfig(security_config) = network + .handle_sonarr_event(SonarrEvent::GetSecurityConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(security_config, response); + } + } + + #[tokio::test] + async fn test_handle_get_status_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!({ + "version": "v1", + "startTime": "2023-02-25T20:16:43Z" + })), + None, + SonarrEvent::GetStatus, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let date_time = DateTime::from(DateTime::parse_from_rfc3339("2023-02-25T20:16:43Z").unwrap()); + + if let SonarrSerdeable::SystemStatus(status) = network + .handle_sonarr_event(SonarrEvent::GetStatus) + .await + .unwrap() + { + async_server.assert_async().await; + assert_str_eq!(app_arc.lock().await.data.sonarr_data.version, "v1"); + assert_eq!(app_arc.lock().await.data.sonarr_data.start_time, date_time); + assert_eq!( + status, + SystemStatus { + version: "v1".to_owned(), + start_time: date_time + } + ); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_tasks_event() { + let tasks_json = json!([{ + "name": "Application Update Check", + "taskName": "ApplicationUpdateCheck", + "interval": 360, + "lastExecution": "2023-05-20T21:29:16Z", + "nextExecution": "2023-05-20T21:29:16Z", + }, + { + "name": "Backup", + "taskName": "Backup", + "interval": 10080, + "lastExecution": "2023-05-20T21:29:16Z", + "nextExecution": "2023-05-20T21:29:16Z", + }]); + let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_tasks = vec![ + SonarrTask { + name: "Application Update Check".to_owned(), + task_name: SonarrTaskName::ApplicationUpdateCheck, + interval: 360, + last_execution: timestamp, + next_execution: timestamp, + }, + SonarrTask { + name: "Backup".to_owned(), + task_name: SonarrTaskName::Backup, + interval: 10080, + last_execution: timestamp, + next_execution: timestamp, + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(tasks_json), + None, + SonarrEvent::GetTasks, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Tasks(tasks) = network + .handle_sonarr_event(SonarrEvent::GetTasks) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.tasks.items, + expected_tasks + ); + assert_eq!(tasks, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_updates_event() { + let updates_json = json!([{ + "version": "4.3.2.1", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": true, + "installedOn": "2023-04-15T02:02:53Z", + "latest": true, + "changes": { + "new": [ + "Cool new thing" + ], + "fixed": [ + "Some bugs killed" + ] + }, + }, + { + "version": "3.2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "installedOn": "2023-04-15T02:02:53Z", + "latest": false, + "changes": { + "new": [ + "Cool new thing (old)", + "Other cool new thing (old)" + ], + }, + }, + { + "version": "2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + "changes": { + "fixed": [ + "Killed bug 1", + "Fixed bug 2" + ] + }, + }]); + let response: Vec = serde_json::from_value(updates_json.clone()).unwrap(); + let line_break = "-".repeat(200); + let expected_text = ScrollableText::with_string(formatdoc!( + " + The latest version of Sonarr is already installed + + 4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) + {line_break} + New: + * Cool new thing + Fixed: + * Some bugs killed + + + 3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) + {line_break} + New: + * Cool new thing (old) + * Other cool new thing (old) + + + 2.1.0 - 2023-04-15 02:02:53 UTC + {line_break} + Fixed: + * Killed bug 1 + * Fixed bug 2" + )); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(updates_json), + None, + SonarrEvent::GetUpdates, + None, + None, + ) + .await; + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Updates(updates) = network + .handle_sonarr_event(SonarrEvent::GetUpdates) + .await + .unwrap() + { + async_server.assert_async().await; + assert_str_eq!( + app_arc.lock().await.data.sonarr_data.updates.get_text(), + expected_text.get_text() + ); + assert_eq!(updates, response); + } + } + + #[tokio::test] + async fn test_handle_start_sonarr_task_event() { + let response = json!({ "test": "test"}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "ApplicationUpdateCheck" + })), + Some(response.clone()), + None, + SonarrEvent::StartTask(SonarrTaskName::ApplicationUpdateCheck), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask { + task_name: SonarrTaskName::default(), + ..SonarrTask::default() + }]); + app_arc.lock().await.server_tabs.next(); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::StartTask( + SonarrTaskName::ApplicationUpdateCheck, + )) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(value, response); + } + } +} diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs deleted file mode 100644 index 2318b8e..0000000 --- a/src/network/sonarr_network_tests.rs +++ /dev/null @@ -1,5840 +0,0 @@ -#[cfg(test)] -mod test { - use std::slice; - use std::sync::Arc; - - use bimap::BiMap; - use chrono::DateTime; - use indoc::formatdoc; - use mockito::Matcher; - use pretty_assertions::{assert_eq, assert_str_eq}; - use reqwest::Client; - use rstest::rstest; - use serde_json::json; - use serde_json::{Number, Value}; - use tokio::sync::Mutex; - use tokio_util::sync::CancellationToken; - - use crate::models::sonarr_models::{ - AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, - DownloadStatus, EditSeriesParams, IndexerSettings, MonitorEpisodeBody, SonarrHistoryEventType, - }; - - use crate::app::App; - use crate::models::servarr_data::modals::IndexerTestResultModalItem; - use crate::models::servarr_data::sonarr::modals::{ - AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, - }; - use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; - use crate::models::servarr_models::{ - AddRootFolderBody, DiskSpace, EditIndexerParams, HostConfig, Indexer, IndexerField, - IndexerTestResult, Language, LogResponse, Quality, QualityProfile, QualityWrapper, QueueEvent, - RootFolder, SecurityConfig, Tag, Update, - }; - use crate::models::sonarr_models::{ - BlocklistItem, DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, - MediaInfo, SonarrRelease, SonarrReleaseDownloadBody, SonarrTaskName, - }; - use crate::models::sonarr_models::{ - BlocklistResponse, SonarrHistoryData, SonarrHistoryItem, SonarrHistoryWrapper, - }; - use crate::models::sonarr_models::{SonarrTask, SystemStatus}; - use crate::models::stateful_table::StatefulTable; - use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; - use crate::models::{HorizontallyScrollableText, ScrollableText}; - - use crate::network::sonarr_network::get_episode_status; - use crate::{ - models::sonarr_models::{ - Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType, - }, - network::{ - network_tests::test_utils::mock_servarr_api, sonarr_network::SonarrEvent, Network, - NetworkEvent, NetworkResource, RequestMethod, - }, - }; - - const SERIES_JSON: &str = r#"{ - "title": "Test", - "status": "continuing", - "ended": false, - "overview": "Blah blah blah", - "network": "HBO", - "seasons": [ - { - "seasonNumber": 1, - "monitored": true, - "statistics": { - "previousAiring": "2022-10-24T01:00:00Z", - "episodeFileCount": 10, - "episodeCount": 10, - "totalEpisodeCount": 10, - "sizeOnDisk": 36708563419, - "percentOfEpisodes": 100.0 - } - } - ], - "year": 2022, - "path": "/nfs/tv/Test", - "qualityProfileId": 6, - "languageProfileId": 1, - "seasonFolder": true, - "monitored": true, - "runtime": 63, - "tvdbId": 371572, - "seriesType": "standard", - "certification": "TV-MA", - "genres": ["cool", "family", "fun"], - "tags": [3], - "ratings": {"votes": 406744, "value": 8.4}, - "statistics": { - "seasonCount": 2, - "episodeFileCount": 18, - "episodeCount": 18, - "totalEpisodeCount": 50, - "sizeOnDisk": 63894022699, - "percentOfEpisodes": 100.0 - }, - "id": 1 - } -"#; - const EPISODE_JSON: &str = r#"{ - "seriesId": 1, - "tvdbId": 1234, - "episodeFileId": 1, - "seasonNumber": 1, - "episodeNumber": 1, - "title": "Something cool", - "airDateUtc": "2024-02-10T07:28:45Z", - "overview": "Okay so this one time at band camp...", - "episodeFile": { - "id": 1, - "relativePath": "/season 1/episode 1.mkv", - "path": "/nfs/tv/series/season 1/episode 1.mkv", - "size": 3543348019, - "dateAdded": "2024-02-10T07:28:45Z", - "languages": [{ "id": 1, "name": "English" }], - "quality": { "quality": { "name": "Bluray-1080p" } }, - "mediaInfo": { - "audioBitrate": 0, - "audioChannels": 7.1, - "audioCodec": "AAC", - "audioLanguages": "eng", - "audioStreamCount": 1, - "videoBitDepth": 10, - "videoBitrate": 0, - "videoCodec": "x265", - "videoFps": 23.976, - "resolution": "1920x1080", - "runTime": "23:51", - "scanType": "Progressive", - "subtitles": "English" - } - }, - "hasFile": true, - "monitored": true, - "id": 1 - }"#; - - #[rstest] - fn test_resource_all_indexer_settings( - #[values( - SonarrEvent::GetAllIndexerSettings, - SonarrEvent::EditAllIndexerSettings(IndexerSettings::default()) - )] - event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/config/indexer"); - } - - #[rstest] - fn test_resource_episode( - #[values(SonarrEvent::GetEpisodes(0), SonarrEvent::GetEpisodeDetails(0))] event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/episode"); - } - - #[rstest] - fn test_resource_series( - #[values( - SonarrEvent::AddSeries(AddSeriesBody::default()), - SonarrEvent::ListSeries, - SonarrEvent::GetSeriesDetails(0), - SonarrEvent::DeleteSeries(DeleteSeriesParams::default()), - SonarrEvent::EditSeries(EditSeriesParams::default()), - SonarrEvent::ToggleSeasonMonitoring((0, 0)), - SonarrEvent::ToggleSeriesMonitoring(0), - )] - event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/series"); - } - - #[rstest] - fn test_resource_tag( - #[values( - SonarrEvent::AddTag(String::new()), - SonarrEvent::DeleteTag(0), - SonarrEvent::GetTags - )] - event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/tag"); - } - - #[rstest] - fn test_resource_host_config( - #[values(SonarrEvent::GetHostConfig, SonarrEvent::GetSecurityConfig)] event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/config/host"); - } - - #[rstest] - fn test_resource_command( - #[values( - SonarrEvent::GetQueuedEvents, - SonarrEvent::StartTask(SonarrTaskName::default()), - SonarrEvent::TriggerAutomaticEpisodeSearch(0), - SonarrEvent::TriggerAutomaticSeasonSearch((0, 0)), - SonarrEvent::TriggerAutomaticSeriesSearch(0), - SonarrEvent::UpdateAllSeries, - SonarrEvent::UpdateAndScanSeries(0), - SonarrEvent::UpdateDownloads - )] - event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/command"); - } - - #[rstest] - fn test_resource_indexer( - #[values( - SonarrEvent::GetIndexers, - SonarrEvent::DeleteIndexer(0), - SonarrEvent::EditIndexer(EditIndexerParams::default()) - )] - event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/indexer"); - } - - #[rstest] - fn test_resource_history( - #[values(SonarrEvent::GetHistory(0), SonarrEvent::GetEpisodeHistory(0))] event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/history"); - } - - #[rstest] - fn test_resource_series_history( - #[values( - SonarrEvent::GetSeriesHistory(0), - SonarrEvent::GetSeasonHistory((0, 0)) - )] - event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/history/series"); - } - - #[rstest] - fn test_resource_queue( - #[values(SonarrEvent::GetDownloads(0), SonarrEvent::DeleteDownload(0))] event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/queue"); - } - - #[rstest] - fn test_resource_root_folder( - #[values( - SonarrEvent::GetRootFolders, - SonarrEvent::DeleteRootFolder(0), - SonarrEvent::AddRootFolder(AddRootFolderBody::default()) - )] - event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/rootfolder"); - } - - #[rstest] - fn test_resource_release( - #[values( - SonarrEvent::GetSeasonReleases((0, 0)), - SonarrEvent::GetEpisodeReleases(0) - )] - event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/release"); - } - - #[rstest] - fn test_resource_episode_file( - #[values(SonarrEvent::GetEpisodeFiles(0), SonarrEvent::DeleteEpisodeFile(0))] - event: SonarrEvent, - ) { - assert_str_eq!(event.resource(), "/episodefile"); - } - - #[rstest] - #[case(SonarrEvent::ClearBlocklist, "/blocklist/bulk")] - #[case(SonarrEvent::DeleteBlocklistItem(0), "/blocklist")] - #[case(SonarrEvent::HealthCheck, "/health")] - #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] - #[case(SonarrEvent::GetDiskSpace, "/diskspace")] - #[case(SonarrEvent::GetLanguageProfiles, "/language")] - #[case(SonarrEvent::GetLogs(500), "/log")] - #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] - #[case(SonarrEvent::GetStatus, "/system/status")] - #[case(SonarrEvent::GetTasks, "/system/task")] - #[case(SonarrEvent::GetUpdates, "/update")] - #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] - #[case(SonarrEvent::SearchNewSeries(String::new()), "/series/lookup")] - #[case(SonarrEvent::TestIndexer(0), "/indexer/test")] - #[case(SonarrEvent::TestAllIndexers, "/indexer/testall")] - #[case(SonarrEvent::ToggleEpisodeMonitoring(0), "/episode/monitor")] - fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { - assert_str_eq!(event.resource(), expected_uri); - } - - #[test] - fn test_from_sonarr_event() { - assert_eq!( - NetworkEvent::Sonarr(SonarrEvent::HealthCheck), - NetworkEvent::from(SonarrEvent::HealthCheck) - ); - } - - #[tokio::test] - async fn test_handle_add_sonarr_root_folder_event() { - let expected_add_root_folder_body = AddRootFolderBody { - path: "/nfs/test".to_owned(), - }; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "path": "/nfs/test" - })), - Some(json!({})), - None, - SonarrEvent::AddRootFolder(expected_add_root_folder_body.clone()), - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::AddRootFolder(expected_add_root_folder_body)) - .await - .is_ok()); - - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .sonarr_data - .edit_root_folder - .is_none()); - } - - #[tokio::test] - async fn test_handle_add_sonarr_series_event() { - let expected_add_series_body = AddSeriesBody { - tvdb_id: 1234, - title: "Test".to_owned(), - monitored: true, - root_folder_path: "/nfs2".to_owned(), - quality_profile_id: 2222, - language_profile_id: 2222, - series_type: "standard".to_owned(), - season_folder: true, - tags: Vec::new(), - tag_input_string: Some("usenet, testing".to_owned()), - add_options: AddSeriesOptions { - monitor: "all".to_owned(), - search_for_cutoff_unmet_episodes: true, - search_for_missing_episodes: true, - }, - }; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "tvdbId": 1234, - "title": "Test", - "monitored": true, - "rootFolderPath": "/nfs2", - "qualityProfileId": 2222, - "languageProfileId": 2222, - "seriesType": "standard", - "seasonFolder": true, - "tags": [1, 2], - "addOptions": { - "monitor": "all", - "searchForCutoffUnmetEpisodes": true, - "searchForMissingEpisodes": true - } - })), - Some(json!({})), - None, - SonarrEvent::AddSeries(expected_add_series_body.clone()), - None, - None, - ) - .await; - app_arc.lock().await.data.sonarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::AddSeries(expected_add_series_body)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_add_sonarr_series_event_does_not_overwrite_tags_vec_when_tag_input_string_is_none( - ) { - let expected_add_series_body = AddSeriesBody { - tvdb_id: 1234, - title: "Test".to_owned(), - monitored: true, - root_folder_path: "/nfs2".to_owned(), - quality_profile_id: 2222, - language_profile_id: 2222, - series_type: "standard".to_owned(), - season_folder: true, - tags: vec![1, 2], - tag_input_string: None, - add_options: AddSeriesOptions { - monitor: "all".to_owned(), - search_for_cutoff_unmet_episodes: true, - search_for_missing_episodes: true, - }, - }; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "tvdbId": 1234, - "title": "Test", - "monitored": true, - "rootFolderPath": "/nfs2", - "qualityProfileId": 2222, - "languageProfileId": 2222, - "seriesType": "standard", - "seasonFolder": true, - "tags": [1, 2], - "addOptions": { - "monitor": "all", - "searchForCutoffUnmetEpisodes": true, - "searchForMissingEpisodes": true - } - })), - Some(json!({})), - None, - SonarrEvent::AddSeries(expected_add_series_body.clone()), - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::AddSeries(expected_add_series_body)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_add_sonarr_tag() { - let tag_json = json!({ "id": 3, "label": "testing" }); - let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ "label": "testing" })), - Some(tag_json), - None, - SonarrEvent::AddTag(String::new()), - None, - None, - ) - .await; - app_arc.lock().await.data.sonarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Tag(tag) = network - .handle_sonarr_event(SonarrEvent::AddTag("testing".to_owned())) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.tags_map, - BiMap::from_iter([ - (1, "usenet".to_owned()), - (2, "test".to_owned()), - (3, "testing".to_owned()) - ]) - ); - assert_eq!(tag, response); - } - } - - #[tokio::test] - async fn test_handle_clear_radarr_blocklist_event() { - let blocklist_items = vec![ - BlocklistItem { - id: 1, - ..blocklist_item() - }, - BlocklistItem { - id: 2, - ..blocklist_item() - }, - BlocklistItem { - id: 3, - ..blocklist_item() - }, - ]; - let expected_request_json = json!({ "ids": [1, 2, 3]}); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - Some(expected_request_json), - None, - None, - SonarrEvent::ClearBlocklist, - None, - None, - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .blocklist - .set_items(blocklist_items); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::ClearBlocklist) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_sonarr_blocklist_item_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - SonarrEvent::DeleteBlocklistItem(1), - Some("/1"), - None, - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .blocklist - .set_items(vec![blocklist_item()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::DeleteBlocklistItem(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_sonarr_episode_file_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - SonarrEvent::DeleteEpisodeFile(1), - Some("/1"), - None, - ) - .await; - app_arc.lock().await.data.sonarr_data.season_details_modal = - Some(SeasonDetailsModal::default()); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::DeleteEpisodeFile(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_sonarr_download_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - SonarrEvent::DeleteDownload(1), - Some("/1"), - None, - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .downloads - .set_items(vec![download_record()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::DeleteDownload(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_sonarr_indexer_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - SonarrEvent::DeleteIndexer(1), - Some("/1"), - None, - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .indexers - .set_items(vec![indexer()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::DeleteIndexer(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_sonarr_root_folder_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - SonarrEvent::DeleteRootFolder(1), - Some("/1"), - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::DeleteRootFolder(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_series_event() { - let delete_series_params = DeleteSeriesParams { - id: 1, - delete_series_files: true, - add_list_exclusion: true, - }; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - SonarrEvent::DeleteSeries(delete_series_params.clone()), - Some("/1"), - Some("deleteFiles=true&addImportExclusion=true"), - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::DeleteSeries(delete_series_params)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_delete_sonarr_tag_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Delete, - None, - None, - None, - SonarrEvent::DeleteTag(1), - Some("/1"), - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::DeleteTag(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_download_sonarr_release_event_uses_provided_params() { - let params = SonarrReleaseDownloadBody { - guid: "1234".to_owned(), - indexer_id: 2, - series_id: Some(1), - ..SonarrReleaseDownloadBody::default() - }; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "guid": "1234", - "indexerId": 2, - "seriesId": 1, - })), - Some(json!({})), - None, - SonarrEvent::DownloadRelease(params.clone()), - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::DownloadRelease(params)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_all_indexer_settings_event() { - let indexer_settings_json = json!({ - "id": 1, - "minimumAge": 1, - "maximumSize": 12345, - "retention": 1, - "rssSyncInterval": 60 - }); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Put, - Some(indexer_settings_json), - None, - None, - SonarrEvent::EditAllIndexerSettings(indexer_settings()), - None, - None, - ) - .await; - - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditAllIndexerSettings(indexer_settings())) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_sonarr_indexer_event() { - let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, - name: Some("Test Update".to_owned()), - enable_rss: Some(false), - enable_automatic_search: Some(false), - enable_interactive_search: Some(false), - url: Some("https://localhost:9696/1/".to_owned()), - api_key: Some("test1234".to_owned()), - seed_ratio: Some("1.3".to_owned()), - tag_input_string: Some("usenet, testing".to_owned()), - priority: Some(0), - ..EditIndexerParams::default() - }; - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let expected_indexer_edit_body_json = json!({ - "enableRss": false, - "enableAutomaticSearch": false, - "enableInteractiveSearch": false, - "name": "Test Update", - "priority": 0, - "fields": [ - { - "name": "baseUrl", - "value": "https://localhost:9696/1/", - }, - { - "name": "apiKey", - "value": "test1234", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.3", - }, - ], - "tags": [1, 2], - "id": 1 - }); - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - SonarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - SonarrEvent::EditIndexer(expected_edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_indexer_edit_body_json)) - .create_async() - .await; - app_arc.lock().await.data.sonarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditIndexer(expected_edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_sonarr_indexer_event_does_not_overwrite_tags_vec_if_tag_input_string_is_none( - ) { - let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, - name: Some("Test Update".to_owned()), - enable_rss: Some(false), - enable_automatic_search: Some(false), - enable_interactive_search: Some(false), - url: Some("https://localhost:9696/1/".to_owned()), - api_key: Some("test1234".to_owned()), - seed_ratio: Some("1.3".to_owned()), - tags: Some(vec![1, 2]), - priority: Some(0), - ..EditIndexerParams::default() - }; - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let expected_indexer_edit_body_json = json!({ - "enableRss": false, - "enableAutomaticSearch": false, - "enableInteractiveSearch": false, - "name": "Test Update", - "priority": 0, - "fields": [ - { - "name": "baseUrl", - "value": "https://localhost:9696/1/", - }, - { - "name": "apiKey", - "value": "test1234", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.3", - }, - ], - "tags": [1, 2], - "id": 1 - }); - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - SonarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - SonarrEvent::EditIndexer(expected_edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_indexer_edit_body_json)) - .create_async() - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditIndexer(expected_edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_sonarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( - ) { - let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, - name: Some("Test Update".to_owned()), - enable_rss: Some(false), - enable_automatic_search: Some(false), - enable_interactive_search: Some(false), - url: Some("https://localhost:9696/1/".to_owned()), - api_key: Some("test1234".to_owned()), - seed_ratio: Some("1.3".to_owned()), - tag_input_string: Some("usenet, testing".to_owned()), - priority: Some(0), - ..EditIndexerParams::default() - }; - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - ], - "tags": [1], - "id": 1 - }); - let expected_indexer_edit_body_json = json!({ - "enableRss": false, - "enableAutomaticSearch": false, - "enableInteractiveSearch": false, - "name": "Test Update", - "priority": 0, - "fields": [ - { - "name": "baseUrl", - "value": "https://localhost:9696/1/", - }, - { - "name": "apiKey", - "value": "test1234", - }, - ], - "tags": [1, 2], - "id": 1 - }); - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - SonarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - SonarrEvent::EditIndexer(expected_edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_indexer_edit_body_json)) - .create_async() - .await; - app_arc.lock().await.data.sonarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditIndexer(expected_edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_sonarr_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( - ) { - let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, - name: Some("Test Update".to_owned()), - enable_rss: Some(false), - enable_automatic_search: Some(false), - enable_interactive_search: Some(false), - url: Some("https://localhost:9696/1/".to_owned()), - api_key: Some("test1234".to_owned()), - seed_ratio: Some("1.3".to_owned()), - tag_input_string: Some("usenet, testing".to_owned()), - priority: Some(0), - ..EditIndexerParams::default() - }; - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - }, - ], - "tags": [1], - "id": 1 - }); - let expected_indexer_edit_body_json = json!({ - "enableRss": false, - "enableAutomaticSearch": false, - "enableInteractiveSearch": false, - "name": "Test Update", - "priority": 0, - "fields": [ - { - "name": "baseUrl", - "value": "https://localhost:9696/1/", - }, - { - "name": "apiKey", - "value": "test1234", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.3", - }, - ], - "tags": [1, 2], - "id": 1 - }); - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - SonarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - SonarrEvent::EditIndexer(expected_edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_indexer_edit_body_json)) - .create_async() - .await; - app_arc.lock().await.data.sonarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditIndexer(expected_edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_sonarr_indexer_event_defaults_to_previous_values() { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let edit_indexer_params = EditIndexerParams { - indexer_id: 1, - ..EditIndexerParams::default() - }; - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json.clone()), - None, - SonarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - SonarrEvent::EditIndexer(edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(indexer_details_json)) - .create_async() - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditIndexer(edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_sonarr_indexer_event_clears_tags_when_clear_tags_is_true() { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1, 2], - "id": 1 - }); - let expected_edit_indexer_body = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "priority": 1, - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [], - "id": 1 - }); - let edit_indexer_params = EditIndexerParams { - indexer_id: 1, - clear_tags: true, - ..EditIndexerParams::default() - }; - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json), - None, - SonarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1?forceSave=true", - SonarrEvent::EditIndexer(edit_indexer_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_edit_indexer_body)) - .create_async() - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditIndexer(edit_indexer_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_series_event() { - let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); - *expected_body.get_mut("monitored").unwrap() = json!(false); - *expected_body.get_mut("seasonFolder").unwrap() = json!(false); - *expected_body.get_mut("seriesType").unwrap() = json!("standard"); - *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); - *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); - *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); - *expected_body.get_mut("tags").unwrap() = json!([1, 2]); - let edit_series_params = EditSeriesParams { - series_id: 1, - monitored: Some(false), - use_season_folders: Some(false), - series_type: Some(SeriesType::Standard), - quality_profile_id: Some(1111), - language_profile_id: Some(1111), - root_folder_path: Some("/nfs/Test Path".to_owned()), - tag_input_string: Some("usenet, testing".to_owned()), - ..EditSeriesParams::default() - }; - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(SERIES_JSON).unwrap()), - None, - SonarrEvent::GetSeriesDetails(1), - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - SonarrEvent::EditSeries(edit_series_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - app_arc.lock().await.data.sonarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditSeries(edit_series_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_series_event_does_not_overwrite_tag_ids_vec_when_tag_input_string_is_none( - ) { - let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); - *expected_body.get_mut("monitored").unwrap() = json!(false); - *expected_body.get_mut("seasonFolder").unwrap() = json!(false); - *expected_body.get_mut("seriesType").unwrap() = json!("standard"); - *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); - *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); - *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); - *expected_body.get_mut("tags").unwrap() = json!([1, 2]); - let edit_series_params = EditSeriesParams { - series_id: 1, - monitored: Some(false), - use_season_folders: Some(false), - series_type: Some(SeriesType::Standard), - quality_profile_id: Some(1111), - language_profile_id: Some(1111), - root_folder_path: Some("/nfs/Test Path".to_owned()), - tags: Some(vec![1, 2]), - ..EditSeriesParams::default() - }; - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(SERIES_JSON).unwrap()), - None, - SonarrEvent::GetSeriesDetails(1), - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - SonarrEvent::EditSeries(edit_series_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - app_arc.lock().await.data.sonarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditSeries(edit_series_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_series_event_defaults_to_previous_values() { - let edit_series_params = EditSeriesParams { - series_id: 1, - ..EditSeriesParams::default() - }; - let expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(SERIES_JSON).unwrap()), - None, - SonarrEvent::GetSeriesDetails(1), - Some("/1"), - None, - ) - .await; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - SonarrEvent::EditSeries(edit_series_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditSeries(edit_series_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_edit_series_event_returns_empty_tags_vec_when_clear_tags_is_true() { - let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); - *expected_body.get_mut("tags").unwrap() = json!([]); - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(SERIES_JSON).unwrap()), - None, - SonarrEvent::GetSeriesDetails(1), - Some("/1"), - None, - ) - .await; - let edit_series_params = EditSeriesParams { - series_id: 1, - clear_tags: true, - ..EditSeriesParams::default() - }; - let async_edit_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - SonarrEvent::EditSeries(edit_series_params.clone()).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::EditSeries(edit_series_params)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_edit_server.assert_async().await; - } - - #[rstest] - #[tokio::test] - async fn test_handle_get_sonarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { - let blocklist_json = json!({"records": [{ - "seriesId": 1007, - "episodeIds": [42020], - "sourceTitle": "z series", - "languages": [{ "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", - "languages": [{ "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 mut expected_blocklist = vec![ - BlocklistItem { - id: 123, - series_id: 1007, - series_title: Some("Z Series".into()), - source_title: "z series".into(), - episode_ids: vec![Number::from(42020)], - ..blocklist_item() - }, - BlocklistItem { - id: 456, - series_id: 2001, - source_title: "A Series".into(), - episode_ids: vec![Number::from(42018)], - ..blocklist_item() - }, - ]; - 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 - .series - .set_items(vec![Series { - id: 1007, - title: "Z Series".into(), - ..series() - }]); - app_arc.lock().await.data.sonarr_data.blocklist.sort_asc = true; - if use_custom_sorting { - let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { - a.source_title - .to_lowercase() - .cmp(&b.source_title.to_lowercase()) - }; - expected_blocklist.sort_by(cmp_fn); - - 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]); - } - app_arc.lock().await.server_tabs.next(); - 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_eq!( - app_arc.lock().await.data.sonarr_data.blocklist.items, - expected_blocklist - ); - assert!(app_arc.lock().await.data.sonarr_data.blocklist.sort_asc); - assert_eq!(blocklist, response); - } - } - - #[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", - "languages": [{ "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", - "languages": [{ "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]); - app_arc.lock().await.server_tabs.next(); - 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!({ - "records": [{ - "title": "Test Download Title", - "status": "downloading", - "id": 1, - "episodeId": 1, - "size": 3543348019f64, - "sizeleft": 1771674009f64, - "outputPath": "/nfs/tv/Test show/season 1/", - "indexer": "kickass torrents", - "downloadClient": "transmission", - }] - }); - let response: DownloadsResponse = - serde_json::from_value(downloads_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(downloads_response_json), - None, - SonarrEvent::GetDownloads(500), - None, - Some("pageSize=500"), - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::DownloadsResponse(downloads) = network - .handle_sonarr_event(SonarrEvent::GetDownloads(500)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.downloads.items, - downloads_response().records - ); - assert_eq!(downloads, response); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_diskspace_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([ - { - "freeSpace": 1111, - "totalSpace": 2222, - }, - { - "freeSpace": 3333, - "totalSpace": 4444 - } - ])), - None, - SonarrEvent::GetDiskSpace, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let disk_space_vec = vec![ - DiskSpace { - free_space: 1111, - total_space: 2222, - }, - DiskSpace { - free_space: 3333, - total_space: 4444, - }, - ]; - - if let SonarrSerdeable::DiskSpaces(disk_space) = network - .handle_sonarr_event(SonarrEvent::GetDiskSpace) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.disk_space_vec, - disk_space_vec - ); - assert_eq!(disk_space, disk_space_vec); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_healthcheck_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - None, - None, - SonarrEvent::HealthCheck, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let _ = network.handle_sonarr_event(SonarrEvent::HealthCheck).await; - - async_server.assert_async().await; - } - - #[rstest] - #[tokio::test] - async fn test_handle_get_episodes_event(#[values(true, false)] use_custom_sorting: bool) { - let episode_1 = Episode { - title: "z test".to_owned(), - episode_file: None, - ..episode() - }; - let episode_2 = Episode { - id: 2, - title: "A test".to_owned(), - episode_file_id: 2, - season_number: 2, - episode_number: 2, - episode_file: None, - ..episode() - }; - let episode_3 = Episode { - id: 3, - title: "A test".to_owned(), - episode_file_id: 3, - season_number: 1, - episode_number: 2, - episode_file: None, - ..episode() - }; - let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()]; - let mut expected_sorted_episodes = vec![episode_1.clone(), episode_3.clone()]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([episode_1, episode_2, episode_3])), - None, - SonarrEvent::GetEpisodes(1), - None, - Some("seriesId=1"), - ) - .await; - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.sort_asc = true; - if use_custom_sorting { - let cmp_fn = |a: &Episode, b: &Episode| a.title.to_lowercase().cmp(&b.title.to_lowercase()); - expected_sorted_episodes.sort_by(cmp_fn); - let title_sort_option = SortOption { - name: "Title", - cmp_fn: Some(cmp_fn), - }; - season_details_modal - .episodes - .sorting(vec![title_sort_option]); - } - app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![Series { - id: 1, - ..Series::default() - }]); - app_arc - .lock() - .await - .data - .sonarr_data - .seasons - .set_items(vec![Season { - season_number: 1, - ..Season::default() - }]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Episodes(episodes) = network - .handle_sonarr_event(SonarrEvent::GetEpisodes(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episodes - .items, - expected_sorted_episodes - ); - assert!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episodes - .sort_asc - ); - assert_eq!(episodes, expected_episodes); - } - } - - #[tokio::test] - async fn test_handle_get_episodes_event_empty_seasons_table_returns_all_episodes_by_default() { - let episode_1 = Episode { - title: "z test".to_owned(), - episode_file: None, - ..episode() - }; - let episode_2 = Episode { - id: 2, - title: "A test".to_owned(), - episode_file_id: 2, - season_number: 2, - episode_number: 2, - episode_file: None, - ..episode() - }; - let episode_3 = Episode { - id: 3, - title: "A test".to_owned(), - episode_file_id: 3, - season_number: 1, - episode_number: 2, - episode_file: None, - ..episode() - }; - let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([episode_1, episode_2, episode_3])), - None, - SonarrEvent::GetEpisodes(1), - None, - Some("seriesId=1"), - ) - .await; - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.sort_asc = true; - app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![Series { - id: 1, - ..Series::default() - }]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Episodes(episodes) = network - .handle_sonarr_event(SonarrEvent::GetEpisodes(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episodes - .items, - expected_episodes - ); - assert!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episodes - .sort_asc - ); - assert_eq!(episodes, expected_episodes); - } - } - - #[tokio::test] - async fn test_handle_get_episodes_event_empty_season_details_modal() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([episode()])), - None, - SonarrEvent::GetEpisodes(1), - None, - Some("seriesId=1"), - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![Series { - id: 1, - ..Series::default() - }]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Episodes(episodes) = network - .handle_sonarr_event(SonarrEvent::GetEpisodes(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episodes - .items, - vec![episode()] - ); - assert_eq!(episodes, vec![episode()]); - } - } - - #[tokio::test] - async fn test_handle_get_episodes_event_no_op_while_user_is_selecting_sort_options_on_table() { - let episodes_json = json!([ - { - "id": 2, - "seriesId": 1, - "tvdbId": 1234, - "episodeFileId": 2, - "seasonNumber": 2, - "episodeNumber": 2, - "title": "Something cool", - "airDateUtc": "2024-02-10T07:28:45Z", - "overview": "Okay so this one time at band camp...", - "hasFile": true, - "monitored": true - }, - { - "id": 1, - "seriesId": 1, - "tvdbId": 1234, - "episodeFileId": 1, - "seasonNumber": 1, - "episodeNumber": 1, - "title": "Something cool", - "airDateUtc": "2024-02-10T07:28:45Z", - "overview": "Okay so this one time at band camp...", - "hasFile": true, - "monitored": true - } - ]); - let episode_1 = Episode { - episode_file: None, - ..episode() - }; - let episode_2 = Episode { - id: 2, - episode_file_id: 2, - season_number: 2, - episode_number: 2, - episode_file: None, - ..episode() - }; - let mut expected_episodes = vec![episode_2.clone(), episode_1.clone()]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(episodes_json), - None, - SonarrEvent::GetEpisodes(1), - None, - Some("seriesId=1"), - ) - .await; - app_arc - .lock() - .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesSortPrompt.into()); - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.sort_asc = true; - let cmp_fn = |a: &Episode, b: &Episode| a.title.to_lowercase().cmp(&b.title.to_lowercase()); - expected_episodes.sort_by(cmp_fn); - let title_sort_option = SortOption { - name: "Title", - cmp_fn: Some(cmp_fn), - }; - season_details_modal - .episodes - .sorting(vec![title_sort_option]); - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![Series { - id: 1, - ..Series::default() - }]); - app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Episodes(episodes) = network - .handle_sonarr_event(SonarrEvent::GetEpisodes(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episodes - .is_empty()); - assert!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episodes - .sort_asc - ); - assert_eq!(episodes, expected_episodes); - } - } - - #[tokio::test] - async fn test_handle_get_episode_files_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([episode_file()])), - None, - SonarrEvent::GetEpisodeFiles(1), - None, - Some("seriesId=1"), - ) - .await; - app_arc.lock().await.data.sonarr_data.season_details_modal = - Some(SeasonDetailsModal::default()); - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![Series { - id: 1, - ..Series::default() - }]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::EpisodeFiles(episode_files) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeFiles(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_files - .items, - vec![episode_file()] - ); - assert_eq!(episode_files, vec![episode_file()]); - } - } - - #[tokio::test] - async fn test_handle_get_episode_files_event_empty_season_details_modal() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([episode_file()])), - None, - SonarrEvent::GetEpisodeFiles(1), - None, - Some("seriesId=1"), - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![Series { - id: 1, - ..Series::default() - }]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::EpisodeFiles(episode_files) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeFiles(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .is_some()); - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_files - .items, - vec![episode_file()] - ); - assert_eq!(episode_files, vec![episode_file()]); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_host_config_event() { - let host_config_response = json!({ - "bindAddress": "*", - "port": 7878, - "urlBase": "some.test.site/sonarr", - "instanceName": "Sonarr", - "applicationUrl": "https://some.test.site:7878/sonarr", - "enableSsl": true, - "sslPort": 9898, - "sslCertPath": "/app/sonarr.pfx", - "sslCertPassword": "test" - }); - let response: HostConfig = serde_json::from_value(host_config_response.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(host_config_response), - None, - SonarrEvent::GetHostConfig, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::HostConfig(host_config) = network - .handle_sonarr_event(SonarrEvent::GetHostConfig) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(host_config, response); - } - } - - #[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": [{ "id": 1, "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": [{ "id": 1, "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(500), - None, - Some("pageSize=500&sortDirection=descending&sortKey=date"), - ) - .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]); - } - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryWrapper(history) = network - .handle_sonarr_event(SonarrEvent::GetHistory(500)) - .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": [{ "id": 1, "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": [{ "id": 1, "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(500), - None, - Some("pageSize=500&sortDirection=descending&sortKey=date"), - ) - .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]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryWrapper(history) = network - .handle_sonarr_event(SonarrEvent::GetHistory(500)) - .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!([{ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "supportsRss": true, - "supportsSearch": true, - "protocol": "torrent", - "priority": 25, - "downloadClientId": 0, - "name": "Test Indexer", - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "implementationName": "Torznab", - "implementation": "Torznab", - "configContract": "TorznabSettings", - "tags": [1], - "id": 1 - }]); - let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexers_response_json), - None, - SonarrEvent::GetIndexers, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Indexers(indexers) = network - .handle_sonarr_event(SonarrEvent::GetIndexers) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.indexers.items, - vec![indexer()] - ); - assert_eq!(indexers, response); - } - } - - #[tokio::test] - async fn test_handle_get_episode_details_event() { - let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(EPISODE_JSON).unwrap()), - None, - SonarrEvent::GetEpisodeDetails(1), - Some("/1"), - None, - ) - .await; - let mut episode_details_modal = EpisodeDetailsModal::default(); - episode_details_modal.episode_details_tabs.next(); - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.set_items(vec![episode()]); - season_details_modal.episode_details_modal = Some(episode_details_modal); - app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); - app_arc - .lock() - .await - .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Episode(episode) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .is_some()); - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap() - .episode_details_tabs - .get_active_route(), - ActiveSonarrBlock::EpisodeHistory.into() - ); - assert_eq!(episode, response); - - let app = app_arc.lock().await; - let episode_details_modal = app - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap(); - assert_str_eq!( - episode_details_modal.episode_details.get_text(), - formatdoc!( - "Title: Something cool - Season: 1 - Episode Number: 1 - Air Date: 2024-02-10 07:28:45 UTC - Status: Downloaded - Description: Okay so this one time at band camp..." - ) - ); - assert_str_eq!( - episode_details_modal.file_details, - formatdoc!( - "Relative Path: /season 1/episode 1.mkv - Absolute Path: /nfs/tv/series/season 1/episode 1.mkv - Size: 3.30 GB - Language: English - Date Added: 2024-02-10 07:28:45 UTC" - ) - ); - assert_str_eq!( - episode_details_modal.audio_details, - formatdoc!( - "Bitrate: 0 - Channels: 7.1 - Codec: AAC - Languages: eng - Stream Count: 1" - ) - ); - assert_str_eq!( - episode_details_modal.video_details, - formatdoc!( - "Bit Depth: 10 - Bitrate: 0 - Codec: x265 - FPS: 23.976 - Resolution: 1920x1080 - Scan Type: Progressive - Runtime: 23:51 - Subtitles: English" - ) - ); - } - } - - #[tokio::test] - async fn test_handle_get_episode_details_event_empty_episode_details_modal() { - let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(EPISODE_JSON).unwrap()), - None, - SonarrEvent::GetEpisodeDetails(1), - Some("/1"), - None, - ) - .await; - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.set_items(vec![episode()]); - app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); - app_arc - .lock() - .await - .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Episode(episode) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .is_some()); - assert_eq!(episode, response); - - let app = app_arc.lock().await; - let episode_details_modal = app - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap(); - assert_str_eq!( - episode_details_modal.episode_details.get_text(), - formatdoc!( - "Title: Something cool - Season: 1 - Episode Number: 1 - Air Date: 2024-02-10 07:28:45 UTC - Status: Downloaded - Description: Okay so this one time at band camp..." - ) - ); - assert_str_eq!( - episode_details_modal.file_details, - formatdoc!( - "Relative Path: /season 1/episode 1.mkv - Absolute Path: /nfs/tv/series/season 1/episode 1.mkv - Size: 3.30 GB - Language: English - Date Added: 2024-02-10 07:28:45 UTC" - ) - ); - assert_str_eq!( - episode_details_modal.audio_details, - formatdoc!( - "Bitrate: 0 - Channels: 7.1 - Codec: AAC - Languages: eng - Stream Count: 1" - ) - ); - assert_str_eq!( - episode_details_modal.video_details, - formatdoc!( - "Bit Depth: 10 - Bitrate: 0 - Codec: x265 - FPS: 23.976 - Resolution: 1920x1080 - Scan Type: Progressive - Runtime: 23:51 - Subtitles: English" - ) - ); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_episode_history_event() { - let history_json = json!({"records": [{ - "id": 123, - "sourceTitle": "z episode", - "episodeId": 1007, - "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetEpisodeHistory(1), - None, - Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), - ) - .await; - app_arc.lock().await.data.sonarr_data.season_details_modal = - Some(SeasonDetailsModal::default()); - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episodes - .set_items(vec![episode()]); - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal = Some(EpisodeDetailsModal::default()); - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal - .as_mut() - .unwrap() - .episode_history - .sort_asc = true; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryWrapper(history) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap() - .episode_history - .items, - expected_history_items - ); - assert!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap() - .episode_history - .sort_asc - ); - assert_eq!(history, response); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_episode_history_event_empty_episode_details_modal() { - let history_json = json!({"records": [{ - "id": 123, - "sourceTitle": "z episode", - "episodeId": 1007, - "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetEpisodeHistory(1), - None, - Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), - ) - .await; - app_arc.lock().await.data.sonarr_data.season_details_modal = - Some(SeasonDetailsModal::default()); - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episodes - .set_items(vec![episode()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryWrapper(history) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap() - .episode_history - .items, - expected_history_items - ); - assert!( - !app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap() - .episode_history - .sort_asc - ); - assert_eq!(history, response); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_episode_history_event_empty_season_details_modal() { - let history_json = json!({"records": [{ - "id": 123, - "sourceTitle": "z episode", - "episodeId": 1007, - "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetEpisodeHistory(1), - None, - Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryWrapper(history) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap() - .episode_history - .items, - expected_history_items - ); - assert!( - !app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap() - .episode_history - .sort_asc - ); - assert_eq!(history, response); - } - } - - #[tokio::test] - async fn test_handle_get_episode_details_event_season_details_modal_not_required_in_cli_mode() { - let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(EPISODE_JSON).unwrap()), - None, - SonarrEvent::GetEpisodeDetails(1), - Some("/1"), - None, - ) - .await; - app_arc.lock().await.cli_mode = true; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Episode(episode) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(episode, response); - } - } - - #[tokio::test] - #[should_panic(expected = "Season details modal is empty")] - async fn test_handle_get_episode_details_event_requires_season_details_modal_to_be_some_when_in_tui_mode( - ) { - let (_async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(EPISODE_JSON).unwrap()), - None, - SonarrEvent::GetEpisodeDetails(1), - Some("/1"), - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - network - .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(1)) - .await - .unwrap(); - } - - #[tokio::test] - async fn test_handle_get_sonarr_language_profiles_event() { - let language_profiles_json = json!([{ - "id": 2222, - "name": "English" - }]); - let response: Vec = serde_json::from_value(language_profiles_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(language_profiles_json), - None, - SonarrEvent::GetLanguageProfiles, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::LanguageProfiles(language_profiles) = network - .handle_sonarr_event(SonarrEvent::GetLanguageProfiles) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.language_profiles_map, - BiMap::from_iter([(2222i64, "English".to_owned())]) - ); - assert_eq!(language_profiles, response); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_logs_event() { - let expected_logs = vec![ - HorizontallyScrollableText::from( - "2023-05-20 21:29:16 UTC|FATAL|SonarrError|Some.Big.Bad.Exception|test exception", - ), - HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), - ]; - let logs_response_json = json!({ - "page": 1, - "pageSize": 500, - "sortKey": "time", - "sortDirection": "descending", - "totalRecords": 2, - "records": [ - { - "time": "2023-05-20T21:29:16Z", - "level": "info", - "logger": "TestLogger", - "message": "test message", - "id": 1 - }, - { - "time": "2023-05-20T21:29:16Z", - "level": "fatal", - "logger": "SonarrError", - "exception": "test exception", - "exceptionType": "Some.Big.Bad.Exception", - "id": 2 - } - ] - }); - let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(logs_response_json), - None, - SonarrEvent::GetLogs(500), - None, - Some("pageSize=500&sortDirection=descending&sortKey=time"), - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::LogResponse(logs) = network - .handle_sonarr_event(SonarrEvent::GetLogs(500)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.logs.items, - expected_logs - ); - assert!(app_arc - .lock() - .await - .data - .sonarr_data - .logs - .current_selection() - .text - .contains("INFO")); - assert_eq!(logs, response); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_quality_profiles_event() { - let quality_profile_json = json!([{ - "id": 2222, - "name": "HD - 1080p" - }]); - let response: Vec = - serde_json::from_value(quality_profile_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(quality_profile_json), - None, - SonarrEvent::GetQualityProfiles, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::QualityProfiles(quality_profiles) = network - .handle_sonarr_event(SonarrEvent::GetQualityProfiles) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.quality_profile_map, - BiMap::from_iter([(2222i64, "HD - 1080p".to_owned())]) - ); - assert_eq!(quality_profiles, response); - } - } - - #[tokio::test] - async fn test_handle_get_queued_sonarr_events_event() { - let queued_events_json = json!([{ - "name": "RefreshMonitoredDownloads", - "commandName": "Refresh Monitored Downloads", - "status": "completed", - "queued": "2023-05-20T21:29:16Z", - "started": "2023-05-20T21:29:16Z", - "ended": "2023-05-20T21:29:16Z", - "duration": "00:00:00.5111547", - "trigger": "scheduled", - }]); - let response: Vec = serde_json::from_value(queued_events_json.clone()).unwrap(); - let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); - let expected_event = QueueEvent { - name: "RefreshMonitoredDownloads".to_owned(), - command_name: "Refresh Monitored Downloads".to_owned(), - status: "completed".to_owned(), - queued: timestamp, - started: Some(timestamp), - ended: Some(timestamp), - duration: Some("00:00:00.5111547".to_owned()), - trigger: "scheduled".to_owned(), - }; - - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(queued_events_json), - None, - SonarrEvent::GetQueuedEvents, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::QueueEvents(events) = network - .handle_sonarr_event(SonarrEvent::GetQueuedEvents) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.queued_events.items, - vec![expected_event] - ); - assert_eq!(events, response); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_root_folders_event() { - let root_folder_json = json!([{ - "id": 1, - "path": "/nfs", - "accessible": true, - "freeSpace": 219902325555200u64, - }]); - let response: Vec = serde_json::from_value(root_folder_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(root_folder_json), - None, - SonarrEvent::GetRootFolders, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::RootFolders(root_folders) = network - .handle_sonarr_event(SonarrEvent::GetRootFolders) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.root_folders.items, - vec![root_folder()] - ); - assert_eq!(root_folders, response); - } - } - - #[tokio::test] - async fn test_handle_get_episode_releases_event() { - let release_json = json!([{ - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "id": 1, "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }} - }]); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(release_json), - None, - SonarrEvent::GetEpisodeReleases(1), - None, - Some("episodeId=1"), - ) - .await; - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.set_items(vec![episode()]); - app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .episode_details_modal = Some(EpisodeDetailsModal::default()); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Releases(releases_vec) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap() - .episode_releases - .items, - vec![release()] - ); - assert_eq!(releases_vec, vec![release()]); - } - } - - #[tokio::test] - async fn test_handle_get_episode_releases_event_empty_episode_details_modal() { - let release_json = json!([{ - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "id": 1, "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }} - }]); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(release_json), - None, - SonarrEvent::GetEpisodeReleases(1), - None, - Some("episodeId=1"), - ) - .await; - let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.set_items(vec![episode()]); - app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Releases(releases_vec) = network - .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .episode_details_modal - .as_ref() - .unwrap() - .episode_releases - .items, - vec![release()] - ); - assert_eq!(releases_vec, vec![release()]); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_season_history_event() { - let history_json = json!([{ - "id": 123, - "sourceTitle": "z episode", - "episodeId": 1007, - "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetSeasonHistory((1, 1)), - None, - Some("seriesId=1&seasonNumber=1"), - ) - .await; - app_arc.lock().await.data.sonarr_data.season_details_modal = - Some(SeasonDetailsModal::default()); - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![series()]); - app_arc - .lock() - .await - .data - .sonarr_data - .seasons - .set_items(vec![season()]); - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_mut() - .unwrap() - .season_history - .sort_asc = true; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryItems(history) = network - .handle_sonarr_event(SonarrEvent::GetSeasonHistory((1, 1))) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .season_history - .items, - expected_history_items - ); - assert!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .season_history - .sort_asc - ); - assert_eq!(history, response); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_season_history_event_empty_season_details_modal() { - let history_json = json!([{ - "id": 123, - "sourceTitle": "z episode", - "episodeId": 1007, - "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{ "id": 1, "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": [{ "id": 1, "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::GetSeasonHistory((1, 1)), - None, - Some("seriesId=1&seasonNumber=1"), - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![series()]); - app_arc - .lock() - .await - .data - .sonarr_data - .seasons - .set_items(vec![season()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryItems(history) = network - .handle_sonarr_event(SonarrEvent::GetSeasonHistory((1, 1))) - .await - .unwrap() - { - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .is_some()); - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .season_history - .items, - expected_history_items - ); - assert!( - !app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .season_history - .sort_asc - ); - assert_eq!(history, response); - } - } - - #[tokio::test] - async fn test_handle_get_season_releases_event() { - let release_json = json!([ - { - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "id": 1, "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }}, - "fullSeason": true - }, - { - "guid": "4567", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "id": 1, "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }}, - } - ]); - let expected_filtered_sonarr_release = SonarrRelease { - full_season: true, - ..release() - }; - let expected_raw_sonarr_releases = vec![ - SonarrRelease { - full_season: true, - ..release() - }, - SonarrRelease { - guid: "4567".to_owned(), - ..release() - }, - ]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(release_json), - None, - SonarrEvent::GetSeasonReleases((1, 1)), - None, - Some("seriesId=1&seasonNumber=1"), - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![series()]); - app_arc - .lock() - .await - .data - .sonarr_data - .seasons - .set_items(vec![season()]); - app_arc.lock().await.data.sonarr_data.season_details_modal = - Some(SeasonDetailsModal::default()); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Releases(releases_vec) = network - .handle_sonarr_event(SonarrEvent::GetSeasonReleases((1, 1))) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .season_releases - .items, - vec![expected_filtered_sonarr_release] - ); - assert_eq!(releases_vec, expected_raw_sonarr_releases); - } - } - - #[tokio::test] - async fn test_handle_get_season_releases_event_empty_season_details_modal() { - let release_json = json!([ - { - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "id": 1, "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }}, - "fullSeason": true - }, - { - "guid": "4567", - "protocol": "usenet", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "id": 1, "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }}, - } - ]); - let expected_sonarr_release = SonarrRelease { - full_season: true, - ..release() - }; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(release_json), - None, - SonarrEvent::GetSeasonReleases((1, 1)), - None, - Some("seriesId=1&seasonNumber=1"), - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![series()]); - app_arc - .lock() - .await - .data - .sonarr_data - .seasons - .set_items(vec![season()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::GetSeasonReleases((1, 1))) - .await - .is_ok()); - - async_server.assert_async().await; - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .season_details_modal - .as_ref() - .unwrap() - .season_releases - .items, - vec![expected_sonarr_release] - ); - } - - #[rstest] - #[tokio::test] - async fn test_handle_list_series_event(#[values(true, false)] use_custom_sorting: bool) { - let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); - let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap(); - *series_1.get_mut("id").unwrap() = json!(1); - *series_1.get_mut("title").unwrap() = json!("z test"); - *series_2.get_mut("id").unwrap() = json!(2); - *series_2.get_mut("title").unwrap() = json!("A test"); - let expected_series = vec![ - Series { - id: 1, - title: "z test".into(), - ..series() - }, - Series { - id: 2, - title: "A test".into(), - ..series() - }, - ]; - let mut expected_sorted_series = vec![ - Series { - id: 1, - title: "z test".into(), - ..series() - }, - Series { - id: 2, - title: "A test".into(), - ..series() - }, - ]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([series_1, series_2])), - None, - SonarrEvent::ListSeries, - None, - None, - ) - .await; - app_arc.lock().await.data.sonarr_data.series.sort_asc = true; - if use_custom_sorting { - let cmp_fn = |a: &Series, b: &Series| { - a.title - .text - .to_lowercase() - .cmp(&b.title.text.to_lowercase()) - }; - expected_sorted_series.sort_by(cmp_fn); - let title_sort_option = SortOption { - name: "Title", - cmp_fn: Some(cmp_fn), - }; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .sorting(vec![title_sort_option]); - } - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SeriesVec(series) = network - .handle_sonarr_event(SonarrEvent::ListSeries) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.series.items, - expected_sorted_series - ); - assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc); - assert_eq!(series, expected_series); - } - } - - #[tokio::test] - async fn test_handle_get_series_details_event() { - let expected_series: Series = serde_json::from_str(SERIES_JSON).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(SERIES_JSON).unwrap()), - None, - SonarrEvent::GetSeriesDetails(1), - Some("/1"), - None, - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![series()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Series(series) = network - .handle_sonarr_event(SonarrEvent::GetSeriesDetails(1)) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(series, expected_series); - } - } - - #[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": [{ "id": 1, "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": [{ "id": 1, "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(1), - 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); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryItems(history_items) = network - .handle_sonarr_event(SonarrEvent::GetSeriesHistory(1)) - .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": [{ "id": 1, "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": [{ "id": 1, "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(1), - None, - Some("seriesId=1"), - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![series()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryItems(history_items) = network - .handle_sonarr_event(SonarrEvent::GetSeriesHistory(1)) - .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": [{ "id": 1, "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": [{ "id": 1, "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(1), - 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()); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SonarrHistoryItems(history_items) = network - .handle_sonarr_event(SonarrEvent::GetSeriesHistory(1)) - .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(); - let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap(); - *series_1.get_mut("id").unwrap() = json!(1); - *series_1.get_mut("title").unwrap() = json!("z test"); - *series_2.get_mut("id").unwrap() = json!(2); - *series_2.get_mut("title").unwrap() = json!("A test"); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([series_1, series_2])), - None, - SonarrEvent::ListSeries, - None, - None, - ) - .await; - app_arc - .lock() - .await - .push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); - app_arc.lock().await.data.sonarr_data.series.sort_asc = true; - let cmp_fn = |a: &Series, b: &Series| { - a.title - .text - .to_lowercase() - .cmp(&b.title.text.to_lowercase()) - }; - let title_sort_option = SortOption { - name: "Title", - cmp_fn: Some(cmp_fn), - }; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .sorting(vec![title_sort_option]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::ListSeries) - .await - .is_ok()); - - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .sonarr_data - .series - .items - .is_empty()); - assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc); - } - - #[tokio::test] - async fn test_handle_get_sonarr_security_config_event() { - let security_config_response = json!({ - "authenticationMethod": "forms", - "authenticationRequired": "disabledForLocalAddresses", - "username": "test", - "password": "some password", - "apiKey": "someApiKey12345", - "certificateValidation": "disabledForLocalAddresses", - }); - let response: SecurityConfig = - serde_json::from_value(security_config_response.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(security_config_response), - None, - SonarrEvent::GetSecurityConfig, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::SecurityConfig(security_config) = network - .handle_sonarr_event(SonarrEvent::GetSecurityConfig) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(security_config, response); - } - } - - #[tokio::test] - async fn test_handle_get_status_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!({ - "version": "v1", - "startTime": "2023-02-25T20:16:43Z" - })), - None, - SonarrEvent::GetStatus, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let date_time = DateTime::from(DateTime::parse_from_rfc3339("2023-02-25T20:16:43Z").unwrap()); - - if let SonarrSerdeable::SystemStatus(status) = network - .handle_sonarr_event(SonarrEvent::GetStatus) - .await - .unwrap() - { - async_server.assert_async().await; - assert_str_eq!(app_arc.lock().await.data.sonarr_data.version, "v1"); - assert_eq!(app_arc.lock().await.data.sonarr_data.start_time, date_time); - assert_eq!( - status, - SystemStatus { - version: "v1".to_owned(), - start_time: date_time - } - ); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_tags_event() { - let tags_json = json!([{ - "id": 2222, - "label": "usenet" - }]); - let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(tags_json), - None, - SonarrEvent::GetTags, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Tags(tags) = network - .handle_sonarr_event(SonarrEvent::GetTags) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.tags_map, - BiMap::from_iter([(2222i64, "usenet".to_owned())]) - ); - assert_eq!(tags, response); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_tasks_event() { - let tasks_json = json!([{ - "name": "Application Update Check", - "taskName": "ApplicationUpdateCheck", - "interval": 360, - "lastExecution": "2023-05-20T21:29:16Z", - "nextExecution": "2023-05-20T21:29:16Z", - }, - { - "name": "Backup", - "taskName": "Backup", - "interval": 10080, - "lastExecution": "2023-05-20T21:29:16Z", - "nextExecution": "2023-05-20T21:29:16Z", - }]); - let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); - let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); - let expected_tasks = vec![ - SonarrTask { - name: "Application Update Check".to_owned(), - task_name: SonarrTaskName::ApplicationUpdateCheck, - interval: 360, - last_execution: timestamp, - next_execution: timestamp, - }, - SonarrTask { - name: "Backup".to_owned(), - task_name: SonarrTaskName::Backup, - interval: 10080, - last_execution: timestamp, - next_execution: timestamp, - }, - ]; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(tasks_json), - None, - SonarrEvent::GetTasks, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Tasks(tasks) = network - .handle_sonarr_event(SonarrEvent::GetTasks) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.tasks.items, - expected_tasks - ); - assert_eq!(tasks, response); - } - } - - #[tokio::test] - async fn test_handle_get_sonarr_updates_event() { - let updates_json = json!([{ - "version": "4.3.2.1", - "releaseDate": "2023-04-15T02:02:53Z", - "installed": true, - "installedOn": "2023-04-15T02:02:53Z", - "latest": true, - "changes": { - "new": [ - "Cool new thing" - ], - "fixed": [ - "Some bugs killed" - ] - }, - }, - { - "version": "3.2.1.0", - "releaseDate": "2023-04-15T02:02:53Z", - "installed": false, - "installedOn": "2023-04-15T02:02:53Z", - "latest": false, - "changes": { - "new": [ - "Cool new thing (old)", - "Other cool new thing (old)" - ], - }, - }, - { - "version": "2.1.0", - "releaseDate": "2023-04-15T02:02:53Z", - "installed": false, - "latest": false, - "changes": { - "fixed": [ - "Killed bug 1", - "Fixed bug 2" - ] - }, - }]); - let response: Vec = serde_json::from_value(updates_json.clone()).unwrap(); - let line_break = "-".repeat(200); - let expected_text = ScrollableText::with_string(formatdoc!( - " - The latest version of Sonarr is already installed - - 4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) - {line_break} - New: - * Cool new thing - Fixed: - * Some bugs killed - - - 3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) - {line_break} - New: - * Cool new thing (old) - * Other cool new thing (old) - - - 2.1.0 - 2023-04-15 02:02:53 UTC - {line_break} - Fixed: - * Killed bug 1 - * Fixed bug 2" - )); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(updates_json), - None, - SonarrEvent::GetUpdates, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Updates(updates) = network - .handle_sonarr_event(SonarrEvent::GetUpdates) - .await - .unwrap() - { - async_server.assert_async().await; - assert_str_eq!( - app_arc.lock().await.data.sonarr_data.updates.get_text(), - expected_text.get_text() - ); - assert_eq!(updates, response); - } - } - - #[tokio::test] - async fn test_handle_mark_sonarr_history_item_as_failed_event() { - let expected_history_item_id = 1; - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - None, - Some(json!({})), - None, - SonarrEvent::MarkHistoryItemAsFailed(expected_history_item_id), - Some("/1"), - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::MarkHistoryItemAsFailed( - expected_history_item_id - )) - .await - .is_ok()); - 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("test term".into()), - None, - Some("term=test%20term"), - ) - .await; - app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); - app_arc.lock().await.server_tabs.next(); - 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("test term".into())) - .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_no_results() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!([])), - None, - SonarrEvent::SearchNewSeries("test term".into()), - None, - Some("term=test%20term"), - ) - .await; - app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::SearchNewSeries("test term".into())) - .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_start_sonarr_task_event() { - let response = json!({ "test": "test"}); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "ApplicationUpdateCheck" - })), - Some(response.clone()), - None, - SonarrEvent::StartTask(SonarrTaskName::ApplicationUpdateCheck), - None, - None, - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .tasks - .set_items(vec![SonarrTask { - task_name: SonarrTaskName::default(), - ..SonarrTask::default() - }]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Value(value) = network - .handle_sonarr_event(SonarrEvent::StartTask( - SonarrTaskName::ApplicationUpdateCheck, - )) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(value, response); - } - } - - #[tokio::test] - async fn test_handle_test_sonarr_indexer_event_error() { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let response_json = json!([ - { - "isWarning": false, - "propertyName": "", - "errorMessage": "test failure", - "severity": "error" - }]); - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json.clone()), - None, - SonarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_test_server = server - .mock( - "POST", - format!("/api/v3{}", SonarrEvent::TestIndexer(1).resource()).as_str(), - ) - .with_status(400) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(indexer_details_json.clone())) - .with_body(response_json.to_string()) - .create_async() - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .indexers - .set_items(vec![indexer()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Value(value) = network - .handle_sonarr_event(SonarrEvent::TestIndexer(1)) - .await - .unwrap() - { - async_details_server.assert_async().await; - async_test_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.indexer_test_errors, - Some("\"test failure\"".to_owned()) - ); - assert_eq!(value, response_json) - } - } - - #[tokio::test] - async fn test_handle_test_sonarr_indexer_event_success() { - let indexer_details_json = json!({ - "enableRss": true, - "enableAutomaticSearch": true, - "enableInteractiveSearch": true, - "name": "Test Indexer", - "fields": [ - { - "name": "baseUrl", - "value": "https://test.com", - }, - { - "name": "apiKey", - "value": "", - }, - { - "name": "seedCriteria.seedRatio", - "value": "1.2", - }, - ], - "tags": [1], - "id": 1 - }); - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(indexer_details_json.clone()), - None, - SonarrEvent::GetIndexers, - Some("/1"), - None, - ) - .await; - let async_test_server = server - .mock( - "POST", - format!("/api/v3{}", SonarrEvent::TestIndexer(1).resource()).as_str(), - ) - .with_status(200) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(indexer_details_json.clone())) - .with_body("{}") - .create_async() - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .indexers - .set_items(vec![indexer()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::Value(value) = network - .handle_sonarr_event(SonarrEvent::TestIndexer(1)) - .await - .unwrap() - { - async_details_server.assert_async().await; - async_test_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.indexer_test_errors, - Some(String::new()) - ); - assert_eq!(value, json!({})); - } - } - - #[tokio::test] - async fn test_handle_test_all_sonarr_indexers_event() { - let indexers = vec![ - Indexer { - id: 1, - name: Some("Test 1".to_owned()), - ..Indexer::default() - }, - Indexer { - id: 2, - name: Some("Test 2".to_owned()), - ..Indexer::default() - }, - ]; - let indexer_test_results_modal_items = vec![ - IndexerTestResultModalItem { - name: "Test 1".to_owned(), - is_valid: true, - validation_failures: HorizontallyScrollableText::default(), - }, - IndexerTestResultModalItem { - name: "Test 2".to_owned(), - is_valid: false, - validation_failures: "Failure for field 'test field 1': test error message, Failure for field 'test field 2': test error message 2".into(), - }, - ]; - let response_json = json!([ - { - "id": 1, - "isValid": true, - "validationFailures": [] - }, - { - "id": 2, - "isValid": false, - "validationFailures": [ - { - "propertyName": "test field 1", - "errorMessage": "test error message", - "severity": "error" - }, - { - "propertyName": "test field 2", - "errorMessage": "test error message 2", - "severity": "error" - }, - ] - }]); - let response: Vec = serde_json::from_value(response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - None, - Some(response_json), - Some(400), - SonarrEvent::TestAllIndexers, - None, - None, - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .indexers - .set_items(indexers); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let SonarrSerdeable::IndexerTestResults(results) = network - .handle_sonarr_event(SonarrEvent::TestAllIndexers) - .await - .unwrap() - { - async_server.assert_async().await; - assert!(app_arc - .lock() - .await - .data - .sonarr_data - .indexer_test_all_results - .is_some()); - assert_eq!( - app_arc - .lock() - .await - .data - .sonarr_data - .indexer_test_all_results - .as_ref() - .unwrap() - .items, - indexer_test_results_modal_items - ); - assert_eq!(results, response); - } - } - - #[tokio::test] - async fn test_handle_toggle_episode_monitoring_event() { - let expected_body = MonitorEpisodeBody { - episode_ids: vec![2], - monitored: false, - }; - let body = Episode { id: 2, ..episode() }; - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(json!(body)), - None, - SonarrEvent::GetEpisodeDetails(2), - Some("/2"), - None, - ) - .await; - let async_toggle_server = server - .mock( - "PUT", - format!( - "/api/v3{}", - SonarrEvent::ToggleEpisodeMonitoring(2).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(json!(expected_body))) - .create_async() - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::ToggleEpisodeMonitoring(2)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_toggle_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_toggle_season_monitoring_event() { - let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); - *expected_body - .get_mut("seasons") - .unwrap() - .as_array_mut() - .unwrap() - .iter_mut() - .find(|season| season["seasonNumber"] == 1) - .unwrap() - .get_mut("monitored") - .unwrap() = json!(false); - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(SERIES_JSON).unwrap()), - None, - SonarrEvent::GetSeriesDetails(1), - Some("/1"), - None, - ) - .await; - let async_toggle_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - SonarrEvent::ToggleSeasonMonitoring((1, 1)).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - { - let mut app = app_arc.lock().await; - app.data.sonarr_data.series.set_items(vec![series()]); - app.data.sonarr_data.seasons.set_items(vec![season()]); - } - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::ToggleSeasonMonitoring((1, 1))) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_toggle_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_toggle_series_monitoring_event() { - let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); - *expected_body.get_mut("monitored").unwrap() = json!(false); - - let (async_details_server, app_arc, mut server) = mock_servarr_api( - RequestMethod::Get, - None, - Some(serde_json::from_str(SERIES_JSON).unwrap()), - None, - SonarrEvent::GetSeriesDetails(1), - Some("/1"), - None, - ) - .await; - let async_toggle_server = server - .mock( - "PUT", - format!( - "/api/v3{}/1", - SonarrEvent::ToggleSeriesMonitoring(1).resource() - ) - .as_str(), - ) - .with_status(202) - .match_header("X-Api-Key", "test1234") - .match_body(Matcher::Json(expected_body)) - .create_async() - .await; - { - let mut app = app_arc.lock().await; - app.data.sonarr_data.series.set_items(vec![series()]); - app.data.sonarr_data.seasons.set_items(vec![season()]); - } - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::ToggleSeriesMonitoring(1)) - .await - .is_ok()); - - async_details_server.assert_async().await; - async_toggle_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_trigger_automatic_episode_search_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "EpisodeSearch", - "episodeIds": [ 1 ] - })), - Some(json!({})), - None, - SonarrEvent::TriggerAutomaticEpisodeSearch(1), - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::TriggerAutomaticEpisodeSearch(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_trigger_automatic_season_search_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "SeasonSearch", - "seriesId": 1, - "seasonNumber": 1 - })), - Some(json!({})), - None, - SonarrEvent::TriggerAutomaticSeasonSearch((1, 1)), - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch((1, 1))) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_trigger_automatic_series_search_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "SeriesSearch", - "seriesId": 1 - })), - Some(json!({})), - None, - SonarrEvent::TriggerAutomaticSeriesSearch(1), - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeriesSearch(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_update_all_series_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "RefreshSeries", - })), - Some(json!({})), - None, - SonarrEvent::UpdateAllSeries, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::UpdateAllSeries) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_update_and_scan_series_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "RefreshSeries", - "seriesId": 1, - })), - Some(json!({})), - None, - SonarrEvent::UpdateAndScanSeries(1), - None, - None, - ) - .await; - app_arc - .lock() - .await - .data - .sonarr_data - .series - .set_items(vec![series()]); - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::UpdateAndScanSeries(1)) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_handle_update_sonarr_downloads_event() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "RefreshMonitoredDownloads" - })), - Some(json!({})), - None, - SonarrEvent::UpdateDownloads, - None, - None, - ) - .await; - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert!(network - .handle_sonarr_event(SonarrEvent::UpdateDownloads) - .await - .is_ok()); - - async_server.assert_async().await; - } - - #[tokio::test] - async fn test_extract_and_add_sonarr_tag_ids_vec() { - let app_arc = Arc::new(Mutex::new(App::test_default())); - let tags = " test,HI ,, usenet "; - { - let mut app = app_arc.lock().await; - app.data.sonarr_data.tags_map = BiMap::from_iter([ - (1, "usenet".to_owned()), - (2, "test".to_owned()), - (3, "hi".to_owned()), - ]); - } - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert_eq!( - network.extract_and_add_sonarr_tag_ids_vec(tags).await, - vec![2, 3, 1] - ); - } - - #[tokio::test] - async fn test_extract_and_add_sonarr_tag_ids_vec_add_missing_tags_first() { - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ "label": "TESTING" })), - Some(json!({ "id": 3, "label": "testing" })), - None, - SonarrEvent::GetTags, - None, - None, - ) - .await; - let tags = "usenet, test, TESTING"; - { - let mut app = app_arc.lock().await; - app.data.sonarr_data.add_series_modal = Some(AddSeriesModal { - tags: tags.into(), - ..AddSeriesModal::default() - }); - app.data.sonarr_data.tags_map = - BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); - } - app_arc.lock().await.server_tabs.next(); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let tag_ids_vec = network.extract_and_add_sonarr_tag_ids_vec(tags).await; - - async_server.assert_async().await; - assert_eq!(tag_ids_vec, vec![1, 2, 3]); - assert_eq!( - app_arc.lock().await.data.sonarr_data.tags_map, - BiMap::from_iter([ - (1, "usenet".to_owned()), - (2, "test".to_owned()), - (3, "testing".to_owned()) - ]) - ); - } - - #[test] - fn test_get_episode_status_downloaded() { - assert_str_eq!(get_episode_status(true, &[], 0), "Downloaded"); - } - - #[test] - fn test_get_episode_status_missing() { - let download_record = DownloadRecord { - episode_id: Some(Number::from(1i64)), - ..DownloadRecord::default() - }; - - assert_str_eq!( - get_episode_status(false, slice::from_ref(&download_record), 0), - "Missing" - ); - - assert_str_eq!(get_episode_status(false, &[download_record], 1), "Missing"); - } - - #[test] - fn test_get_episode_status_missing_if_episode_id_is_missing() { - let download_record = DownloadRecord::default(); - - assert_str_eq!( - get_episode_status(false, slice::from_ref(&download_record), 0), - "Missing" - ); - - assert_str_eq!(get_episode_status(false, &[download_record], 1), "Missing"); - } - - #[test] - fn test_get_episode_status_downloading() { - assert_str_eq!( - get_episode_status( - false, - &[DownloadRecord { - episode_id: Some(Number::from(1i64)), - status: DownloadStatus::Downloading, - ..DownloadRecord::default() - }], - 1 - ), - "Downloading" - ); - } - - #[test] - fn test_get_episode_status_awaiting_import() { - assert_str_eq!( - get_episode_status( - false, - &[DownloadRecord { - episode_id: Some(Number::from(1i64)), - status: DownloadStatus::Completed, - ..DownloadRecord::default() - }], - 1 - ), - "Awaiting Import" - ); - } - - fn add_series_search_result() -> AddSeriesSearchResult { - AddSeriesSearchResult { - tvdb_id: 1234, - title: HorizontallyScrollableText::from("Test"), - status: Some("continuing".to_owned()), - ended: false, - overview: Some("New series blah blah blah".to_owned()), - genres: genres(), - year: 2023, - network: Some("Prime Video".to_owned()), - runtime: 60, - ratings: Some(rating()), - statistics: Some(add_series_search_result_statistics()), - } - } - - fn add_series_search_result_statistics() -> AddSeriesSearchResultStatistics { - AddSeriesSearchResultStatistics { season_count: 3 } - } - - fn blocklist_item() -> BlocklistItem { - BlocklistItem { - id: 1, - series_id: 1, - series_title: None, - episode_ids: vec![Number::from(1)], - source_title: "Test Source Title".to_owned(), - languages: vec![language()], - quality: quality_wrapper(), - date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), - protocol: "usenet".to_owned(), - indexer: "NZBgeek (Prowlarr)".to_owned(), - message: "test message".to_owned(), - } - } - - fn download_record() -> DownloadRecord { - DownloadRecord { - title: "Test Download Title".to_owned(), - status: DownloadStatus::Downloading, - id: 1, - episode_id: Some(Number::from(1i64)), - size: 3543348019f64, - sizeleft: 1771674009f64, - output_path: Some(HorizontallyScrollableText::from( - "/nfs/tv/Test show/season 1/", - )), - indexer: "kickass torrents".to_owned(), - download_client: Some("transmission".to_owned()), - } - } - - fn downloads_response() -> DownloadsResponse { - DownloadsResponse { - records: vec![download_record()], - } - } - - fn episode() -> Episode { - Episode { - id: 1, - series_id: 1, - tvdb_id: 1234, - episode_file_id: 1, - season_number: 1, - episode_number: 1, - title: "Something cool".to_owned(), - air_date_utc: Some(DateTime::from( - DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap(), - )), - overview: Some("Okay so this one time at band camp...".to_owned()), - has_file: true, - monitored: true, - episode_file: Some(episode_file()), - } - } - - fn episode_file() -> EpisodeFile { - EpisodeFile { - id: 1, - relative_path: "/season 1/episode 1.mkv".to_owned(), - path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), - size: 3543348019, - quality: quality_wrapper(), - languages: vec![language()], - date_added: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), - media_info: Some(media_info()), - } - } - - fn genres() -> Vec { - vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()] - } - - fn history_data() -> SonarrHistoryData { - SonarrHistoryData { - dropped_path: Some("/nfs/nzbget/completed/series/Coolness/something.cool.mkv".to_owned()), - imported_path: Some( - "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv".to_owned(), - ), - ..SonarrHistoryData::default() - } - } - - 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: SonarrHistoryEventType::Grabbed, - data: history_data(), - } - } - - fn indexer() -> Indexer { - Indexer { - enable_rss: true, - enable_automatic_search: true, - enable_interactive_search: true, - supports_rss: true, - supports_search: true, - protocol: "torrent".to_owned(), - priority: 25, - download_client_id: 0, - name: Some("Test Indexer".to_owned()), - implementation_name: Some("Torznab".to_owned()), - implementation: Some("Torznab".to_owned()), - config_contract: Some("TorznabSettings".to_owned()), - tags: vec![Number::from(1)], - id: 1, - fields: Some(vec![ - IndexerField { - name: Some("baseUrl".to_owned()), - value: Some(json!("https://test.com")), - }, - IndexerField { - name: Some("apiKey".to_owned()), - value: Some(json!("")), - }, - IndexerField { - name: Some("seedCriteria.seedRatio".to_owned()), - value: Some(json!("1.2")), - }, - ]), - } - } - - fn indexer_settings() -> IndexerSettings { - IndexerSettings { - id: 1, - minimum_age: 1, - retention: 1, - maximum_size: 12345, - rss_sync_interval: 60, - } - } - - fn language() -> Language { - Language { - id: 1, - name: "English".to_owned(), - } - } - - fn media_info() -> MediaInfo { - MediaInfo { - audio_bitrate: 0, - audio_channels: Number::from_f64(7.1).unwrap(), - audio_codec: Some("AAC".to_owned()), - audio_languages: Some("eng".to_owned()), - audio_stream_count: 1, - video_bit_depth: 10, - video_bitrate: 0, - video_codec: Some("x265".to_owned()), - video_fps: Number::from_f64(23.976).unwrap(), - resolution: "1920x1080".to_owned(), - run_time: "23:51".to_owned(), - scan_type: "Progressive".to_owned(), - subtitles: Some("English".to_owned()), - } - } - fn quality() -> Quality { - Quality { - name: "Bluray-1080p".to_owned(), - } - } - - fn quality_wrapper() -> QualityWrapper { - QualityWrapper { quality: quality() } - } - - fn rating() -> Rating { - Rating { - votes: 406744, - value: 8.4, - } - } - - fn season() -> Season { - Season { - title: None, - season_number: 1, - monitored: true, - statistics: Some(season_statistics()), - } - } - - fn season_statistics() -> SeasonStatistics { - SeasonStatistics { - previous_airing: Some(DateTime::from( - DateTime::parse_from_rfc3339("2022-10-24T01:00:00Z").unwrap(), - )), - next_airing: None, - episode_file_count: 10, - episode_count: 10, - total_episode_count: 10, - size_on_disk: 36708563419, - percent_of_episodes: 100.0, - } - } - - fn series() -> Series { - Series { - title: "Test".to_owned().into(), - status: SeriesStatus::Continuing, - ended: false, - overview: Some("Blah blah blah".to_owned()), - network: Some("HBO".to_owned()), - seasons: Some(vec![season()]), - year: 2022, - path: "/nfs/tv/Test".to_owned(), - quality_profile_id: 6, - language_profile_id: 1, - season_folder: true, - monitored: true, - runtime: 63, - tvdb_id: 371572, - series_type: SeriesType::Standard, - certification: Some("TV-MA".to_owned()), - genres: vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()], - tags: vec![Number::from(3)], - ratings: rating(), - statistics: Some(series_statistics()), - id: 1, - } - } - - fn series_statistics() -> SeriesStatistics { - SeriesStatistics { - season_count: 2, - episode_file_count: 18, - episode_count: 18, - total_episode_count: 50, - size_on_disk: 63894022699, - percent_of_episodes: 100.0, - } - } - - fn rejections() -> Vec { - vec![ - "Unknown quality profile".to_owned(), - "Release is already mapped".to_owned(), - ] - } - - fn release() -> SonarrRelease { - SonarrRelease { - guid: "1234".to_owned(), - protocol: "torrent".to_owned(), - age: 1, - title: HorizontallyScrollableText::from("Test Release"), - indexer: "kickass torrents".to_owned(), - indexer_id: 2, - size: 1234, - rejected: true, - rejections: Some(rejections()), - seeders: Some(Number::from(2)), - leechers: Some(Number::from(1)), - languages: Some(vec![language()]), - quality: quality_wrapper(), - full_season: false, - } - } - - fn root_folder() -> RootFolder { - RootFolder { - id: 1, - path: "/nfs".to_owned(), - accessible: true, - free_space: 219902325555200, - unmapped_folders: None, - } - } -}