feat(network): Added get quality profiles and get episode details events for Sonarr

This commit is contained in:
2024-11-15 18:19:03 -07:00
parent 1fe95d057b
commit e14b7072c6
11 changed files with 941 additions and 27 deletions
-1
View File
@@ -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;
+1
View File
@@ -1 +1,2 @@
pub mod modals;
pub mod sonarr_data;
+13
View File
@@ -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<MovieHistoryItem>,
// pub episode_cast: StatefulTable<Credit>,
// pub episode_crew: StatefulTable<Credit>,
// pub episode_releases: StatefulTable<Release>,
}
+17 -3
View File
@@ -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<Series>,
pub blocklist: StatefulTable<BlocklistItem>,
pub logs: StatefulList<HorizontallyScrollableText>,
pub episodes: StatefulTree<Episode>,
pub episodes_tree: StatefulTree<Episode>,
pub episodes_table: StatefulTable<Episode>,
pub downloads: StatefulTable<DownloadRecord>,
pub episode_details_modal: Option<EpisodeDetailsModal>,
pub quality_profile_map: BiMap<i64, String>,
}
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,
@@ -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());
}
}
}
+71 -3
View File
@@ -37,6 +37,25 @@ pub struct BlocklistResponse {
pub records: Vec<BlocklistItem>,
}
#[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<HorizontallyScrollableText>,
#[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<String>,
pub has_file: bool,
pub monitored: bool,
pub episode_file: Option<EpisodeFile>,
}
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<Utc>,
pub media_info: Option<MediaInfo>,
}
#[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<Log>,
}
#[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<String>,
pub audio_languages: Option<String>,
#[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<String>,
}
#[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<Episode>),
QualityProfiles(Vec<QualityProfile>),
SeriesVec(Vec<Series>),
SystemStatus(SystemStatus),
BlocklistResponse(BlocklistResponse),
@@ -279,7 +345,9 @@ impl From<()> for SonarrSerdeable {
serde_enum_from!(
SonarrSerdeable {
Value(Value),
Episode(Episode),
Episodes(Vec<Episode>),
QualityProfiles(Vec<QualityProfile>),
SeriesVec(Vec<Series>),
SystemStatus(SystemStatus),
BlocklistResponse(BlocklistResponse),
+29 -2
View File
@@ -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)
);
}
}
+5 -4
View File
@@ -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<Vec<QualityProfile>> {
async fn get_radarr_quality_profiles(&mut self) -> Result<Vec<QualityProfile>> {
info!("Fetching Radarr quality profiles");
let event = RadarrEvent::GetQualityProfiles;
+1 -1
View File
@@ -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"
+205 -5
View File
@@ -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<i64>),
GetBlocklist,
GetEpisodeDetails(Option<i64>),
GetEpisodes(Option<i64>),
GetLogs(Option<u64>),
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<Episode>>(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<i64>) -> Result<Episode> {
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<Vec<QualityProfile>> {
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<QualityProfile>>(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<Vec<Series>> {
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>) -> 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()
}
+594 -8
View File
@@ -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<QualityProfile> =
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<T>(state: &mut TreeState, items: &[TreeItem<T>])
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);
}
}