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
+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);
}
}