feat: Added initial Sonarr CLI support and the initial network handler setup for the TUI

This commit is contained in:
2024-11-10 21:23:55 -07:00
parent b6f5b9d08c
commit 60d61b9e31
28 changed files with 2419 additions and 761 deletions
+12 -6
View File
@@ -5,9 +5,10 @@ mod tests {
use tokio::sync::mpsc;
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
use crate::app::{App, Data, RadarrConfig, DEFAULT_ROUTE};
use crate::app::{App, Data, ServarrConfig, DEFAULT_ROUTE};
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::{HorizontallyScrollableText, Route, TabRoute};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::{HorizontallyScrollableText, TabRoute};
use crate::network::radarr_network::RadarrEvent;
use crate::network::NetworkEvent;
@@ -34,7 +35,7 @@ mod tests {
},
TabRoute {
title: "Sonarr",
route: Route::Sonarr,
route: ActiveSonarrBlock::Series.into(),
help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)),
contextual_help: None,
},
@@ -126,6 +127,10 @@ mod tests {
version: "test".to_owned(),
..RadarrData::default()
},
sonarr_data: SonarrData {
version: "test".to_owned(),
..SonarrData::default()
},
},
..App::default()
};
@@ -135,6 +140,7 @@ mod tests {
assert_eq!(app.tick_count, 0);
assert_eq!(app.error, HorizontallyScrollableText::default());
assert!(app.data.radarr_data.version.is_empty());
assert!(app.data.sonarr_data.version.is_empty());
}
#[test]
@@ -248,11 +254,11 @@ mod tests {
}
#[test]
fn test_radarr_config_default() {
let radarr_config = RadarrConfig::default();
fn test_servarr_config_default() {
let radarr_config = ServarrConfig::default();
assert_eq!(radarr_config.host, Some("localhost".to_string()));
assert_eq!(radarr_config.port, Some(7878));
assert_eq!(radarr_config.port, None);
assert_eq!(radarr_config.uri, None);
assert!(radarr_config.api_token.is_empty());
assert_eq!(radarr_config.ssl_cert_path, None);
+14 -14
View File
@@ -9,6 +9,7 @@ use tokio_util::sync::CancellationToken;
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::{HorizontallyScrollableText, Route, TabRoute, TabState};
use crate::network::NetworkEvent;
@@ -151,7 +152,7 @@ impl<'a> Default for App<'a> {
},
TabRoute {
title: "Sonarr",
route: Route::Sonarr,
route: ActiveSonarrBlock::Series.into(),
help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)),
contextual_help: None,
},
@@ -172,25 +173,24 @@ impl<'a> Default for App<'a> {
#[derive(Default)]
pub struct Data<'a> {
pub radarr_data: RadarrData<'a>,
}
pub trait ServarrConfig {
fn validate(&self);
pub sonarr_data: SonarrData,
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct AppConfig {
pub radarr: RadarrConfig,
pub radarr: ServarrConfig,
pub sonarr: ServarrConfig,
}
impl ServarrConfig for AppConfig {
fn validate(&self) {
impl AppConfig {
pub fn validate(&self) {
self.radarr.validate();
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RadarrConfig {
#[cfg_attr(test, derive(Clone))]
pub struct ServarrConfig {
pub host: Option<String>,
pub port: Option<u16>,
pub uri: Option<String>,
@@ -198,20 +198,20 @@ pub struct RadarrConfig {
pub ssl_cert_path: Option<String>,
}
impl ServarrConfig for RadarrConfig {
impl ServarrConfig {
fn validate(&self) {
if self.host.is_none() && self.uri.is_none() {
log_and_print_error("'host' or 'uri' is required for Radarr configuration".to_owned());
log_and_print_error("'host' or 'uri' is required for configuration".to_owned());
process::exit(1);
}
}
}
impl Default for RadarrConfig {
impl Default for ServarrConfig {
fn default() -> Self {
RadarrConfig {
ServarrConfig {
host: Some("localhost".to_string()),
port: Some(7878),
port: None,
uri: None,
api_token: "".to_string(),
ssl_cert_path: None,
+43 -5
View File
@@ -20,9 +20,9 @@ mod tests {
};
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr"]);
#[rstest]
fn test_servarr_subcommand_requires_subcommand(#[values("radarr", "sonarr")] subcommand: &str) {
let result = Cli::command().try_get_matches_from(["managarr", subcommand]);
assert!(result.is_err());
assert_eq!(
@@ -39,6 +39,13 @@ mod tests {
assert!(result.is_ok());
}
#[test]
fn test_sonarr_subcommand_delegates_to_sonarr() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series"]);
assert!(result.is_ok());
}
#[test]
fn test_completions_requires_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions"]);
@@ -121,10 +128,41 @@ mod tests {
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let claer_blocklist_command = RadarrCommand::ClearBlocklist.into();
let clear_blocklist_command = RadarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, claer_blocklist_command, &mut mock_network).await;
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert!(result.is_ok());
}
// TODO: Uncomment to properly test delegation once the ClearBlocklist command is added to Sonarr
// #[tokio::test]
// async fn test_cli_handler_delegates_sonarr_commands_to_the_sonarr_cli_handler() {
// let mut mock_network = MockNetworkTrait::new();
// mock_network
// .expect_handle_network_event()
// .with(eq::<NetworkEvent>(SonarrEvent::GetBlocklist.into()))
// .times(1)
// .returning(|_| {
// Ok(Serdeable::Sonarr(SonarrSerdeable::BlocklistResponse(
// BlocklistResponse {
// records: vec![BlocklistItem::default()],
// },
// )))
// });
// mock_network
// .expect_handle_network_event()
// .with(eq::<NetworkEvent>(SonarrEvent::ClearBlocklist.into()))
// .times(1)
// .returning(|_| {
// Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
// json!({"testResponse": "response"}),
// )))
// });
// let clear_blocklist_command = SonarrCommand::ClearBlocklist.into();
// let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
// assert!(result.is_ok());
// }
}
+15 -1
View File
@@ -4,11 +4,13 @@ use anyhow::Result;
use clap::{command, Subcommand};
use clap_complete::Shell;
use radarr::{RadarrCliHandler, RadarrCommand};
use sonarr::{SonarrCliHandler, SonarrCommand};
use tokio::sync::Mutex;
use crate::{app::App, network::NetworkTrait};
pub mod radarr;
pub mod sonarr;
#[cfg(test)]
#[path = "cli_tests.rs"]
@@ -19,6 +21,9 @@ pub enum Command {
#[command(subcommand, about = "Commands for manging your Radarr instance")]
Radarr(RadarrCommand),
#[command(subcommand, about = "Commands for manging your Sonarr instance")]
Sonarr(SonarrCommand),
#[command(
arg_required_else_help = true,
about = "Generate shell completions for the Managarr CLI"
@@ -45,11 +50,20 @@ pub(crate) async fn handle_command(
command: Command,
network: &mut dyn NetworkTrait,
) -> Result<()> {
if let Command::Radarr(radarr_command) = command {
match command {
Command::Radarr(radarr_command) => {
RadarrCliHandler::with(app, radarr_command, network)
.handle()
.await?
}
Command::Sonarr(sonarr_command) => {
SonarrCliHandler::with(app, sonarr_command, network)
.handle()
.await?
}
_ => (),
}
Ok(())
}
+1 -1
View File
@@ -1,5 +1,5 @@
#[cfg(test)]
mod test {
mod tests {
use clap::error::ErrorKind;
use clap::CommandFactory;
+1 -1
View File
@@ -130,7 +130,7 @@ mod tests {
#[case(RadarrListCommand::Tasks, RadarrEvent::GetTasks)]
#[case(RadarrListCommand::Updates, RadarrEvent::GetUpdates)]
#[tokio::test]
async fn test_handle_list_blocklist_command(
async fn test_handle_list_command(
#[case] list_command: RadarrListCommand,
#[case] expected_radarr_event: RadarrEvent,
) {
+60
View File
@@ -0,0 +1,60 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "get_command_handler_tests.rs"]
mod get_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrGetCommand {
#[command(about = "Get the system status")]
SystemStatus,
}
impl From<SonarrGetCommand> for Command {
fn from(value: SonarrGetCommand) -> Self {
Command::Sonarr(SonarrCommand::Get(value))
}
}
pub(super) struct SonarrGetCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrGetCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrGetCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrGetCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<()> {
match self.command {
SonarrGetCommand::SystemStatus => {
execute_network_event!(self, SonarrEvent::GetStatus);
}
}
Ok(())
}
}
@@ -0,0 +1,71 @@
#[cfg(test)]
mod tests {
use crate::cli::{
sonarr::{get_command_handler::SonarrGetCommand, SonarrCommand},
Command,
};
use crate::Cli;
use clap::CommandFactory;
#[test]
fn test_sonarr_get_command_from() {
let command = SonarrGetCommand::SystemStatus;
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(SonarrCommand::Get(command)));
}
mod cli {
use super::*;
#[test]
fn test_system_status_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "system-status"]);
assert!(result.is_ok());
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
sonarr::get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler},
CliCommandHandler,
},
models::{sonarr_models::SonarrSerdeable, Serdeable},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_handle_get_system_status_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(SonarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_system_status_command = SonarrGetCommand::SystemStatus;
let result =
SonarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+60
View File
@@ -0,0 +1,60 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "list_command_handler_tests.rs"]
mod list_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrListCommand {
#[command(about = "List all series in your Sonarr library")]
Series,
}
impl From<SonarrListCommand> for Command {
fn from(value: SonarrListCommand) -> Self {
Command::Sonarr(SonarrCommand::List(value))
}
}
pub(super) struct SonarrListCommandHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: SonarrListCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: SonarrListCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrListCommandHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<()> {
match self.command {
SonarrListCommand::Series => {
execute_network_event!(self, SonarrEvent::ListSeries);
}
}
Ok(())
}
}
@@ -0,0 +1,77 @@
#[cfg(test)]
mod tests {
use crate::cli::{
sonarr::{list_command_handler::SonarrListCommand, SonarrCommand},
Command,
};
use crate::Cli;
use clap::CommandFactory;
#[test]
fn test_sonarr_list_command_from() {
let command = SonarrListCommand::Series;
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(SonarrCommand::List(command)));
}
mod cli {
use super::*;
use rstest::rstest;
#[rstest]
fn test_list_commands_have_no_arg_requirements(#[values("series")] subcommand: &str) {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]);
assert!(result.is_ok());
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use rstest::rstest;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::sonarr::list_command_handler::SonarrListCommand;
use crate::cli::CliCommandHandler;
use crate::network::sonarr_network::SonarrEvent;
use crate::{
app::App,
models::{radarr_models::RadarrSerdeable, Serdeable},
network::{MockNetworkTrait, NetworkEvent},
};
#[rstest]
#[case(SonarrListCommand::Series, SonarrEvent::ListSeries)]
#[tokio::test]
async fn test_handle_list_command(
#[case] list_command: SonarrListCommand,
#[case] expected_sonarr_event: SonarrEvent,
) {
use crate::cli::sonarr::list_command_handler::SonarrListCommandHandler;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(expected_sonarr_event.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Radarr(RadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let result = SonarrListCommandHandler::with(&app_arc, list_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+75
View File
@@ -0,0 +1,75 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler};
use list_command_handler::{SonarrListCommand, SonarrListCommandHandler};
use tokio::sync::Mutex;
use crate::{app::App, network::NetworkTrait};
use super::{CliCommandHandler, Command};
mod get_command_handler;
mod list_command_handler;
#[cfg(test)]
#[path = "sonarr_command_tests.rs"]
mod sonarr_command_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrCommand {
#[command(
subcommand,
about = "Commands to fetch details of the resources in your Sonarr instance"
)]
Get(SonarrGetCommand),
#[command(
subcommand,
about = "Commands to list attributes from your Sonarr instance"
)]
List(SonarrListCommand),
}
impl From<SonarrCommand> for Command {
fn from(sonarr_command: SonarrCommand) -> Command {
Command::Sonarr(sonarr_command)
}
}
pub(super) struct SonarrCliHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: SonarrCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: SonarrCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrCliHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<()> {
match self.command {
SonarrCommand::Get(get_command) => {
SonarrGetCommandHandler::with(self.app, get_command, self.network)
.handle()
.await?
}
SonarrCommand::List(list_command) => {
SonarrListCommandHandler::with(self.app, list_command, self.network)
.handle()
.await?
}
}
Ok(())
}
}
+86
View File
@@ -0,0 +1,86 @@
#[cfg(test)]
mod tests {
use crate::cli::{
sonarr::{list_command_handler::SonarrListCommand, SonarrCommand},
Command,
};
#[test]
fn test_sonarr_command_from() {
let command = SonarrCommand::List(SonarrListCommand::Series);
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(command));
}
mod cli {}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
sonarr::{
get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand,
SonarrCliHandler, SonarrCommand,
},
CliCommandHandler,
},
models::{
sonarr_models::{Series, SonarrSerdeable},
Serdeable,
},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_sonarr_cli_handler_delegates_get_commands_to_the_get_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(SonarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_system_status_command = SonarrCommand::Get(SonarrGetCommand::SystemStatus);
let result = SonarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_cli_handler_delegates_list_commands_to_the_list_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(SonarrEvent::ListSeries.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::SeriesVec(vec![
Series::default(),
])))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let list_series_command = SonarrCommand::List(SonarrListCommand::Series);
let result = SonarrCliHandler::with(&app_arc, list_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ use std::{io, panic, process};
use anyhow::anyhow;
use anyhow::Result;
use app::{log_and_print_error, AppConfig, ServarrConfig};
use app::{log_and_print_error, AppConfig};
use clap::{
command, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser,
};
@@ -112,7 +112,7 @@ async fn main() -> Result<()> {
match args.command {
Some(command) => match command {
Command::Radarr(_) => {
Command::Radarr(_) | Command::Sonarr(_) => {
let app_nw = Arc::clone(&app);
let mut network = Network::new(&app_nw, cancellation_token, reqwest_client);
+15 -1
View File
@@ -6,8 +6,11 @@ use radarr_models::RadarrSerdeable;
use regex::Regex;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Number;
use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use sonarr_models::SonarrSerdeable;
pub mod radarr_models;
pub mod servarr_data;
pub mod sonarr_models;
pub mod stateful_list;
pub mod stateful_table;
@@ -20,7 +23,7 @@ mod model_tests;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Route {
Radarr(ActiveRadarrBlock, Option<ActiveRadarrBlock>),
Sonarr,
Sonarr(ActiveSonarrBlock, Option<ActiveSonarrBlock>),
Readarr,
Lidarr,
Whisparr,
@@ -33,6 +36,7 @@ pub enum Route {
#[serde(untagged)]
pub enum Serdeable {
Radarr(RadarrSerdeable),
Sonarr(SonarrSerdeable),
}
pub trait Scrollable {
@@ -359,6 +363,16 @@ where
)))
}
pub fn from_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
let num: Number = Deserialize::deserialize(deserializer)?;
num.as_f64().ok_or(de::Error::custom(format!(
"Unable to convert Number to f64: {num:?}"
)))
}
pub fn strip_non_search_characters(input: &str) -> String {
Regex::new(r"[^a-zA-Z0-9.,/'\-:\s]")
.unwrap()
+8
View File
@@ -10,6 +10,7 @@ mod tests {
use serde::de::IntoDeserializer;
use serde_json::to_string;
use crate::models::from_f64;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::{from_i64, strip_non_search_characters};
use crate::models::{
@@ -649,6 +650,13 @@ mod tests {
);
}
#[test]
fn test_from_f64() {
let deserializer: F64Deserializer<ValueError> = 1f64.into_deserializer();
assert_eq!(from_f64(deserializer), Ok(1.0));
}
#[test]
fn test_horizontally_scrollable_serialize() {
let text = HorizontallyScrollableText::from("Test");
+1
View File
@@ -1 +1,2 @@
pub mod radarr;
pub mod sonarr;
@@ -19,6 +19,14 @@ mod tests {
use crate::assert_movie_info_tabs_reset;
use crate::models::BlockSelectionState;
#[test]
fn test_from_active_radarr_block_to_route() {
assert_eq!(
Route::from(ActiveRadarrBlock::AddMoviePrompt),
Route::Radarr(ActiveRadarrBlock::AddMoviePrompt, None)
);
}
#[test]
fn test_from_tuple_to_route_with_context() {
assert_eq!(
@@ -60,7 +68,7 @@ mod tests {
assert_eq!(radarr_data.disk_space_vec, Vec::new());
assert!(radarr_data.version.is_empty());
assert_eq!(radarr_data.start_time, <DateTime<Utc>>::default());
assert!(radarr_data.movies.items.is_empty());
assert!(radarr_data.movies.is_empty());
assert_eq!(radarr_data.selected_block, BlockSelectionState::default());
assert!(radarr_data.downloads.items.is_empty());
assert!(radarr_data.indexers.items.is_empty());
+1
View File
@@ -0,0 +1 @@
pub mod sonarr_data;
@@ -0,0 +1,43 @@
use chrono::{DateTime, Utc};
use strum::EnumIter;
use crate::models::{sonarr_models::Series, stateful_table::StatefulTable, Route};
#[cfg(test)]
#[path = "sonarr_data_tests.rs"]
mod sonarr_data_tests;
pub struct SonarrData {
pub version: String,
pub start_time: DateTime<Utc>,
pub series: StatefulTable<Series>,
}
impl Default for SonarrData {
fn default() -> SonarrData {
SonarrData {
version: String::new(),
start_time: DateTime::default(),
series: StatefulTable::default(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)]
pub enum ActiveSonarrBlock {
#[default]
Series,
SeriesSortPrompt,
}
impl From<ActiveSonarrBlock> for Route {
fn from(active_sonarr_block: ActiveSonarrBlock) -> Route {
Route::Sonarr(active_sonarr_block, None)
}
}
impl From<(ActiveSonarrBlock, Option<ActiveSonarrBlock>)> for Route {
fn from(value: (ActiveSonarrBlock, Option<ActiveSonarrBlock>)) -> Route {
Route::Sonarr(value.0, value.1)
}
}
@@ -0,0 +1,42 @@
#[cfg(test)]
mod tests {
mod sonarr_data_tests {
use chrono::{DateTime, Utc};
use crate::models::{
servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData},
Route,
};
#[test]
fn test_from_active_sonarr_block_to_route() {
assert_eq!(
Route::from(ActiveSonarrBlock::SeriesSortPrompt),
Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, None)
);
}
#[test]
fn test_from_tuple_to_route_with_context() {
assert_eq!(
Route::from((
ActiveSonarrBlock::SeriesSortPrompt,
Some(ActiveSonarrBlock::Series)
)),
Route::Sonarr(
ActiveSonarrBlock::SeriesSortPrompt,
Some(ActiveSonarrBlock::Series),
)
);
}
#[test]
fn test_sonarr_data_defaults() {
let sonarr_data = SonarrData::default();
assert!(sonarr_data.version.is_empty());
assert_eq!(sonarr_data.start_time, <DateTime<Utc>>::default());
assert!(sonarr_data.series.is_empty());
}
}
}
+207
View File
@@ -0,0 +1,207 @@
use std::fmt::{Display, Formatter};
use chrono::{DateTime, Utc};
use clap::ValueEnum;
use derivative::Derivative;
use serde::{Deserialize, Serialize};
use serde_json::{json, Number, Value};
use strum::EnumIter;
use crate::serde_enum_from;
use super::{HorizontallyScrollableText, Serdeable};
#[cfg(test)]
#[path = "sonarr_models_tests.rs"]
mod sonarr_models_tests;
#[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)]
#[derivative(Default)]
pub struct Rating {
#[serde(deserialize_with = "super::from_i64")]
pub votes: i64,
#[serde(deserialize_with = "super::from_f64")]
pub value: f64,
}
impl Eq for Rating {}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Season {
#[serde(deserialize_with = "super::from_i64")]
pub season_number: i64,
pub monitored: bool,
pub statistics: SeasonStatistics,
}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SeasonStatistics {
pub next_airing: Option<DateTime<Utc>>,
pub previous_airing: Option<DateTime<Utc>>,
#[serde(deserialize_with = "super::from_i64")]
pub episode_file_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub episode_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub total_episode_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub size_on_disk: i64,
#[serde(deserialize_with = "super::from_f64")]
pub percent_of_episodes: f64,
}
impl Eq for SeasonStatistics {}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Series {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
#[serde(deserialize_with = "super::from_i64")]
pub tvdb_id: i64,
pub title: HorizontallyScrollableText,
#[serde(deserialize_with = "super::from_i64")]
pub quality_profile_id: i64,
#[serde(deserialize_with = "super::from_i64")]
pub language_profile_id: i64,
#[serde(deserialize_with = "super::from_i64")]
pub runtime: i64,
#[serde(deserialize_with = "super::from_i64")]
pub year: i64,
pub monitored: bool,
pub series_type: SeriesType,
pub path: String,
pub genres: Vec<String>,
pub tags: Vec<Number>,
pub ratings: Rating,
pub ended: bool,
pub status: SeriesStatus,
pub overview: String,
pub network: Option<String>,
pub season_folder: bool,
pub certification: Option<String>,
pub statistics: Option<SeriesStatistics>,
pub seasons: Option<Vec<Season>>,
}
#[derive(
Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, ValueEnum,
)]
#[serde(rename_all = "camelCase")]
pub enum SeriesType {
#[default]
Standard,
Daily,
Anime,
}
impl Display for SeriesType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let series_type = match self {
SeriesType::Standard => "standard",
SeriesType::Daily => "daily",
SeriesType::Anime => "anime",
};
write!(f, "{series_type}")
}
}
impl SeriesType {
pub fn to_display_str<'a>(self) -> &'a str {
match self {
SeriesType::Standard => "Standard",
SeriesType::Daily => "Daily",
SeriesType::Anime => "Anime",
}
}
}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SeriesStatistics {
#[serde(deserialize_with = "super::from_i64")]
pub season_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub episode_file_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub episode_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub total_episode_count: i64,
#[serde(deserialize_with = "super::from_i64")]
pub size_on_disk: i64,
#[serde(deserialize_with = "super::from_f64")]
pub percent_of_episodes: f64,
}
impl Eq for SeriesStatistics {}
#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter)]
#[serde(rename_all = "camelCase")]
pub enum SeriesStatus {
#[default]
Continuing,
Ended,
Upcoming,
Deleted,
}
impl Display for SeriesStatus {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let series_status = match self {
SeriesStatus::Continuing => "continuing",
SeriesStatus::Ended => "ended",
SeriesStatus::Upcoming => "upcoming",
SeriesStatus::Deleted => "deleted",
};
write!(f, "{series_status}")
}
}
impl SeriesStatus {
pub fn to_display_str<'a>(self) -> &'a str {
match self {
SeriesStatus::Continuing => "Continuing",
SeriesStatus::Ended => "Ended",
SeriesStatus::Upcoming => "Upcoming",
SeriesStatus::Deleted => "Deleted",
}
}
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum SonarrSerdeable {
Value(Value),
SeriesVec(Vec<Series>),
SystemStatus(SystemStatus),
}
impl From<SonarrSerdeable> for Serdeable {
fn from(value: SonarrSerdeable) -> Serdeable {
Serdeable::Sonarr(value)
}
}
impl From<()> for SonarrSerdeable {
fn from(_: ()) -> Self {
SonarrSerdeable::Value(json!({}))
}
}
serde_enum_from!(
SonarrSerdeable {
Value(Value),
SeriesVec(Vec<Series>),
SystemStatus(SystemStatus),
}
);
#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SystemStatus {
pub version: String,
pub start_time: DateTime<Utc>,
}
+92
View File
@@ -0,0 +1,92 @@
#[cfg(test)]
mod tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use serde_json::json;
use crate::models::{
sonarr_models::{Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus},
Serdeable,
};
#[test]
fn test_series_status_display() {
assert_str_eq!(SeriesStatus::Continuing.to_string(), "continuing");
assert_str_eq!(SeriesStatus::Ended.to_string(), "ended");
assert_str_eq!(SeriesStatus::Upcoming.to_string(), "upcoming");
assert_str_eq!(SeriesStatus::Deleted.to_string(), "deleted");
}
#[test]
fn test_series_status_to_display_str() {
assert_str_eq!(SeriesStatus::Continuing.to_display_str(), "Continuing");
assert_str_eq!(SeriesStatus::Ended.to_display_str(), "Ended");
assert_str_eq!(SeriesStatus::Upcoming.to_display_str(), "Upcoming");
assert_str_eq!(SeriesStatus::Deleted.to_display_str(), "Deleted");
}
#[test]
fn test_series_type_display() {
assert_str_eq!(SeriesType::Standard.to_string(), "standard");
assert_str_eq!(SeriesType::Daily.to_string(), "daily");
assert_str_eq!(SeriesType::Anime.to_string(), "anime");
}
#[test]
fn test_series_type_to_display_str() {
assert_str_eq!(SeriesType::Standard.to_display_str(), "Standard");
assert_str_eq!(SeriesType::Daily.to_display_str(), "Daily");
assert_str_eq!(SeriesType::Anime.to_display_str(), "Anime");
}
#[test]
fn test_sonarr_serdeable_from() {
let sonarr_serdeable = SonarrSerdeable::Value(json!({}));
let serdeable: Serdeable = Serdeable::from(sonarr_serdeable.clone());
assert_eq!(serdeable, Serdeable::Sonarr(sonarr_serdeable));
}
#[test]
fn test_sonarr_serdeable_from_unit() {
let sonarr_serdeable = SonarrSerdeable::from(());
assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(json!({})));
}
#[test]
fn test_sonarr_serdeable_from_value() {
let value = json!({"test": "test"});
let sonarr_serdeable: SonarrSerdeable = value.clone().into();
assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(value));
}
#[test]
fn test_sonarr_serdeable_from_series() {
let series = vec![Series {
id: 1,
..Series::default()
}];
let sonarr_serdeable: SonarrSerdeable = series.clone().into();
assert_eq!(sonarr_serdeable, SonarrSerdeable::SeriesVec(series));
}
#[test]
fn test_sonarr_serdeable_from_system_status() {
let system_status = SystemStatus {
version: "1".to_owned(),
..SystemStatus::default()
};
let sonarr_serdeable: SonarrSerdeable = system_status.clone().into();
assert_eq!(
sonarr_serdeable,
SonarrSerdeable::SystemStatus(system_status)
);
}
}
+76 -6
View File
@@ -8,35 +8,42 @@ use regex::Regex;
use reqwest::{Client, RequestBuilder};
use serde::de::DeserializeOwned;
use serde::Serialize;
use sonarr_network::SonarrEvent;
use strum_macros::Display;
use tokio::select;
use tokio::sync::{Mutex, MutexGuard};
use tokio_util::sync::CancellationToken;
use crate::app::App;
use crate::app::{App, ServarrConfig};
use crate::models::Serdeable;
use crate::network::radarr_network::RadarrEvent;
#[cfg(test)]
use mockall::automock;
pub mod radarr_network;
pub mod sonarr_network;
mod utils;
#[cfg(test)]
#[path = "network_tests.rs"]
mod network_tests;
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum NetworkEvent {
Radarr(RadarrEvent),
}
#[cfg_attr(test, automock)]
#[async_trait]
pub trait NetworkTrait {
async fn handle_network_event(&mut self, network_event: NetworkEvent) -> Result<Serdeable>;
}
pub trait NetworkResource {
fn resource(&self) -> &'static str;
}
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum NetworkEvent {
Radarr(RadarrEvent),
Sonarr(SonarrEvent),
}
#[derive(Clone)]
pub struct Network<'a, 'b> {
client: Client,
@@ -52,6 +59,10 @@ impl<'a, 'b> NetworkTrait for Network<'a, 'b> {
.handle_radarr_event(radarr_event)
.await
.map(Serdeable::from),
NetworkEvent::Sonarr(sonarr_event) => self
.handle_sonarr_event(sonarr_event)
.await
.map(Serdeable::from),
};
let mut app = self.app.lock().await;
@@ -180,6 +191,65 @@ impl<'a, 'b> Network<'a, 'b> {
.header("X-Api-Key", api_token),
}
}
async fn request_props_from<T, N>(
&self,
network_event: N,
method: RequestMethod,
body: Option<T>,
path: Option<String>,
query_params: Option<String>,
) -> RequestProps<T>
where
T: Serialize + Debug,
N: Into<NetworkEvent> + NetworkResource,
{
let app = self.app.lock().await;
let resource = network_event.resource();
let (
ServarrConfig {
host,
port,
uri,
api_token,
ssl_cert_path,
},
default_port,
) = match network_event.into() {
NetworkEvent::Radarr(_) => (&app.config.radarr, 7878),
NetworkEvent::Sonarr(_) => (&app.config.sonarr, 8989),
};
let mut uri = if let Some(servarr_uri) = uri {
format!("{servarr_uri}/api/v3{resource}")
} else {
let protocol = if ssl_cert_path.is_some() {
"https"
} else {
"http"
};
let host = host.as_ref().unwrap();
format!(
"{protocol}://{host}:{}/api/v3{resource}",
port.unwrap_or(default_port)
)
};
if let Some(path) = path {
uri = format!("{uri}{path}");
}
if let Some(params) = query_params {
uri = format!("{uri}?{params}");
}
RequestProps {
uri,
method,
body,
api_token: api_token.to_owned(),
ignore_status_code: false,
}
}
}
#[derive(Clone, Copy, Debug, Display, PartialEq, Eq)]
+288 -3
View File
@@ -12,9 +12,11 @@ mod tests {
use tokio::sync::{mpsc, Mutex};
use tokio_util::sync::CancellationToken;
use crate::app::{App, AppConfig, RadarrConfig};
use crate::app::{App, AppConfig, ServarrConfig};
use crate::models::HorizontallyScrollableText;
use crate::network::radarr_network::RadarrEvent;
use crate::network::sonarr_network::SonarrEvent;
use crate::network::NetworkResource;
use crate::network::{Network, NetworkEvent, NetworkTrait, RequestMethod, RequestProps};
#[tokio::test]
@@ -34,12 +36,12 @@ mod tests {
);
let mut app = App::default();
app.is_loading = true;
let radarr_config = RadarrConfig {
let radarr_config = ServarrConfig {
host,
api_token: String::new(),
port,
ssl_cert_path: None,
..RadarrConfig::default()
..ServarrConfig::default()
};
app.config.radarr = radarr_config;
let app_arc = Arc::new(Mutex::new(app));
@@ -395,6 +397,214 @@ mod tests {
async_server.assert_async().await;
}
#[rstest]
#[case(RadarrEvent::GetMovies, 7878)]
#[case(SonarrEvent::ListSeries, 8989)]
#[tokio::test]
async fn test_request_props_from_default_config(
#[case] network_event: impl Into<NetworkEvent> + NetworkResource,
#[case] default_port: u16,
) {
let app_arc = Arc::new(Mutex::new(App::default()));
let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let resource = network_event.resource();
let request_props = network
.request_props_from(network_event, RequestMethod::Get, None::<()>, None, None)
.await;
assert_str_eq!(
request_props.uri,
format!("http://localhost:{default_port}/api/v3{resource}")
);
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert!(request_props.api_token.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_request_props_from_custom_config(
#[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into<NetworkEvent>
+ NetworkResource,
) {
let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::default()));
let resource = network_event.resource();
let servarr_config = ServarrConfig {
host: Some("192.168.0.123".to_owned()),
port: Some(8080),
api_token: api_token.clone(),
ssl_cert_path: Some("/test/cert.crt".to_owned()),
..ServarrConfig::default()
};
{
let mut app = app_arc.lock().await;
app.config.radarr = servarr_config.clone();
app.config.sonarr = servarr_config;
}
let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let request_props = network
.request_props_from(network_event, RequestMethod::Get, None::<()>, None, None)
.await;
assert_str_eq!(
request_props.uri,
format!("https://192.168.0.123:8080/api/v3{resource}")
);
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert_str_eq!(request_props.api_token, api_token);
}
#[rstest]
#[tokio::test]
async fn test_request_props_from_custom_config_using_uri_instead_of_host_and_port(
#[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into<NetworkEvent>
+ NetworkResource,
) {
let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::default()));
let resource = network_event.resource();
let servarr_config = ServarrConfig {
uri: Some("https://192.168.0.123:8080".to_owned()),
api_token: api_token.clone(),
..ServarrConfig::default()
};
{
let mut app = app_arc.lock().await;
app.config.radarr = servarr_config.clone();
app.config.sonarr = servarr_config;
}
let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let request_props = network
.request_props_from(network_event, RequestMethod::Get, None::<()>, None, None)
.await;
assert_str_eq!(
request_props.uri,
format!("https://192.168.0.123:8080/api/v3{resource}")
);
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert_str_eq!(request_props.api_token, api_token);
}
#[rstest]
#[case(RadarrEvent::GetMovies, 7878)]
#[case(SonarrEvent::ListSeries, 8989)]
#[tokio::test]
async fn test_request_props_from_default_config_with_path_and_query_params(
#[case] network_event: impl Into<NetworkEvent> + NetworkResource,
#[case] default_port: u16,
) {
let app_arc = Arc::new(Mutex::new(App::default()));
let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let resource = network_event.resource();
let request_props = network
.request_props_from(
network_event,
RequestMethod::Get,
None::<()>,
Some("/test".to_owned()),
Some("id=1".to_owned()),
)
.await;
assert_str_eq!(
request_props.uri,
format!("http://localhost:{default_port}/api/v3{resource}/test?id=1")
);
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert!(request_props.api_token.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_request_props_from_custom_config_with_path_and_query_params(
#[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into<NetworkEvent>
+ NetworkResource,
) {
let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::default()));
let resource = network_event.resource();
let servarr_config = ServarrConfig {
host: Some("192.168.0.123".to_owned()),
port: Some(8080),
api_token: api_token.clone(),
ssl_cert_path: Some("/test/cert.crt".to_owned()),
..ServarrConfig::default()
};
{
let mut app = app_arc.lock().await;
app.config.radarr = servarr_config.clone();
app.config.sonarr = servarr_config;
}
let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let request_props = network
.request_props_from(
network_event,
RequestMethod::Get,
None::<()>,
Some("/test".to_owned()),
Some("id=1".to_owned()),
)
.await;
assert_str_eq!(
request_props.uri,
format!("https://192.168.0.123:8080/api/v3{resource}/test?id=1")
);
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert_str_eq!(request_props.api_token, api_token);
}
#[rstest]
#[tokio::test]
async fn test_request_props_from_custom_config_using_uri_instead_of_host_and_port_with_path_and_query_params(
#[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into<NetworkEvent>
+ NetworkResource,
) {
let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::default()));
let resource = network_event.resource();
let servarr_config = ServarrConfig {
uri: Some("https://192.168.0.123:8080".to_owned()),
api_token: api_token.clone(),
..ServarrConfig::default()
};
{
let mut app = app_arc.lock().await;
app.config.radarr = servarr_config.clone();
app.config.sonarr = servarr_config;
}
let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let request_props = network
.request_props_from(
network_event,
RequestMethod::Get,
None::<()>,
Some("/test".to_owned()),
Some("id=1".to_owned()),
)
.await;
assert_str_eq!(
request_props.uri,
format!("https://192.168.0.123:8080/api/v3{resource}/test?id=1")
);
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert_str_eq!(request_props.api_token, api_token);
}
#[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq, Eq)]
struct Test {
pub value: String,
@@ -425,3 +635,78 @@ mod tests {
(async_server, app_arc, server)
}
}
#[cfg(test)]
pub(in crate::network) mod test_utils {
use std::sync::Arc;
use mockito::{Matcher, Mock, Server, ServerGuard};
use serde_json::Value;
use tokio::sync::Mutex;
use crate::{
app::{App, ServarrConfig},
network::{NetworkEvent, NetworkResource, RequestMethod},
};
pub async fn mock_servarr_api<'a>(
method: RequestMethod,
request_body: Option<Value>,
response_body: Option<Value>,
response_status: Option<usize>,
network_event: impl Into<NetworkEvent> + NetworkResource,
path: Option<&str>,
query_params: Option<&str>,
) -> (Mock, Arc<Mutex<App<'a>>>, ServerGuard) {
let status = response_status.unwrap_or(200);
let resource = network_event.resource();
let mut server = Server::new_async().await;
let mut uri = format!("/api/v3{resource}");
if let Some(path) = path {
uri = format!("{uri}{path}");
}
if let Some(params) = query_params {
uri = format!("{uri}?{params}");
}
let mut async_server = server
.mock(&method.to_string().to_uppercase(), uri.as_str())
.match_header("X-Api-Key", "test1234")
.with_status(status);
if let Some(body) = request_body {
async_server = async_server.match_body(Matcher::Json(body));
}
if let Some(body) = response_body {
async_server = async_server.with_body(body.to_string());
}
async_server = async_server.create_async().await;
let host = Some(server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned());
let port = Some(
server.host_with_port().split(':').collect::<Vec<&str>>()[1]
.parse()
.unwrap(),
);
let mut app = App::default();
let servarr_config = ServarrConfig {
host,
port,
api_token: "test1234".to_owned(),
..ServarrConfig::default()
};
match network_event.into() {
NetworkEvent::Radarr(_) => app.config.radarr = servarr_config,
NetworkEvent::Sonarr(_) => app.config.sonarr = servarr_config,
}
let app_arc = Arc::new(Mutex::new(app));
(async_server, app_arc, server)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
use anyhow::Result;
use log::info;
use crate::{
models::{
servarr_data::sonarr::sonarr_data::ActiveSonarrBlock,
sonarr_models::{Series, SonarrSerdeable, SystemStatus},
Route,
},
network::RequestMethod,
};
use super::{Network, NetworkEvent, NetworkResource};
#[cfg(test)]
#[path = "sonarr_network_tests.rs"]
mod sonarr_network_tests;
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum SonarrEvent {
GetStatus,
HealthCheck,
ListSeries,
}
impl NetworkResource for SonarrEvent {
fn resource(&self) -> &'static str {
match &self {
SonarrEvent::GetStatus => "/system/status",
SonarrEvent::HealthCheck => "/health",
SonarrEvent::ListSeries => "/series",
}
}
}
impl From<SonarrEvent> for NetworkEvent {
fn from(sonarr_event: SonarrEvent) -> Self {
NetworkEvent::Sonarr(sonarr_event)
}
}
impl<'a, 'b> Network<'a, 'b> {
pub async fn handle_sonarr_event(
&mut self,
sonarr_event: SonarrEvent,
) -> Result<SonarrSerdeable> {
match sonarr_event {
SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from),
SonarrEvent::HealthCheck => self
.get_sonarr_healthcheck()
.await
.map(SonarrSerdeable::from),
SonarrEvent::ListSeries => self.list_series().await.map(SonarrSerdeable::from),
}
}
async fn get_sonarr_healthcheck(&mut self) -> Result<()> {
info!("Performing Sonarr health check");
let event = SonarrEvent::HealthCheck;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), ()>(request_props, |_, _| ())
.await
}
async fn list_series(&mut self) -> Result<Vec<Series>> {
info!("Fetching Sonarr library");
let event = SonarrEvent::ListSeries;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<Series>>(request_props, |mut series_vec, mut app| {
if !matches!(
app.get_current_route(),
Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, _)
) {
series_vec.sort_by(|a, b| a.id.cmp(&b.id));
app.data.sonarr_data.series.set_items(series_vec);
app.data.sonarr_data.series.apply_sorting_toggle(false);
}
})
.await
}
async fn get_sonarr_status(&mut self) -> Result<SystemStatus> {
info!("Fetching Sonarr system status");
let event = SonarrEvent::GetStatus;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), SystemStatus>(request_props, |system_status, mut app| {
app.data.sonarr_data.version = system_status.version;
app.data.sonarr_data.start_time = system_status.start_time;
})
.await
}
}
+351
View File
@@ -0,0 +1,351 @@
#[cfg(test)]
mod test {
use chrono::{DateTime, Utc};
use pretty_assertions::{assert_eq, assert_str_eq};
use reqwest::Client;
use rstest::rstest;
use serde_json::json;
use serde_json::{Number, Value};
use tokio_util::sync::CancellationToken;
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::sonarr_models::SystemStatus;
use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption};
use crate::{
models::sonarr_models::{
Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType,
},
network::{
network_tests::test_utils::mock_servarr_api, sonarr_network::SonarrEvent, Network,
NetworkEvent, NetworkResource, RequestMethod,
},
};
const SERIES_JSON: &str = r#"{
"title": "Test",
"status": "continuing",
"ended": false,
"overview": "Blah blah blah",
"network": "HBO",
"seasons": [
{
"seasonNumber": 1,
"monitored": true,
"statistics": {
"previousAiring": "2022-10-24T01:00:00Z",
"episodeFileCount": 10,
"episodeCount": 10,
"totalEpisodeCount": 10,
"sizeOnDisk": 36708563419,
"percentOfEpisodes": 100.0
}
}
],
"year": 2022,
"path": "/nfs/tv/Test",
"qualityProfileId": 6,
"languageProfileId": 1,
"seasonFolder": true,
"monitored": true,
"runtime": 63,
"tvdbId": 371572,
"seriesType": "standard",
"certification": "TV-MA",
"genres": ["cool", "family", "fun"],
"tags": [3],
"ratings": {"votes": 406744, "value": 8.4},
"statistics": {
"seasonCount": 2,
"episodeFileCount": 18,
"episodeCount": 18,
"totalEpisodeCount": 50,
"sizeOnDisk": 63894022699,
"percentOfEpisodes": 100.0
},
"id": 1
}
"#;
#[rstest]
fn test_resource_series(#[values(SonarrEvent::ListSeries)] event: SonarrEvent) {
assert_str_eq!(event.resource(), "/series");
}
#[rstest]
#[case(SonarrEvent::HealthCheck, "/health")]
#[case(SonarrEvent::GetStatus, "/system/status")]
fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) {
assert_str_eq!(event.resource(), expected_uri);
}
#[test]
fn test_from_sonarr_event() {
assert_eq!(
NetworkEvent::Sonarr(SonarrEvent::HealthCheck),
NetworkEvent::from(SonarrEvent::HealthCheck)
);
}
#[tokio::test]
async fn test_handle_get_sonarr_healthcheck_event() {
let (async_server, app_arc, _server) = mock_servarr_api(
RequestMethod::Get,
None,
None,
None,
SonarrEvent::HealthCheck,
None,
None,
)
.await;
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let _ = network.handle_sonarr_event(SonarrEvent::HealthCheck).await;
async_server.assert_async().await;
}
#[rstest]
#[tokio::test]
async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) {
let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap();
let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap();
*series_1.get_mut("id").unwrap() = json!(1);
*series_1.get_mut("title").unwrap() = json!("z test");
*series_2.get_mut("id").unwrap() = json!(2);
*series_2.get_mut("title").unwrap() = json!("A test");
let expected_series = vec![
Series {
id: 1,
title: "z test".into(),
..series()
},
Series {
id: 2,
title: "A test".into(),
..series()
},
];
let mut expected_sorted_series = vec![
Series {
id: 1,
title: "z test".into(),
..series()
},
Series {
id: 2,
title: "A test".into(),
..series()
},
];
let (async_server, app_arc, _server) = mock_servarr_api(
RequestMethod::Get,
None,
Some(json!([series_1, series_2])),
None,
SonarrEvent::ListSeries,
None,
None,
)
.await;
app_arc.lock().await.data.sonarr_data.series.sort_asc = true;
if use_custom_sorting {
let cmp_fn = |a: &Series, b: &Series| {
a.title
.text
.to_lowercase()
.cmp(&b.title.text.to_lowercase())
};
expected_sorted_series.sort_by(cmp_fn);
let title_sort_option = SortOption {
name: "Title",
cmp_fn: Some(cmp_fn),
};
app_arc
.lock()
.await
.data
.sonarr_data
.series
.sorting(vec![title_sort_option]);
}
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
if let SonarrSerdeable::SeriesVec(series) = network
.handle_sonarr_event(SonarrEvent::ListSeries)
.await
.unwrap()
{
async_server.assert_async().await;
assert_eq!(
app_arc.lock().await.data.sonarr_data.series.items,
expected_sorted_series
);
assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc);
assert_eq!(series, expected_series);
}
}
#[tokio::test]
async fn test_handle_get_series_event_no_op_while_user_is_selecting_sort_options() {
let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap();
let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap();
*series_1.get_mut("id").unwrap() = json!(1);
*series_1.get_mut("title").unwrap() = json!("z test");
*series_2.get_mut("id").unwrap() = json!(2);
*series_2.get_mut("title").unwrap() = json!("A test");
let (async_server, app_arc, _server) = mock_servarr_api(
RequestMethod::Get,
None,
Some(json!([series_1, series_2])),
None,
SonarrEvent::ListSeries,
None,
None,
)
.await;
app_arc
.lock()
.await
.push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into());
app_arc.lock().await.data.sonarr_data.series.sort_asc = true;
let cmp_fn = |a: &Series, b: &Series| {
a.title
.text
.to_lowercase()
.cmp(&b.title.text.to_lowercase())
};
let title_sort_option = SortOption {
name: "Title",
cmp_fn: Some(cmp_fn),
};
app_arc
.lock()
.await
.data
.sonarr_data
.series
.sorting(vec![title_sort_option]);
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
assert!(network
.handle_sonarr_event(SonarrEvent::ListSeries)
.await
.is_ok());
async_server.assert_async().await;
assert!(app_arc
.lock()
.await
.data
.sonarr_data
.series
.items
.is_empty());
assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc);
}
#[tokio::test]
async fn test_handle_get_status_event() {
let (async_server, app_arc, _server) = mock_servarr_api(
RequestMethod::Get,
None,
Some(json!({
"version": "v1",
"startTime": "2023-02-25T20:16:43Z"
})),
None,
SonarrEvent::GetStatus,
None,
None,
)
.await;
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let date_time = DateTime::from(DateTime::parse_from_rfc3339("2023-02-25T20:16:43Z").unwrap())
as DateTime<Utc>;
if let SonarrSerdeable::SystemStatus(status) = network
.handle_sonarr_event(SonarrEvent::GetStatus)
.await
.unwrap()
{
async_server.assert_async().await;
assert_str_eq!(app_arc.lock().await.data.sonarr_data.version, "v1");
assert_eq!(app_arc.lock().await.data.sonarr_data.start_time, date_time);
assert_eq!(
status,
SystemStatus {
version: "v1".to_owned(),
start_time: date_time
}
);
}
}
fn rating() -> Rating {
Rating {
votes: 406744,
value: 8.4,
}
}
fn season() -> Season {
Season {
season_number: 1,
monitored: true,
statistics: season_statistics(),
}
}
fn season_statistics() -> SeasonStatistics {
SeasonStatistics {
previous_airing: Some(DateTime::from(
DateTime::parse_from_rfc3339("2022-10-24T01:00:00Z").unwrap(),
)),
next_airing: None,
episode_file_count: 10,
episode_count: 10,
total_episode_count: 10,
size_on_disk: 36708563419,
percent_of_episodes: 100.0,
}
}
fn series() -> Series {
Series {
title: "Test".to_owned().into(),
status: SeriesStatus::Continuing,
ended: false,
overview: "Blah blah blah".into(),
network: Some("HBO".to_owned()),
seasons: Some(vec![season()]),
year: 2022,
path: "/nfs/tv/Test".to_owned(),
quality_profile_id: 6,
language_profile_id: 1,
season_folder: true,
monitored: true,
runtime: 63,
tvdb_id: 371572,
series_type: SeriesType::Standard,
certification: Some("TV-MA".to_owned()),
genres: vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()],
tags: vec![Number::from(3)],
ratings: rating(),
statistics: Some(series_statistics()),
id: 1,
}
}
fn series_statistics() -> SeriesStatistics {
SeriesStatistics {
season_count: 2,
episode_file_count: 18,
episode_count: 18,
total_episode_count: 50,
size_on_disk: 63894022699,
percent_of_episodes: 100.0,
}
}
}