diff --git a/src/main.rs b/src/main.rs index 5e201f3..72eeae8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,6 @@ use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; -use human_panic::metadata; use log::{error, warn}; use network::NetworkTrait; use ratatui::backend::CrosstermBackend; diff --git a/src/models/servarr_data/sonarr/mod.rs b/src/models/servarr_data/sonarr/mod.rs index 49bfe8e..8058f64 100644 --- a/src/models/servarr_data/sonarr/mod.rs +++ b/src/models/servarr_data/sonarr/mod.rs @@ -1 +1,2 @@ +pub mod modals; pub mod sonarr_data; diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs new file mode 100644 index 0000000..0f00318 --- /dev/null +++ b/src/models/servarr_data/sonarr/modals.rs @@ -0,0 +1,13 @@ +use crate::models::ScrollableText; + +#[derive(Default)] +pub struct EpisodeDetailsModal { + pub episode_details: ScrollableText, + pub file_details: String, + pub audio_details: String, + pub video_details: String, + // pub episode_history: StatefulTable, + // pub episode_cast: StatefulTable, + // pub episode_crew: StatefulTable, + // pub episode_releases: StatefulTable, +} diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index acd61a5..3cb44b6 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -1,14 +1,17 @@ +use bimap::BiMap; use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ - sonarr_models::{BlocklistItem, Episode, Series}, + sonarr_models::{BlocklistItem, DownloadRecord, Episode, Series}, stateful_list::StatefulList, stateful_table::StatefulTable, stateful_tree::StatefulTree, HorizontallyScrollableText, Route, }; +use super::modals::EpisodeDetailsModal; + #[cfg(test)] #[path = "sonarr_data_tests.rs"] mod sonarr_data_tests; @@ -19,7 +22,11 @@ pub struct SonarrData { pub series: StatefulTable, pub blocklist: StatefulTable, pub logs: StatefulList, - pub episodes: StatefulTree, + pub episodes_tree: StatefulTree, + pub episodes_table: StatefulTable, + pub downloads: StatefulTable, + pub episode_details_modal: Option, + pub quality_profile_map: BiMap, } impl Default for SonarrData { @@ -30,7 +37,11 @@ impl Default for SonarrData { series: StatefulTable::default(), blocklist: StatefulTable::default(), logs: StatefulList::default(), - episodes: StatefulTree::default(), + episodes_tree: StatefulTree::default(), + episodes_table: StatefulTable::default(), + downloads: StatefulTable::default(), + episode_details_modal: None, + quality_profile_map: BiMap::new(), } } } @@ -39,6 +50,9 @@ impl Default for SonarrData { pub enum ActiveSonarrBlock { Blocklist, BlocklistSortPrompt, + EpisodesExplorer, + EpisodesTable, + EpisodesTableSortPrompt, #[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 86a1937..24d40ea 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -39,6 +39,11 @@ mod tests { assert!(sonarr_data.series.is_empty()); assert!(sonarr_data.blocklist.is_empty()); assert!(sonarr_data.logs.is_empty()); + assert!(sonarr_data.episodes_tree.is_empty()); + assert!(sonarr_data.episodes_table.is_empty()); + assert!(sonarr_data.downloads.is_empty()); + assert!(sonarr_data.episode_details_modal.is_none()); + assert!(sonarr_data.quality_profile_map.is_empty()); } } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index afdebaa..fd65f1e 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -37,6 +37,25 @@ pub struct BlocklistResponse { pub records: Vec, } +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadRecord { + pub title: String, + pub status: String, + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + #[serde(deserialize_with = "super::from_i64")] + pub sizeleft: i64, + pub output_path: Option, + #[serde(default)] + pub indexer: String, + pub download_client: String, +} + #[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Episode { @@ -57,6 +76,7 @@ pub struct Episode { pub overview: Option, pub has_file: bool, pub monitored: bool, + pub episode_file: Option, } impl Display for Episode { @@ -65,7 +85,19 @@ impl Display for Episode { } } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EpisodeFile { + pub relative_path: String, + pub path: String, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub language: Language, + pub date_added: DateTime, + pub media_info: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Language { pub name: String, } @@ -87,16 +119,48 @@ pub struct LogResponse { pub records: Vec, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Serialize, Deserialize, Derivative, Hash, Debug, Clone, PartialEq, Eq)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +pub struct MediaInfo { + #[serde(deserialize_with = "super::from_i64")] + pub audio_bitrate: i64, + #[derivative(Default(value = "Number::from(0)"))] + pub audio_channels: Number, + pub audio_codec: Option, + pub audio_languages: Option, + #[serde(deserialize_with = "super::from_i64")] + pub audio_stream_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub video_bit_depth: i64, + #[serde(deserialize_with = "super::from_i64")] + pub video_bitrate: i64, + pub video_codec: String, + #[derivative(Default(value = "Number::from(0)"))] + pub video_fps: Number, + pub resolution: String, + pub run_time: String, + pub scan_type: String, + 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, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[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 { @@ -257,7 +321,9 @@ impl SeriesStatus { #[allow(clippy::large_enum_variant)] pub enum SonarrSerdeable { Value(Value), + Episode(Episode), Episodes(Vec), + QualityProfiles(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), @@ -279,7 +345,9 @@ impl From<()> for SonarrSerdeable { serde_enum_from!( SonarrSerdeable { Value(Value), + Episode(Episode), Episodes(Vec), + QualityProfiles(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index e4c659a..162ff7e 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -5,8 +5,8 @@ mod tests { use crate::models::{ sonarr_models::{ - BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, Series, SeriesStatus, - SeriesType, SonarrSerdeable, SystemStatus, + BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, QualityProfile, Series, + SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, }, Serdeable, }; @@ -77,6 +77,18 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(value)); } + #[test] + fn test_sonarr_serdeable_from_episode() { + let episode = Episode { + id: 1, + ..Episode::default() + }; + + let sonarr_serdeable: SonarrSerdeable = episode.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Episode(episode)); + } + #[test] fn test_sonarr_serdeable_from_episodes() { let episodes = vec![Episode { @@ -146,4 +158,19 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::LogResponse(log_response)); } + + #[test] + fn test_sonarr_serdeable_from_quality_profiles() { + let quality_profiles = vec![QualityProfile { + name: "Test Profile".to_owned(), + id: 1, + }]; + + let sonarr_serdeable: SonarrSerdeable = quality_profiles.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::QualityProfiles(quality_profiles) + ); + } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 936178b..cff5517 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -211,9 +211,10 @@ impl<'a, 'b> Network<'a, 'b> { .map(RadarrSerdeable::from), RadarrEvent::GetMovies => self.get_movies().await.map(RadarrSerdeable::from), RadarrEvent::GetOverview => self.get_diskspace().await.map(RadarrSerdeable::from), - RadarrEvent::GetQualityProfiles => { - self.get_quality_profiles().await.map(RadarrSerdeable::from) - } + RadarrEvent::GetQualityProfiles => self + .get_radarr_quality_profiles() + .await + .map(RadarrSerdeable::from), RadarrEvent::GetQueuedEvents => self.get_queued_events().await.map(RadarrSerdeable::from), RadarrEvent::GetReleases(movie_id) => { self.get_releases(movie_id).await.map(RadarrSerdeable::from) @@ -1702,7 +1703,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_quality_profiles(&mut self) -> Result> { + async fn get_radarr_quality_profiles(&mut self) -> Result> { info!("Fetching Radarr quality profiles"); let event = RadarrEvent::GetQualityProfiles; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index f386a11..efee36e 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2586,7 +2586,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_quality_profiles_event() { + async fn test_handle_get_radarr_quality_profiles_event() { let quality_profile_json = json!([{ "id": 2222, "name": "HD - 1080p" diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 252ab08..03bbf7c 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1,19 +1,22 @@ use std::collections::BTreeMap; use anyhow::Result; +use indoc::formatdoc; use log::info; use managarr_tree_widget::TreeItem; use serde_json::{json, Value}; use crate::{ models::{ - servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, + servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, sonarr_models::{ - BlocklistResponse, Episode, LogResponse, Series, SonarrSerdeable, SystemStatus, + BlocklistResponse, DownloadRecord, Episode, LogResponse, QualityProfile, Series, + SonarrSerdeable, SystemStatus, }, - HorizontallyScrollableText, Route, Scrollable, + HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, network::RequestMethod, + utils::convert_to_gb, }; use super::{Network, NetworkEvent, NetworkResource}; @@ -26,8 +29,10 @@ pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), GetBlocklist, + GetEpisodeDetails(Option), GetEpisodes(Option), GetLogs(Option), + GetQualityProfiles, GetStatus, HealthCheck, ListSeries, @@ -39,8 +44,9 @@ impl NetworkResource for SonarrEvent { SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", - SonarrEvent::GetEpisodes(_) => "/episode", + SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", SonarrEvent::GetLogs(_) => "/log", + SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries => "/series", @@ -73,6 +79,14 @@ impl<'a, 'b> Network<'a, 'b> { .get_episodes(series_id) .await .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeDetails(episode_id) => self + .get_episode_details(episode_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetQualityProfiles => self + .get_sonarr_quality_profiles() + .await + .map(SonarrSerdeable::from), SonarrEvent::GetLogs(events) => self .get_sonarr_logs(events) .await @@ -204,6 +218,22 @@ impl<'a, 'b> Network<'a, 'b> { 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::EpisodesTableSortPrompt, _) + ) { + app + .data + .sonarr_data + .episodes_table + .set_items(episode_vec.clone()); + app + .data + .sonarr_data + .episodes_table + .apply_sorting_toggle(false); + } + let mut seasons = BTreeMap::new(); for episode in episode_vec { @@ -226,7 +256,114 @@ impl<'a, 'b> Network<'a, 'b> { }) .collect(); - app.data.sonarr_data.episodes.set_items(tree); + app.data.sonarr_data.episodes_tree.set_items(tree); + }) + .await + } + + async fn get_episode_details(&mut self, episode_id: Option) -> Result { + info!("Fetching Sonarr episode details"); + let event = SonarrEvent::GetEpisodeDetails(None); + let id = self.extract_episode_id(episode_id).await; + + info!("Fetching episode details for episode with ID: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), Episode>(request_props, |episode_response, mut app| { + 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 mut episode_details_modal = EpisodeDetailsModal { + episode_details: ScrollableText::with_string(formatdoc!( + " + Title: {} + Season: {season_number} + Episode Number: {episode_number} + Air Date: {air_date} + Status: {status} + Description: {}", + title.unwrap_or_default(), + overview.unwrap_or_default(), + )), + ..EpisodeDetailsModal::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.language.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, + media_info.video_fps.as_f64().unwrap(), + media_info.resolution, + media_info.scan_type, + media_info.run_time, + media_info.subtitles.unwrap_or_default() + ); + } + }; + + app.data.sonarr_data.episode_details_modal = Some(episode_details_modal); }) .await } @@ -278,6 +415,24 @@ impl<'a, 'b> Network<'a, 'b> { .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 list_series(&mut self) -> Result> { info!("Fetching Sonarr library"); let event = SonarrEvent::ListSeries; @@ -332,4 +487,49 @@ 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 + } else { + app + .data + .sonarr_data + .episodes_tree + .current_selection() + .as_ref() + .unwrap() + .id + }; + + episode_id + } +} + +fn get_episode_status(has_file: bool, downloads_vec: &[DownloadRecord], episode_id: i64) -> String { + if !has_file { + if let Some(download) = downloads_vec + .iter() + .find(|&download| download.episode_id == episode_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/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 6b8c0d3..bb0b182 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -1,10 +1,18 @@ #[cfg(test)] mod test { + use std::fmt::Display; + use std::hash::Hash; use std::sync::Arc; + use bimap::BiMap; use chrono::{DateTime, Utc}; - use managarr_tree_widget::TreeItem; + use indoc::formatdoc; + use managarr_tree_widget::{Tree, TreeItem, TreeState}; use pretty_assertions::{assert_eq, assert_str_eq}; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::text::ToText; + use ratatui::widgets::StatefulWidget; use reqwest::Client; use rstest::rstest; use serde_json::json; @@ -14,13 +22,17 @@ mod test { use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; - use crate::models::sonarr_models::{BlocklistItem, Episode, Language, LogResponse}; + use crate::models::sonarr_models::{ + BlocklistItem, DownloadRecord, Episode, EpisodeFile, Language, LogResponse, MediaInfo, + QualityProfile, + }; 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}; + use crate::network::sonarr_network::get_episode_status; use crate::{ models::sonarr_models::{ Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType, @@ -75,6 +87,50 @@ mod test { "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": { + "relativePath": "/season 1/episode 1.mkv", + "path": "/nfs/tv/series/season 1/episode 1.mkv", + "size": 3543348019, + "dateAdded": "2024-02-10T07:28:45Z", + "language": { "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_episode( + #[values(SonarrEvent::GetEpisodes(None), SonarrEvent::GetEpisodeDetails(None))] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/episode"); + } #[rstest] fn test_resource_series(#[values(SonarrEvent::ListSeries)] event: SonarrEvent) { @@ -86,8 +142,8 @@ mod test { #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] - #[case(SonarrEvent::GetEpisodes(None), "/episode")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] + #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); @@ -285,8 +341,124 @@ mod test { async_server.assert_async().await; } + #[rstest] #[tokio::test] - async fn test_handle_get_episodes_event() { + 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, + ..episode() + }; + let episode_2 = Episode { + id: 2, + title: Some("A test".to_owned()), + episode_file_id: 2, + season_number: 2, + episode_number: 2, + episode_file: None, + ..episode() + }; + 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, + Some(json!([episode_1, episode_2])), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &Episode, b: &Episode| { + a.title + .as_ref() + .unwrap() + .to_lowercase() + .cmp(&b.title.as_ref().unwrap().to_lowercase()) + }; + expected_sorted_episodes.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sorting(vec![title_sort_option]); + } + 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.episodes_table.items, + expected_sorted_episodes + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .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_no_op_while_user_is_selecting_sort_options_on_table() { let episodes_json = json!([ { "id": 2, @@ -323,15 +495,19 @@ mod test { title: Some("Season 2".to_owned()), ..Episode::default() }; - let episode_1 = episode(); + 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 expected_episodes = vec![episode_2.clone(), episode_1.clone()]; + let mut expected_episodes = vec![episode_2.clone(), episode_1.clone()]; let expected_tree = vec![ TreeItem::new( marker_episode_1, @@ -354,6 +530,36 @@ mod test { Some("seriesId=1"), ) .await; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTableSortPrompt.into()); + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sort_asc = true; + let cmp_fn = |a: &Episode, b: &Episode| { + a.title + .as_ref() + .unwrap() + .to_lowercase() + .cmp(&b.title.as_ref().unwrap().to_lowercase()) + }; + expected_episodes.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sorting(vec![title_sort_option]); app_arc .lock() .await @@ -372,8 +578,24 @@ mod test { .unwrap() { async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .is_empty()); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sort_asc + ); assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes.items, + app_arc.lock().await.data.sonarr_data.episodes_tree.items, expected_tree ); assert_eq!(episodes, expected_episodes); @@ -420,6 +642,7 @@ mod test { }; let episode_1 = Episode { series_id: 2, + episode_file: None, ..episode() }; let episode_2 = Episode { @@ -428,6 +651,7 @@ mod test { season_number: 2, episode_number: 2, series_id: 2, + episode_file: None, ..episode() }; let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; @@ -462,13 +686,128 @@ mod test { { async_server.assert_async().await; assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes.items, + app_arc.lock().await.data.sonarr_data.episodes_tree.items, expected_tree ); assert_eq!(episodes, expected_episodes); } } + #[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(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .set_items(vec![episode()]); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .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(); + 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_uses_provided_id() { + 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; + 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] async fn test_handle_get_sonarr_logs_event() { let expected_logs = vec![ @@ -605,6 +944,40 @@ mod test { } } + #[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; + 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); + } + } + #[rstest] #[tokio::test] async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) { @@ -841,6 +1214,180 @@ mod test { assert_str_eq!(series_id_param, "seriesId=1"); } + #[tokio::test] + async fn test_extract_episode_id() { + 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() + }]); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + 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() { + 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() + }]); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let id = network.extract_episode_id(Some(2)).await; + + assert_eq!(id, 2); + } + + #[tokio::test] + async fn test_extract_episode_id_filtered_series() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut filtered_episodes = StatefulTable::default(); + filtered_episodes.set_filtered_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc.lock().await.data.sonarr_data.episodes_table = filtered_episodes; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + 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_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"); + } + + #[test] + fn test_get_episode_status_missing() { + let download_record = DownloadRecord { + episode_id: 1, + ..DownloadRecord::default() + }; + + assert_str_eq!( + get_episode_status(false, &[download_record.clone()], 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: 1, + status: "downloading".to_owned(), + ..DownloadRecord::default() + }], + 1 + ), + "Downloading" + ); + } + + #[test] + fn test_get_episode_status_awaiting_import() { + assert_str_eq!( + get_episode_status( + false, + &[DownloadRecord { + episode_id: 1, + status: "completed".to_owned(), + ..DownloadRecord::default() + }], + 1 + ), + "Awaiting Import" + ); + } + fn blocklist_item() -> BlocklistItem { BlocklistItem { id: 1, @@ -871,6 +1418,18 @@ mod test { 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 { + relative_path: "/season 1/episode 1.mkv".to_owned(), + path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), + size: 3543348019, + language: language(), + date_added: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + media_info: Some(media_info()), } } @@ -880,6 +1439,23 @@ mod test { } } + 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: "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(), @@ -955,4 +1531,14 @@ mod test { percent_of_episodes: 100.0, } } + + fn render(state: &mut TreeState, items: &[TreeItem]) + where + T: ToText + Clone + Default + Display + Hash + PartialEq + Eq, + { + let tree = Tree::new(items).unwrap(); + let area = Rect::new(0, 0, 10, 4); + let mut buffer = Buffer::empty(area); + StatefulWidget::render(tree, area, &mut buffer, state); + } }