diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 102a064..6b98a5e 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -48,6 +48,7 @@ mod tests { assert!(!app.is_routing); assert!(!app.should_refresh); assert!(!app.should_ignore_quit_key); + assert!(!app.cli_mode); } #[test] diff --git a/src/app/mod.rs b/src/app/mod.rs index 764280a..ba95fcc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -36,6 +36,7 @@ pub struct App<'a> { pub is_loading: bool, pub should_refresh: bool, pub should_ignore_quit_key: bool, + pub cli_mode: bool, pub config: AppConfig, pub data: Data<'a>, } @@ -164,6 +165,7 @@ impl<'a> Default for App<'a> { is_routing: false, should_refresh: false, should_ignore_quit_key: false, + cli_mode: false, config: AppConfig::default(), data: Data::default(), } diff --git a/src/app/radarr/radarr_tests.rs b/src/app/radarr/radarr_tests.rs index 286d21f..7a2ab5a 100644 --- a/src/app/radarr/radarr_tests.rs +++ b/src/app/radarr/radarr_tests.rs @@ -6,9 +6,10 @@ mod tests { use crate::app::radarr::ActiveRadarrBlock; use crate::app::App; - use crate::models::radarr_models::{Collection, CollectionMovie, Credit, Release}; + use crate::models::radarr_models::{Collection, CollectionMovie, Credit}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; + use crate::models::servarr_models::Release; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkEvent; diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index 2dd8551..d1b94b8 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -11,10 +11,9 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::blocklist::{blocklist_sorting_options, BlocklistHandler}; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{ - BlocklistItem, BlocklistItemMovie, Language, Quality, QualityWrapper, - }; + use crate::models::radarr_models::{BlocklistItem, BlocklistItemMovie}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; use crate::models::stateful_table::SortOption; mod test_handle_scroll_up_and_down { diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index c5711b9..f50c804 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -11,11 +11,12 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::library::{movies_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{Language, Movie}; + use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, ADD_MOVIE_BLOCKS, DELETE_MOVIE_BLOCKS, EDIT_MOVIE_BLOCKS, LIBRARY_BLOCKS, MOVIE_DETAILS_BLOCKS, }; + use crate::models::servarr_models::Language; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; use crate::test_handler_delegation; diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 35f42dc..fce5b46 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -4,10 +4,10 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; -use crate::models::radarr_models::{Language, Release}; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_MOVIE_SELECTION_BLOCKS, MOVIE_DETAILS_BLOCKS, }; +use crate::models::servarr_models::{Language, Release}; use crate::models::stateful_table::SortOption; use crate::models::{BlockSelectionState, Scrollable}; use crate::network::radarr_network::RadarrEvent; diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index e9c45b4..174232b 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -14,11 +14,10 @@ mod tests { releases_sorting_options, MovieDetailsHandler, }; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{ - Credit, Language, MovieHistoryItem, Quality, QualityWrapper, Release, - }; + use crate::models::radarr_models::{Credit, MovieHistoryItem}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper, Release}; use crate::models::stateful_table::SortOption; use crate::models::{HorizontallyScrollableText, ScrollableText}; diff --git a/src/main.rs b/src/main.rs index 3db416f..b45d3e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,6 +113,7 @@ async fn main() -> Result<()> { match args.command { Some(command) => match command { Command::Radarr(_) | Command::Sonarr(_) => { + app.lock().await.cli_mode = true; let app_nw = Arc::clone(&app); let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 83aef7c..89f45b8 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -9,7 +9,10 @@ use strum_macros::EnumIter; use crate::{models::HorizontallyScrollableText, serde_enum_from}; -use super::servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}; +use super::servarr_models::{ + HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, Release, + SecurityConfig, +}; use super::Serdeable; #[cfg(test)] @@ -262,28 +265,6 @@ pub struct IndexerValidationFailure { pub severity: String, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Language { - pub name: String, -} - -#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Log { - pub time: DateTime, - pub exception: Option, - pub exception_type: Option, - pub level: String, - pub logger: Option, - pub message: Option, - pub method: Option, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct LogResponse { - pub records: Vec, -} - #[derive(Serialize, Deserialize, Derivative, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] #[serde(rename_all = "camelCase")] @@ -436,32 +417,6 @@ pub struct MovieHistoryItem { pub event_type: String, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Quality { - pub name: String, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct QualityProfile { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: String, -} - -impl From<(&i64, &String)> for QualityProfile { - fn from(value: (&i64, &String)) -> Self { - QualityProfile { - id: *value.0, - name: value.1.clone(), - } - } -} - -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct QualityWrapper { - pub quality: Quality, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] pub struct Rating { @@ -477,28 +432,6 @@ pub struct RatingsList { pub rotten_tomatoes: Option, } -#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -#[serde(default)] -pub struct Release { - pub guid: String, - pub protocol: String, - #[serde(deserialize_with = "super::from_i64")] - pub age: i64, - pub title: HorizontallyScrollableText, - pub indexer: String, - #[serde(deserialize_with = "super::from_i64")] - pub indexer_id: i64, - #[serde(deserialize_with = "super::from_i64")] - pub size: i64, - pub rejected: bool, - pub rejections: Option>, - pub seeders: Option, - pub leechers: Option, - pub languages: Option>, - pub quality: QualityWrapper, -} - #[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] #[serde(rename_all = "camelCase")] pub struct ReleaseDownloadBody { diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index da0c5df..3ef03b7 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -6,11 +6,11 @@ mod tests { use crate::models::{ radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, - DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, Log, - LogResponse, MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, - RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, + DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, + MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, RadarrSerdeable, + Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, }, - servarr_models::{HostConfig, QueueEvent, SecurityConfig}, + servarr_models::{HostConfig, Log, LogResponse, QueueEvent, SecurityConfig}, Serdeable, }; diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 93ef321..8cfce95 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -1,10 +1,10 @@ use strum::IntoEnumIterator; use crate::models::radarr_models::{ - Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release, RootFolder, + Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, RootFolder, }; use crate::models::servarr_data::radarr::radarr_data::RadarrData; -use crate::models::servarr_models::Indexer; +use crate::models::servarr_models::{Indexer, Release}; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; diff --git a/src/models/servarr_data/radarr/radarr_test_utils.rs b/src/models/servarr_data/radarr/radarr_test_utils.rs index e20e530..b437060 100644 --- a/src/models/servarr_data/radarr/radarr_test_utils.rs +++ b/src/models/servarr_data/radarr/radarr_test_utils.rs @@ -1,10 +1,11 @@ #[cfg(test)] pub mod utils { use crate::models::radarr_models::{ - AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, Release, + AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, }; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::RadarrData; + use crate::models::servarr_models::Release; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 0f00318..7d27bda 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -1,4 +1,6 @@ -use crate::models::ScrollableText; +use crate::models::{ + servarr_models::Release, sonarr_models::Episode, stateful_table::StatefulTable, ScrollableText, +}; #[derive(Default)] pub struct EpisodeDetailsModal { @@ -9,5 +11,13 @@ pub struct EpisodeDetailsModal { // pub episode_history: StatefulTable, // pub episode_cast: StatefulTable, // pub episode_crew: StatefulTable, - // pub episode_releases: StatefulTable, + pub episode_releases: StatefulTable, +} + +#[derive(Default)] +pub struct SeasonDetailsModal { + pub season_details: ScrollableText, + pub episodes: StatefulTable, + pub episode_details_modal: Option, + pub season_releases: StatefulTable, } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 427ea0f..88fb209 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -4,14 +4,13 @@ use strum::EnumIter; use crate::models::{ servarr_models::{Indexer, QueueEvent}, - sonarr_models::{BlocklistItem, DownloadRecord, Episode, IndexerSettings, Series}, + sonarr_models::{BlocklistItem, DownloadRecord, IndexerSettings, Season, Series}, stateful_list::StatefulList, stateful_table::StatefulTable, - stateful_tree::StatefulTree, HorizontallyScrollableText, Route, }; -use super::modals::EpisodeDetailsModal; +use super::modals::SeasonDetailsModal; #[cfg(test)] #[path = "sonarr_data_tests.rs"] @@ -20,14 +19,13 @@ mod sonarr_data_tests; pub struct SonarrData { pub blocklist: StatefulTable, pub downloads: StatefulTable, - pub episode_details_modal: Option, - pub episodes_table: StatefulTable, - pub episodes_tree: StatefulTree, pub indexers: StatefulTable, pub indexer_settings: Option, pub logs: StatefulList, pub quality_profile_map: BiMap, pub queued_events: StatefulTable, + pub seasons: StatefulTable, + pub season_details_modal: Option, pub series: StatefulTable, pub start_time: DateTime, pub version: String, @@ -38,15 +36,14 @@ impl Default for SonarrData { SonarrData { blocklist: StatefulTable::default(), downloads: StatefulTable::default(), - episode_details_modal: None, - episodes_table: StatefulTable::default(), - episodes_tree: StatefulTree::default(), indexers: StatefulTable::default(), indexer_settings: None, logs: StatefulList::default(), quality_profile_map: BiMap::new(), queued_events: StatefulTable::default(), + seasons: StatefulTable::default(), series: StatefulTable::default(), + season_details_modal: None, start_time: DateTime::default(), version: String::new(), } @@ -57,9 +54,10 @@ impl Default for SonarrData { pub enum ActiveSonarrBlock { Blocklist, BlocklistSortPrompt, - EpisodesExplorer, - EpisodesTable, - EpisodesTableSortPrompt, + Episodes, + EpisodesSortPrompt, + Seasons, + SeasonsSortPrompt, #[default] Series, SeriesSortPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 49efe6c..c4dd645 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -36,14 +36,13 @@ mod tests { assert!(sonarr_data.blocklist.is_empty()); assert!(sonarr_data.downloads.is_empty()); - assert!(sonarr_data.episode_details_modal.is_none()); - assert!(sonarr_data.episodes_table.is_empty()); - assert!(sonarr_data.episodes_tree.is_empty()); assert!(sonarr_data.indexers.is_empty()); assert!(sonarr_data.indexer_settings.is_none()); assert!(sonarr_data.logs.is_empty()); assert!(sonarr_data.quality_profile_map.is_empty()); assert!(sonarr_data.queued_events.is_empty()); + assert!(sonarr_data.seasons.is_empty()); + assert!(sonarr_data.season_details_modal.is_none()); assert!(sonarr_data.series.is_empty()); assert_eq!(sonarr_data.start_time, >::default()); assert!(sonarr_data.version.is_empty()); diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 8c04e9e..909199f 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -114,6 +114,54 @@ pub struct IndexerField { pub value: Option, } +#[derive(Serialize, Deserialize, Default, Hash, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct Language { + pub name: String, +} + +#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Log { + pub time: DateTime, + pub exception: Option, + pub exception_type: Option, + pub level: String, + pub logger: Option, + pub message: Option, + pub method: Option, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct LogResponse { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct Quality { + pub name: String, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct QualityProfile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: String, +} + +impl From<(&i64, &String)> for QualityProfile { + fn from(value: (&i64, &String)) -> Self { + QualityProfile { + id: *value.0, + name: value.1.clone(), + } + } +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct QualityWrapper { + pub quality: Quality, +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct QueueEvent { @@ -127,6 +175,28 @@ pub struct QueueEvent { pub duration: Option, } +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[serde(default)] +pub struct Release { + pub guid: String, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub age: i64, + pub title: HorizontallyScrollableText, + pub indexer: String, + #[serde(deserialize_with = "super::from_i64")] + pub indexer_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub rejected: bool, + pub rejections: Option>, + pub seeders: Option, + pub leechers: Option, + pub languages: Option>, + pub quality: QualityWrapper, +} + #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SecurityConfig { diff --git a/src/models/servarr_models_tests.rs b/src/models/servarr_models_tests.rs index 8b1468d..dfe4cc9 100644 --- a/src/models/servarr_models_tests.rs +++ b/src/models/servarr_models_tests.rs @@ -1,9 +1,9 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_str_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use crate::models::servarr_models::{ - AuthenticationMethod, AuthenticationRequired, CertificateValidation, + AuthenticationMethod, AuthenticationRequired, CertificateValidation, QualityProfile, }; #[test] @@ -31,4 +31,19 @@ mod tests { ); assert_str_eq!(CertificateValidation::Disabled.to_string(), "disabled"); } + + #[test] + fn test_quality_profile_from_tuple_ref() { + let id = 2; + let name = "Test".to_owned(); + let quality_profile_tuple = (&id, &name); + let expected_quality_profile = QualityProfile { + id: 2, + name: "Test".to_owned(), + }; + + let quality_profile = QualityProfile::from(quality_profile_tuple); + + assert_eq!(expected_quality_profile, quality_profile); + } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index a712e7f..ca74870 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -10,7 +10,10 @@ use strum::EnumIter; use crate::serde_enum_from; use super::{ - servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}, + servarr_models::{ + HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, + Release, SecurityConfig, + }, HorizontallyScrollableText, Serdeable, }; @@ -121,28 +124,6 @@ pub struct IndexerSettings { pub rss_sync_interval: i64, } -#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Language { - pub name: String, -} - -#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Log { - pub time: DateTime, - pub exception: Option, - pub exception_type: Option, - pub level: String, - pub logger: Option, - pub message: Option, - pub method: Option, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct LogResponse { - pub records: Vec, -} - #[derive(Serialize, Deserialize, Derivative, Hash, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] #[serde(rename_all = "camelCase")] @@ -168,23 +149,6 @@ pub struct MediaInfo { pub subtitles: Option, } -#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Quality { - pub name: String, -} - -#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct QualityWrapper { - pub quality: Quality, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct QualityProfile { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: String, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)] #[derivative(Default)] pub struct Rating { @@ -353,6 +317,7 @@ pub enum SonarrSerdeable { Indexers(Vec), QualityProfiles(Vec), QueueEvents(Vec), + Releases(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), SystemStatus(SystemStatus), @@ -383,6 +348,7 @@ serde_enum_from!( Indexers(Vec), QualityProfiles(Vec), QueueEvents(Vec), + Releases(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), SystemStatus(SystemStatus), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 4ff109a..9d864e2 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -4,11 +4,12 @@ mod tests { use serde_json::json; use crate::models::{ - servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}, + servarr_models::{ + HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, SecurityConfig, + }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Log, LogResponse, QualityProfile, Series, SeriesStatus, SeriesType, - SonarrSerdeable, SystemStatus, + IndexerSettings, Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, }, Serdeable, }; @@ -243,6 +244,18 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::QueueEvents(queue_events)); } + #[test] + fn test_sonarr_serdeable_from_releases() { + let releases = vec![Release { + size: 1, + ..Release::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = releases.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Releases(releases)); + } + #[test] fn test_sonarr_serdeable_from_security_config() { let security_config = SecurityConfig { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 304895e..aaf6d72 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -10,16 +10,17 @@ use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, AddRootFolderBody, BlocklistResponse, Collection, CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, - IndexerTestResult, LogResponse, Movie, MovieCommandBody, MovieHistoryItem, QualityProfile, - RadarrSerdeable, Release, ReleaseDownloadBody, RootFolder, SystemStatus, Tag, Task, TaskName, - Update, + IndexerTestResult, Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, + ReleaseDownloadBody, RootFolder, SystemStatus, Tag, Task, TaskName, Update, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, MovieDetailsModal, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; -use crate::models::servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}; +use crate::models::servarr_models::{ + HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, SecurityConfig, +}; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; use crate::network::{Network, NetworkEvent, RequestMethod}; @@ -223,9 +224,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_queued_radarr_events() .await .map(RadarrSerdeable::from), - RadarrEvent::GetReleases(movie_id) => { - self.get_releases(movie_id).await.map(RadarrSerdeable::from) - } + RadarrEvent::GetReleases(movie_id) => self + .get_movie_releases(movie_id) + .await + .map(RadarrSerdeable::from), RadarrEvent::GetRootFolders => self.get_root_folders().await.map(RadarrSerdeable::from), RadarrEvent::GetSecurityConfig => self .get_radarr_security_config() @@ -1750,7 +1752,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_releases(&mut self, movie_id: Option) -> Result> { + async fn get_movie_releases(&mut self, movie_id: Option) -> Result> { let (id, movie_id_param) = self.extract_movie_id(movie_id).await; info!("Fetching releases for movie with ID: {id}"); let event = RadarrEvent::GetReleases(None); diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index c80f719..45b7e8d 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -15,11 +15,13 @@ mod test { use crate::app::ServarrConfig; use crate::models::radarr_models::{ - BlocklistItem, BlocklistItemMovie, CollectionMovie, Language, MediaInfo, MinimumAvailability, - Monitor, MovieCollection, MovieFile, Quality, QualityWrapper, Rating, RatingsList, + BlocklistItem, BlocklistItemMovie, CollectionMovie, MediaInfo, MinimumAvailability, Monitor, + MovieCollection, MovieFile, Rating, RatingsList, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; - use crate::models::servarr_models::{HostConfig, IndexerField}; + use crate::models::servarr_models::{ + 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; diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 2b1676d..19ac1a4 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1,18 +1,20 @@ -use std::collections::BTreeMap; - use anyhow::Result; use indoc::formatdoc; use log::{debug, info}; -use managarr_tree_widget::TreeItem; use serde_json::{json, Value}; use crate::{ models::{ - servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, - servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}, + servarr_data::sonarr::{ + modals::{EpisodeDetailsModal, SeasonDetailsModal}, + sonarr_data::ActiveSonarrBlock, + }, + servarr_models::{ + HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, SecurityConfig, + }, sonarr_models::{ - BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, LogResponse, - QualityProfile, Series, SonarrSerdeable, SystemStatus, + BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, + SonarrSerdeable, SystemStatus, }, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, @@ -39,6 +41,7 @@ pub enum SonarrEvent { GetLogs(Option), GetQualityProfiles, GetQueuedEvents, + GetSeasonReleases(Option<(i64, i64)>), GetSecurityConfig, GetStatus, HealthCheck, @@ -59,6 +62,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", + SonarrEvent::GetSeasonReleases(_) => "/release", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries => "/series", @@ -100,11 +104,15 @@ impl<'a, 'b> Network<'a, 'b> { .get_episode_details(episode_id) .await .map(SonarrSerdeable::from), - SonarrEvent::GetIndexers => self.get_sonarr_indexers().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::GetLogs(events) => self + .get_sonarr_logs(events) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetQualityProfiles => self .get_sonarr_quality_profiles() .await @@ -113,8 +121,8 @@ impl<'a, 'b> Network<'a, 'b> { .get_queued_sonarr_events() .await .map(SonarrSerdeable::from), - SonarrEvent::GetLogs(events) => self - .get_sonarr_logs(events) + SonarrEvent::GetSeasonReleases(params) => self + .get_season_releases(params) .await .map(SonarrSerdeable::from), SonarrEvent::GetSecurityConfig => self @@ -288,43 +296,29 @@ impl<'a, 'b> Network<'a, 'b> { episode_vec.sort_by(|a, b| a.id.cmp(&b.id)); if !matches!( app.get_current_route(), - Route::Sonarr(ActiveSonarrBlock::EpisodesTableSortPrompt, _) + Route::Sonarr(ActiveSonarrBlock::EpisodesSortPrompt, _) ) { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + app .data .sonarr_data - .episodes_table + .season_details_modal + .as_mut() + .unwrap() + .episodes .set_items(episode_vec.clone()); app .data .sonarr_data - .episodes_table + .season_details_modal + .as_mut() + .unwrap() + .episodes .apply_sorting_toggle(false); } - - let mut seasons = BTreeMap::new(); - - for episode in episode_vec { - seasons - .entry(episode.season_number) - .or_insert_with(Vec::new) - .push(episode); - } - - let tree = seasons - .into_iter() - .map(|(season, episodes_vec)| { - let marker_episode = Episode { - title: Some(format!("Season {season}")), - ..Episode::default() - }; - let children = episodes_vec.into_iter().map(TreeItem::new_leaf).collect(); - - TreeItem::new(marker_episode, children).expect("All item identifiers must be unique") - }) - .collect(); - - app.data.sonarr_data.episodes_tree.set_items(tree); }) .await } @@ -431,7 +425,15 @@ impl<'a, 'b> Network<'a, 'b> { } }; - app.data.sonarr_data.episode_details_modal = Some(episode_details_modal); + if !app.cli_mode { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is empty") + .episode_details_modal = Some(episode_details_modal); + } }) .await } @@ -548,6 +550,51 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_season_releases( + &mut self, + series_season_id_tuple: Option<(i64, i64)>, + ) -> Result> { + let event = SonarrEvent::GetSeasonReleases(None); + let (series_id, season_number) = + if let Some((series_id, season_number)) = series_season_id_tuple { + (Some(series_id), Some(season_number)) + } else { + (None, None) + }; + + let (series_id, series_id_param) = self.extract_series_id(series_id).await; + let (season_number, season_number_param) = self.extract_season_number(season_number).await; + + 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!("{}&{}", series_id_param, season_number_param)), + ) + .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()); + } + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .set_items(release_vec); + }) + .await + } + async fn get_sonarr_security_config(&mut self) -> Result { info!("Fetching Sonarr security config"); let event = SonarrEvent::GetSecurityConfig; @@ -616,24 +663,38 @@ impl<'a, 'b> Network<'a, 'b> { (series_id, format!("seriesId={series_id}")) } - async fn extract_episode_id(&mut self, episode_id: Option) -> i64 { - let app = self.app.lock().await; - - let episode_id = if let Some(id) = episode_id { - id - } else if matches!( - app.get_current_route(), - Route::Sonarr(ActiveSonarrBlock::EpisodesTable, _) - ) { - app.data.sonarr_data.episodes_table.current_selection().id + async fn extract_season_number(&mut self, season_number: Option) -> (i64, String) { + let season_number = if let Some(number) = season_number { + number } else { - app + self + .app + .lock() + .await .data .sonarr_data - .episodes_tree + .seasons .current_selection() + .season_number + }; + (season_number, format!("seasonNumber={season_number}")) + } + + async fn extract_episode_id(&mut self, episode_id: Option) -> i64 { + let episode_id = if let Some(id) = episode_id { + id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .season_details_modal .as_ref() - .unwrap() + .expect("Season details have not been loaded") + .episodes + .current_selection() .id }; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index b16200d..2209cc8 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -21,16 +21,17 @@ mod test { use tokio_util::sync::CancellationToken; use crate::app::App; + use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ - HostConfig, Indexer, IndexerField, QueueEvent, SecurityConfig, + HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, + QualityWrapper, QueueEvent, Release, SecurityConfig, }; + use crate::models::sonarr_models::BlocklistResponse; + use crate::models::sonarr_models::SystemStatus; use crate::models::sonarr_models::{ - BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Language, LogResponse, - MediaInfo, QualityProfile, + BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, MediaInfo, }; - use crate::models::sonarr_models::{BlocklistResponse, Quality}; - use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; use crate::models::stateful_table::StatefulTable; use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; @@ -157,6 +158,11 @@ mod test { assert_str_eq!(event.resource(), "/indexer"); } + #[rstest] + fn test_resource_release(#[values(SonarrEvent::GetSeasonReleases(None))] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/release"); + } + #[rstest] #[case(SonarrEvent::ClearBlocklist, "/blocklist/bulk")] #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] @@ -408,14 +414,6 @@ mod test { #[rstest] #[tokio::test] async fn test_handle_get_episodes_event(#[values(true, false)] use_custom_sorting: bool) { - let marker_episode_1 = Episode { - title: Some("Season 1".to_owned()), - ..Episode::default() - }; - let marker_episode_2 = Episode { - title: Some("Season 2".to_owned()), - ..Episode::default() - }; let episode_1 = Episode { title: Some("z test".to_owned()), episode_file: None, @@ -432,18 +430,6 @@ mod test { }; let expected_episodes = vec![episode_1.clone(), episode_2.clone()]; let mut expected_sorted_episodes = vec![episode_1.clone(), episode_2.clone()]; - let expected_tree = vec![ - TreeItem::new( - marker_episode_1, - vec![TreeItem::new_leaf(episode_1.clone())], - ) - .unwrap(), - TreeItem::new( - marker_episode_2, - vec![TreeItem::new_leaf(episode_2.clone())], - ) - .unwrap(), - ]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -454,13 +440,8 @@ mod test { Some("seriesId=1"), ) .await; - app_arc - .lock() - .await - .data - .sonarr_data - .episodes_table - .sort_asc = true; + 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 @@ -474,14 +455,11 @@ mod test { name: "Title", cmp_fn: Some(cmp_fn), }; - app_arc - .lock() - .await - .data - .sonarr_data - .episodes_table + 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 @@ -501,7 +479,16 @@ mod test { { async_server.assert_async().await; assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes_table.items, + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .items, expected_sorted_episodes ); assert!( @@ -510,17 +497,63 @@ mod test { .await .data .sonarr_data - .episodes_table + .season_details_modal + .as_ref() + .unwrap() + .episodes .sort_asc ); - assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes_tree.items, - expected_tree - ); 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(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(None)) + .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!([ @@ -551,14 +584,6 @@ mod test { "monitored": true } ]); - let marker_episode_1 = Episode { - title: Some("Season 1".to_owned()), - ..Episode::default() - }; - let marker_episode_2 = Episode { - title: Some("Season 2".to_owned()), - ..Episode::default() - }; let episode_1 = Episode { episode_file: None, ..episode() @@ -572,18 +597,6 @@ mod test { ..episode() }; let mut expected_episodes = vec![episode_2.clone(), episode_1.clone()]; - let expected_tree = vec![ - TreeItem::new( - marker_episode_1, - vec![TreeItem::new_leaf(episode_1.clone())], - ) - .unwrap(), - TreeItem::new( - marker_episode_2, - vec![TreeItem::new_leaf(episode_2.clone())], - ) - .unwrap(), - ]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -597,14 +610,9 @@ mod test { app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTableSortPrompt.into()); - app_arc - .lock() - .await - .data - .sonarr_data - .episodes_table - .sort_asc = true; + .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 .as_ref() @@ -617,12 +625,8 @@ mod test { name: "Title", cmp_fn: Some(cmp_fn), }; - app_arc - .lock() - .await - .data - .sonarr_data - .episodes_table + season_details_modal + .episodes .sorting(vec![title_sort_option]); app_arc .lock() @@ -634,6 +638,7 @@ mod test { id: 1, ..Series::default() }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); if let SonarrSerdeable::Episodes(episodes) = network @@ -647,7 +652,10 @@ mod test { .await .data .sonarr_data - .episodes_table + .season_details_modal + .as_ref() + .unwrap() + .episodes .is_empty()); assert!( app_arc @@ -655,13 +663,12 @@ mod test { .await .data .sonarr_data - .episodes_table + .season_details_modal + .as_ref() + .unwrap() + .episodes .sort_asc ); - assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes_tree.items, - expected_tree - ); assert_eq!(episodes, expected_episodes); } } @@ -791,14 +798,6 @@ mod test { "monitored": true } ]); - let marker_episode_1 = Episode { - title: Some("Season 1".to_owned()), - ..Episode::default() - }; - let marker_episode_2 = Episode { - title: Some("Season 2".to_owned()), - ..Episode::default() - }; let episode_1 = Episode { series_id: 2, episode_file: None, @@ -814,18 +813,6 @@ mod test { ..episode() }; let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; - let expected_tree = vec![ - TreeItem::new( - marker_episode_1, - vec![TreeItem::new_leaf(episode_1.clone())], - ) - .unwrap(), - TreeItem::new( - marker_episode_2, - vec![TreeItem::new_leaf(episode_2.clone())], - ) - .unwrap(), - ]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -844,10 +831,6 @@ mod test { .unwrap() { async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes_tree.items, - expected_tree - ); assert_eq!(episodes, expected_episodes); } } @@ -865,17 +848,13 @@ mod test { 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 - .data - .sonarr_data - .episodes_table - .set_items(vec![episode()]); - app_arc - .lock() - .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); if let SonarrSerdeable::Episode(episode) = network @@ -889,12 +868,23 @@ mod test { .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.episode_details_modal.as_ref().unwrap(); + 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!( @@ -955,6 +945,9 @@ mod test { 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); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); if let SonarrSerdeable::Episode(episode) = network @@ -967,6 +960,75 @@ mod test { } } + #[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(None), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.cli_mode = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(Some(1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(episode, response); + } + } + + #[tokio::test] + #[should_panic(expected = "Season details have not been loaded")] + async fn test_handle_get_episode_details_event_requires_season_details_modal_to_be_some_when_no_parameter_is_passed( + ) { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(None)) + .await + .unwrap(); + } + + #[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() { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(Some(1))) + .await + .unwrap(); + } + #[tokio::test] async fn test_handle_get_sonarr_logs_event() { let expected_logs = vec![ @@ -1188,6 +1250,272 @@ mod test { } } + #[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": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + 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()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(None)) + .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![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[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": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + 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()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(None)) + .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![release()] + ); + } + + #[tokio::test] + async fn test_handle_get_season_releases_event_uses_provided_series_id_and_season_number() { + 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": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=2&seasonNumber=2"), + ) + .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()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(Some((2, 2)))) + .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![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + async fn test_handle_get_season_releases_event_filtered_series_and_filtered_seasons() { + 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": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + let mut filtered_series = StatefulTable::default(); + filtered_series.set_filtered_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.series = filtered_series; + let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_filtered_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + app_arc.lock().await.data.sonarr_data.seasons = filtered_seasons; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(None)) + .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![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + #[rstest] #[tokio::test] async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) { @@ -1459,22 +1787,76 @@ mod test { } #[tokio::test] - async fn test_extract_episode_id() { + async fn test_extract_season_number() { let app_arc = Arc::new(Mutex::new(App::default())); app_arc .lock() .await .data .sonarr_data - .episodes_table - .set_items(vec![Episode { - id: 1, - ..Episode::default() + .seasons + .set_items(vec![Season { + season_number: 1, + ..Season::default() }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, season_number_param) = network.extract_season_number(None).await; + + assert_eq!(id, 1); + assert_str_eq!(season_number_param, "seasonNumber=1"); + } + + #[tokio::test] + async fn test_extract_season_number_uses_provided_season_number() { + let app_arc = Arc::new(Mutex::new(App::default())); app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + .data + .sonarr_data + .seasons + .set_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let (id, season_number_param) = network.extract_season_number(Some(2)).await; + + assert_eq!(id, 2); + assert_str_eq!(season_number_param, "seasonNumber=2"); + } + + #[tokio::test] + async fn test_extract_season_number_filtered_seasons() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_filtered_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + app_arc.lock().await.data.sonarr_data.seasons = filtered_seasons; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, season_number_param) = network.extract_season_number(None).await; + + assert_eq!(id, 1); + assert_str_eq!(season_number_param, "seasonNumber=1"); + } + + #[tokio::test] + async fn test_extract_episode_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let id = network.extract_episode_id(None).await; @@ -1485,20 +1867,16 @@ mod test { #[tokio::test] async fn test_extract_episode_id_uses_provided_id() { let app_arc = Arc::new(Mutex::new(App::default())); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); app_arc .lock() .await - .data - .sonarr_data - .episodes_table - .set_items(vec![Episode { - id: 1, - ..Episode::default() - }]); - app_arc - .lock() - .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let id = network.extract_episode_id(Some(2)).await; @@ -1514,11 +1892,15 @@ mod test { id: 1, ..Episode::default() }]); - app_arc.lock().await.data.sonarr_data.episodes_table = filtered_episodes; + let season_details_modal = SeasonDetailsModal { + episodes: filtered_episodes, + ..SeasonDetailsModal::default() + }; + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let id = network.extract_episode_id(None).await; @@ -1526,60 +1908,6 @@ mod test { assert_eq!(id, 1); } - #[tokio::test] - async fn test_extract_episode_id_from_tree() { - let app_arc = Arc::new(Mutex::new(App::default())); - { - let mut app = app_arc.lock().await; - let items = vec![TreeItem::new_leaf(Episode { - id: 1, - ..Episode::default() - })]; - app.data.sonarr_data.episodes_tree.set_items(items.clone()); - render( - &mut app.data.sonarr_data.episodes_tree.state, - &items.clone(), - ); - app.data.sonarr_data.episodes_tree.state.key_down(); - render( - &mut app.data.sonarr_data.episodes_tree.state, - &items.clone(), - ); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let id = network.extract_episode_id(None).await; - - assert_eq!(id, 1); - } - - #[tokio::test] - async fn test_extract_episode_id_uses_provided_id_over_tree() { - let app_arc = Arc::new(Mutex::new(App::default())); - { - let mut app = app_arc.lock().await; - let items = vec![TreeItem::new_leaf(Episode { - id: 1, - ..Episode::default() - })]; - app.data.sonarr_data.episodes_tree.set_items(items.clone()); - render( - &mut app.data.sonarr_data.episodes_tree.state, - &items.clone(), - ); - app.data.sonarr_data.episodes_tree.state.key_down(); - render( - &mut app.data.sonarr_data.episodes_tree.state, - &items.clone(), - ); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let id = network.extract_episode_id(Some(2)).await; - - assert_eq!(id, 2); - } - #[test] fn test_get_episode_status_downloaded() { assert_str_eq!(get_episode_status(true, &[], 0), "Downloaded"); @@ -1831,6 +2159,31 @@ mod test { } } + fn rejections() -> Vec { + vec![ + "Unknown quality profile".to_owned(), + "Release is already mapped".to_owned(), + ] + } + + fn release() -> Release { + Release { + 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 render(state: &mut TreeState, items: &[TreeItem]) where T: ToText + Clone + Default + Display + Hash + PartialEq + Eq, diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index b8218bf..36699a6 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -7,9 +7,10 @@ use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; use crate::app::App; -use crate::models::radarr_models::{Credit, MovieHistoryItem, Release}; +use crate::models::radarr_models::{Credit, MovieHistoryItem}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; +use crate::models::servarr_models::Release; use crate::models::Route; use crate::ui::radarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle;