diff --git a/README.md b/README.md index 3219aaa..d647ec6 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ tautulli: - [x] View details of a specific movie including description, history, downloaded file info, or the credits - [x] View details of any collection and the movies in them - [x] Search your library or collections -- [ ] Add or delete movies +- [x] Add or delete movies - [ ] Manage your quality profiles - [ ] Modify your Radarr settings diff --git a/src/app/radarr.rs b/src/app/radarr.rs index d868d2c..b3b341c 100644 --- a/src/app/radarr.rs +++ b/src/app/radarr.rs @@ -7,12 +7,13 @@ use strum::EnumIter; use crate::app::{App, Route}; use crate::models::radarr_models::{ AddMovieSearchResult, Collection, CollectionMovie, Credit, DiskSpace, DownloadRecord, Movie, - MovieHistoryItem, + MovieHistoryItem, RootFolder, }; use crate::models::{ScrollableText, StatefulTable, TabRoute, TabState}; use crate::network::radarr_network::RadarrEvent; pub struct RadarrData { + pub root_folders: Vec, pub disk_space_vec: Vec, pub version: String, pub start_time: DateTime, @@ -31,6 +32,7 @@ pub struct RadarrData { pub collections: StatefulTable, pub filtered_collections: StatefulTable, pub collection_movies: StatefulTable, + pub prompt_confirm_action: Option, pub main_tabs: TabState, pub movie_info_tabs: TabState, pub search: String, @@ -72,6 +74,7 @@ impl RadarrData { impl Default for RadarrData { fn default() -> RadarrData { RadarrData { + root_folders: Vec::new(), disk_space_vec: Vec::new(), version: String::default(), start_time: DateTime::default(), @@ -90,6 +93,7 @@ impl Default for RadarrData { collections: StatefulTable::default(), filtered_collections: StatefulTable::default(), collection_movies: StatefulTable::default(), + prompt_confirm_action: None, search: String::default(), filter: String::default(), is_searching: false, @@ -201,19 +205,15 @@ impl App { self .dispatch_network_event(RadarrEvent::GetDownloads.into()) .await; - - if self.data.radarr_data.prompt_confirm { - self.data.radarr_data.prompt_confirm = false; - self - .dispatch_network_event(RadarrEvent::DeleteMovie.into()) - .await; - } + self.check_for_prompt_action().await; } ActiveRadarrBlock::AddMovieSearchResults => { self.is_loading = true; self .dispatch_network_event(RadarrEvent::SearchNewMovie.into()) .await; + + self.check_for_prompt_action().await; } ActiveRadarrBlock::MovieDetails | ActiveRadarrBlock::FileInfo => { self.is_loading = true; @@ -243,6 +243,19 @@ impl App { self.reset_tick_count(); } + async fn check_for_prompt_action(&mut self) { + if self.data.radarr_data.prompt_confirm { + self.data.radarr_data.prompt_confirm = false; + if let Some(radarr_event) = &self.data.radarr_data.prompt_confirm_action { + self + .dispatch_network_event(radarr_event.clone().into()) + .await; + self.should_refresh = true; + self.data.radarr_data.prompt_confirm_action = None; + } + } + } + pub(super) async fn radarr_on_tick( &mut self, active_radarr_block: ActiveRadarrBlock, @@ -252,6 +265,9 @@ impl App { self .dispatch_network_event(RadarrEvent::GetQualityProfiles.into()) .await; + self + .dispatch_network_event(RadarrEvent::GetRootFolders.into()) + .await; self .dispatch_network_event(RadarrEvent::GetOverview.into()) .await; diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 24dc484..28a057b 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -43,16 +43,22 @@ pub trait KeyEventHandler<'a, T: Into> { } pub fn handle_events(key: Key, app: &mut App) { - match app.get_current_route().clone() { - Route::Radarr(active_radarr_block) => { - RadarrHandler::with(&key, app, &active_radarr_block).handle() - } - _ => (), + if let Route::Radarr(active_radarr_block) = app.get_current_route().clone() { + RadarrHandler::with(&key, app, &active_radarr_block).handle() } } -pub fn handle_clear_errors(app: &mut App) { +fn handle_clear_errors(app: &mut App) { if !app.error.text.is_empty() { app.error = HorizontallyScrollableText::default(); } } + +fn handle_prompt_toggle(app: &mut App, key: &Key) { + match key { + _ if *key == DEFAULT_KEYBINDINGS.left.key || *key == DEFAULT_KEYBINDINGS.right.key => { + app.data.radarr_data.prompt_confirm = !app.data.radarr_data.prompt_confirm; + } + _ => (), + } +} diff --git a/src/handlers/radarr_handlers/add_movie_handler.rs b/src/handlers/radarr_handlers/add_movie_handler.rs index a1706be..7c346f2 100644 --- a/src/handlers/radarr_handlers/add_movie_handler.rs +++ b/src/handlers/radarr_handlers/add_movie_handler.rs @@ -1,7 +1,8 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::radarr::ActiveRadarrBlock; -use crate::handlers::KeyEventHandler; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::{Scrollable, StatefulTable}; +use crate::network::radarr_network::RadarrEvent; use crate::{App, Key}; pub(super) struct AddMovieHandler<'a> { @@ -28,44 +29,36 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for AddMovieHandler<'a> { } fn handle_scroll_up(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::AddMovieSearchResults => { - self.app.data.radarr_data.add_searched_movies.scroll_up() - } - _ => (), + if self.active_radarr_block == &ActiveRadarrBlock::AddMovieSearchResults { + self.app.data.radarr_data.add_searched_movies.scroll_up() } } fn handle_scroll_down(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::AddMovieSearchResults => { - self.app.data.radarr_data.add_searched_movies.scroll_down() - } - _ => (), + if self.active_radarr_block == &ActiveRadarrBlock::AddMovieSearchResults { + self.app.data.radarr_data.add_searched_movies.scroll_down() } } fn handle_home(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::AddMovieSearchResults => self + if self.active_radarr_block == &ActiveRadarrBlock::AddMovieSearchResults { + self .app .data .radarr_data .add_searched_movies - .scroll_to_top(), - _ => (), + .scroll_to_top() } } fn handle_end(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::AddMovieSearchResults => self + if self.active_radarr_block == &ActiveRadarrBlock::AddMovieSearchResults { + self .app .data .radarr_data .add_searched_movies - .scroll_to_bottom(), - _ => (), + .scroll_to_bottom() } } @@ -73,14 +66,7 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for AddMovieHandler<'a> { fn handle_left_right_action(&mut self) { if *self.active_radarr_block == ActiveRadarrBlock::AddMoviePrompt { - match self.key { - _ if *self.key == DEFAULT_KEYBINDINGS.left.key - || *self.key == DEFAULT_KEYBINDINGS.right.key => - { - self.app.data.radarr_data.prompt_confirm = !self.app.data.radarr_data.prompt_confirm; - } - _ => (), - } + handle_prompt_toggle(self.app, self.key); } } @@ -97,6 +83,14 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for AddMovieHandler<'a> { .app .push_navigation_stack(ActiveRadarrBlock::AddMoviePrompt.into()); } + ActiveRadarrBlock::AddMoviePrompt => { + if self.app.data.radarr_data.prompt_confirm { + self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::AddMovie); + self.app.pop_navigation_stack(); + } else { + self.app.pop_navigation_stack(); + } + } _ => (), } } @@ -123,8 +117,8 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for AddMovieHandler<'a> { fn handle_char_key_event(&mut self) { let key = self.key; - match self.active_radarr_block { - ActiveRadarrBlock::AddMovieSearchInput => match self.key { + if self.active_radarr_block == &ActiveRadarrBlock::AddMovieSearchInput { + match self.key { _ if *key == DEFAULT_KEYBINDINGS.backspace.key => { self.app.data.radarr_data.search.pop(); } @@ -132,8 +126,7 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for AddMovieHandler<'a> { self.app.data.radarr_data.search.push(*character); } _ => (), - }, - _ => (), + } } } } diff --git a/src/handlers/radarr_handlers/mod.rs b/src/handlers/radarr_handlers/mod.rs index 43d38ca..2bb9efb 100644 --- a/src/handlers/radarr_handlers/mod.rs +++ b/src/handlers/radarr_handlers/mod.rs @@ -3,8 +3,9 @@ use crate::app::radarr::ActiveRadarrBlock; use crate::handlers::radarr_handlers::add_movie_handler::AddMovieHandler; use crate::handlers::radarr_handlers::collection_details_handler::CollectionDetailsHandler; use crate::handlers::radarr_handlers::movie_details_handler::MovieDetailsHandler; -use crate::handlers::{handle_clear_errors, KeyEventHandler}; +use crate::handlers::{handle_clear_errors, handle_prompt_toggle, KeyEventHandler}; use crate::models::Scrollable; +use crate::network::radarr_network::RadarrEvent; use crate::utils::strip_non_alphanumeric_characters; use crate::{App, Key}; @@ -217,14 +218,7 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> { _ => (), } } - ActiveRadarrBlock::DeleteMoviePrompt => match self.key { - _ if *self.key == DEFAULT_KEYBINDINGS.left.key - || *self.key == DEFAULT_KEYBINDINGS.right.key => - { - self.app.data.radarr_data.prompt_confirm = !self.app.data.radarr_data.prompt_confirm; - } - _ => (), - }, + ActiveRadarrBlock::DeleteMoviePrompt => handle_prompt_toggle(self.app, self.key), _ => (), } } @@ -292,7 +286,10 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> { } } ActiveRadarrBlock::DeleteMoviePrompt => { - self.app.should_refresh = self.app.data.radarr_data.prompt_confirm; + if self.app.data.radarr_data.prompt_confirm { + self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteMovie); + } + self.app.pop_navigation_stack(); } _ => (), diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 28f4df3..b0f3578 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -19,6 +19,14 @@ pub struct SystemStatus { pub start_time: DateTime, } +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RootFolder { + pub path: String, + pub accessible: bool, + pub free_space: Number, +} + #[derive(Derivative, Deserialize, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] #[serde(rename_all = "camelCase")] @@ -200,19 +208,19 @@ pub struct Credit { pub credit_type: CreditType, } -#[derive(Serialize, Debug)] +#[derive(Default, Derivative, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct AddMovieBody { - pub tmdb_id: Number, + pub tmdb_id: u64, pub title: String, pub root_folder_path: String, - pub quality_profile_id: Number, + pub quality_profile_id: u64, pub minimum_availability: String, pub monitored: bool, pub add_options: AddOptions, } -#[derive(Serialize, Debug, PartialEq, Eq)] +#[derive(Default, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddOptions { pub search_for_movie: bool, diff --git a/src/network/mod.rs b/src/network/mod.rs index 563bf32..46d187c 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -1,7 +1,12 @@ +use std::fmt::Debug; use std::sync::Arc; -use reqwest::Client; -use tokio::sync::Mutex; +use anyhow::anyhow; +use log::{debug, error}; +use reqwest::{Client, RequestBuilder}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use tokio::sync::{Mutex, MutexGuard}; use crate::app::App; use crate::network::radarr_network::RadarrEvent; @@ -33,4 +38,90 @@ impl<'a> Network<'a> { let mut app = self.app.lock().await; app.is_loading = false; } + + pub async fn handle_request( + &self, + request_props: RequestProps, + mut app_update_fn: impl FnMut(R, MutexGuard), + ) where + T: Serialize + Default + Debug, + R: DeserializeOwned, + { + let method = request_props.method.clone(); + match self.call_api(request_props).await.send().await { + Ok(response) => { + if response.status().is_success() { + match method { + RequestMethod::Get => match utils::parse_response::(response).await { + Ok(value) => { + let app = self.app.lock().await; + app_update_fn(value, app); + } + Err(e) => { + error!("Failed to parse response! {:?}", e); + self.app.lock().await.handle_error(anyhow!(e)); + } + }, + RequestMethod::Delete | RequestMethod::Post => (), + } + } else { + error!( + "Request failed. Received {} response code", + response.status() + ); + self.app.lock().await.handle_error(anyhow!( + "Request failed. Received {} response code", + response.status() + )); + } + } + Err(e) => { + error!("Failed to send request. {:?}", e); + self.app.lock().await.handle_error(anyhow!(e)); + } + } + } + + async fn call_api( + &self, + request_props: RequestProps, + ) -> RequestBuilder { + let RequestProps { + uri, + method, + body, + api_token, + } = request_props; + debug!("Creating RequestBuilder for resource: {:?}", uri); + let app = self.app.lock().await; + debug!( + "Sending {:?} request to {} with body {:?}", + method, uri, body + ); + + match method { + RequestMethod::Get => app.client.get(uri).header("X-Api-Key", api_token), + RequestMethod::Post => app + .client + .post(uri) + .json(&body.unwrap_or_default()) + .header("X-Api-Key", api_token), + RequestMethod::Delete => app.client.delete(uri).header("X-Api-Key", api_token), + } + } +} + +#[derive(Clone, Debug)] +pub enum RequestMethod { + Get, + Post, + Delete, +} + +#[derive(Debug)] +pub struct RequestProps { + pub uri: String, + pub method: RequestMethod, + pub body: Option, + pub api_token: String, } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 10a2258..39c180e 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -1,23 +1,23 @@ -use anyhow::anyhow; +use std::fmt::Debug; + use indoc::formatdoc; -use log::{debug, error}; -use reqwest::{RequestBuilder, StatusCode}; -use serde::de::DeserializeOwned; -use tokio::sync::MutexGuard; +use log::{debug, info}; +use serde::Serialize; use urlencoding::encode; -use crate::app::{App, RadarrConfig}; +use crate::app::RadarrConfig; use crate::models::radarr_models::{ - AddMovieSearchResult, Collection, Credit, CreditType, DiskSpace, DownloadsResponse, Movie, - MovieHistoryItem, QualityProfile, SystemStatus, + AddMovieBody, AddMovieSearchResult, AddOptions, Collection, Credit, CreditType, DiskSpace, + DownloadsResponse, Movie, MovieHistoryItem, QualityProfile, RootFolder, SystemStatus, }; use crate::models::ScrollableText; use crate::network::utils::get_movie_status; -use crate::network::{utils, Network, NetworkEvent}; +use crate::network::{Network, NetworkEvent, RequestMethod, RequestProps}; use crate::utils::{convert_runtime, convert_to_gb}; -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq, Clone)] pub enum RadarrEvent { + AddMovie, DeleteMovie, GetCollections, GetDownloads, @@ -27,34 +27,27 @@ pub enum RadarrEvent { GetMovieHistory, GetOverview, GetQualityProfiles, + GetRootFolders, GetStatus, SearchNewMovie, HealthCheck, } -#[derive(Clone)] -enum RequestMethod { - GET, - DELETE, -} - -struct RequestProps { - pub resource: String, - pub method: RequestMethod, - pub body: Option, -} - impl RadarrEvent { const fn resource(self) -> &'static str { match self { RadarrEvent::GetCollections => "/collection", RadarrEvent::GetDownloads => "/queue", - RadarrEvent::GetMovies | RadarrEvent::GetMovieDetails | RadarrEvent::DeleteMovie => "/movie", + RadarrEvent::AddMovie + | RadarrEvent::GetMovies + | RadarrEvent::GetMovieDetails + | RadarrEvent::DeleteMovie => "/movie", RadarrEvent::SearchNewMovie => "/movie/lookup", RadarrEvent::GetMovieCredits => "/credit", RadarrEvent::GetMovieHistory => "/history/movie", RadarrEvent::GetOverview => "/diskspace", RadarrEvent::GetQualityProfiles => "/qualityprofile", + RadarrEvent::GetRootFolders => "/rootfolder", RadarrEvent::GetStatus => "/system/status", RadarrEvent::HealthCheck => "/health", } @@ -70,227 +63,197 @@ impl From for NetworkEvent { impl<'a> Network<'a> { pub async fn handle_radarr_event(&self, radarr_event: RadarrEvent) { match radarr_event { - RadarrEvent::GetCollections => { - self - .get_collections(RadarrEvent::GetCollections.resource().to_owned()) - .await - } - RadarrEvent::HealthCheck => { - self - .get_healthcheck(RadarrEvent::HealthCheck.resource().to_owned()) - .await - } - RadarrEvent::GetOverview => { - self - .get_diskspace(RadarrEvent::GetOverview.resource().to_owned()) - .await - } - RadarrEvent::GetStatus => { - self - .get_status(RadarrEvent::GetStatus.resource().to_owned()) - .await - } - RadarrEvent::GetMovies => { - self - .get_movies(RadarrEvent::GetMovies.resource().to_owned()) - .await - } - RadarrEvent::DeleteMovie => { - self - .delete_movie(RadarrEvent::DeleteMovie.resource().to_owned()) - .await - } - RadarrEvent::GetMovieCredits => { - self - .get_credits(RadarrEvent::GetMovieCredits.resource().to_owned()) - .await - } - RadarrEvent::GetMovieDetails => { - self - .get_movie_details(RadarrEvent::GetMovieDetails.resource().to_owned()) - .await - } - RadarrEvent::GetMovieHistory => { - self - .get_movie_history(RadarrEvent::GetMovieHistory.resource().to_owned()) - .await - } - RadarrEvent::GetDownloads => { - self - .get_downloads(RadarrEvent::GetDownloads.resource().to_owned()) - .await - } - RadarrEvent::GetQualityProfiles => { - self - .get_quality_profiles(RadarrEvent::GetQualityProfiles.resource().to_owned()) - .await - } - RadarrEvent::SearchNewMovie => { - self - .search_movie(RadarrEvent::SearchNewMovie.resource().to_owned()) - .await - } + RadarrEvent::GetCollections => self.get_collections().await, + RadarrEvent::HealthCheck => self.get_healthcheck().await, + RadarrEvent::GetOverview => self.get_diskspace().await, + RadarrEvent::GetStatus => self.get_status().await, + RadarrEvent::GetMovies => self.get_movies().await, + RadarrEvent::DeleteMovie => self.delete_movie().await, + RadarrEvent::GetMovieCredits => self.get_credits().await, + RadarrEvent::GetMovieDetails => self.get_movie_details().await, + RadarrEvent::GetMovieHistory => self.get_movie_history().await, + RadarrEvent::GetDownloads => self.get_downloads().await, + RadarrEvent::GetQualityProfiles => self.get_quality_profiles().await, + RadarrEvent::GetRootFolders => self.get_root_folders().await, + RadarrEvent::SearchNewMovie => self.search_movie().await, + RadarrEvent::AddMovie => self.add_movie().await, } } - async fn get_healthcheck(&self, resource: String) { - if let Err(e) = self - .call_radarr_api::<()>(RequestProps { - resource, - method: RequestMethod::GET, - body: None::<()>, + async fn get_healthcheck(&self) { + info!("Performing Radarr health check"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::HealthCheck.resource(), + RequestMethod::Get, + None::<()>, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await; + } + + async fn get_diskspace(&self) { + info!("Fetching Radarr disk space"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetOverview.resource(), + RequestMethod::Get, + None::<()>, + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { + app.data.radarr_data.disk_space_vec = disk_space_vec; }) - .await - .send() - .await - { - error!("Healthcheck failed. {:?}", e); - self.app.lock().await.handle_error(anyhow!(e)); - } - } - - async fn get_diskspace(&self, resource: String) { - type ResponseType = Vec; - self - .handle_request::( - RequestProps { - resource, - method: RequestMethod::GET, - body: None::, - }, - |disk_space_vec, mut app| { - app.data.radarr_data.disk_space_vec = disk_space_vec; - }, - ) .await; } - async fn get_status(&self, resource: String) { - self - .handle_request::( - RequestProps { - resource, - method: RequestMethod::GET, - body: None::, - }, - |system_status, mut app| { - app.data.radarr_data.version = system_status.version; - app.data.radarr_data.start_time = system_status.start_time; - }, + async fn get_status(&self) { + info!("Fetching Radarr system status"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetStatus.resource(), + RequestMethod::Get, + None::<()>, ) .await; + + self + .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { + app.data.radarr_data.version = system_status.version; + app.data.radarr_data.start_time = system_status.start_time; + }) + .await; } - async fn get_movies(&self, resource: String) { - type ResponseType = Vec; - self - .handle_request::( - RequestProps { - resource, - method: RequestMethod::GET, - body: None::, - }, - |movie_vec, mut app| app.data.radarr_data.movies.set_items(movie_vec), + async fn get_movies(&self) { + info!("Fetching Radarr library"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetMovies.resource(), + RequestMethod::Get, + None::<()>, ) .await; + + self + .handle_request::<(), Vec>(request_props, |movie_vec, mut app| { + app.data.radarr_data.movies.set_items(movie_vec) + }) + .await; } - async fn search_movie(&self, resource: String) { - type ResponseType = Vec; + async fn search_movie(&self) { + info!("Searching for specific Radarr movie"); + let search_string = self.app.lock().await.data.radarr_data.search.clone(); - debug!( - "Searching for movie: {:?}", - format!("{}?term={}", resource, encode(search_string.as_str())) - ); - self - .handle_request::( - RequestProps { - resource: format!("{}?term={}", resource, encode(&search_string)), - method: RequestMethod::GET, - body: None::, - }, - |movie_vec, mut app| { - app - .data - .radarr_data - .add_searched_movies - .set_items(movie_vec) - }, + let request_props = self + .radarr_request_props_from( + format!( + "{}?term={}", + RadarrEvent::SearchNewMovie.resource(), + encode(&search_string) + ) + .as_str(), + RequestMethod::Get, + None::<()>, ) .await; + + self + .handle_request::<(), Vec>(request_props, |movie_vec, mut app| { + app + .data + .radarr_data + .add_searched_movies + .set_items(movie_vec) + }) + .await; } - async fn get_movie_details(&self, resource: String) { + async fn get_movie_details(&self) { + info!("Fetching Radarr movie details"); + let movie_id = self.extract_movie_id().await; + let request_props = self + .radarr_request_props_from( + format!("{}/{}", RadarrEvent::GetMovieDetails.resource(), movie_id).as_str(), + RequestMethod::Get, + None::<()>, + ) + .await; + self - .handle_request::( - RequestProps { - resource: format!("{}/{}", resource, movie_id), - method: RequestMethod::GET, - body: None::, - }, - |movie_response, mut app| { - let Movie { - id, - title, - year, - overview, - path, - studio, - has_file, - quality_profile_id, - size_on_disk, - genres, - runtime, - ratings, - movie_file, - collection, - .. - } = movie_response; - let (hours, minutes) = convert_runtime(runtime.as_u64().unwrap()); - let size = convert_to_gb(size_on_disk.as_u64().unwrap()); - let quality_profile = app - .data - .radarr_data - .quality_profile_map - .get(&quality_profile_id.as_u64().unwrap()) - .unwrap() - .to_owned(); - let imdb_rating = if let Some(rating) = ratings.imdb { - if let Some(value) = rating.value.as_f64() { - format!("{:.1}", value) - } else { - "".to_owned() - } + .handle_request::<(), Movie>(request_props, |movie_response, mut app| { + let Movie { + id, + title, + year, + overview, + path, + studio, + has_file, + quality_profile_id, + size_on_disk, + genres, + runtime, + ratings, + movie_file, + collection, + .. + } = movie_response; + let (hours, minutes) = convert_runtime(runtime.as_u64().unwrap()); + let size = convert_to_gb(size_on_disk.as_u64().unwrap()); + let quality_profile = app + .data + .radarr_data + .quality_profile_map + .get(&quality_profile_id.as_u64().unwrap()) + .unwrap() + .to_owned(); + let imdb_rating = if let Some(rating) = ratings.imdb { + if let Some(value) = rating.value.as_f64() { + format!("{:.1}", value) } else { "".to_owned() - }; + } + } else { + "".to_owned() + }; - let tmdb_rating = if let Some(rating) = ratings.tmdb { - if let Some(value) = rating.value.as_f64() { - format!("{}%", (value * 10f64).ceil()) - } else { - "".to_owned() - } + let tmdb_rating = if let Some(rating) = ratings.tmdb { + if let Some(value) = rating.value.as_f64() { + format!("{}%", (value * 10f64).ceil()) } else { "".to_owned() - }; + } + } else { + "".to_owned() + }; - let rotten_tomatoes_rating = if let Some(rating) = ratings.rotten_tomatoes { - if let Some(value) = rating.value.as_u64() { - format!("{}%", value) - } else { - "".to_owned() - } + let rotten_tomatoes_rating = if let Some(rating) = ratings.rotten_tomatoes { + if let Some(value) = rating.value.as_u64() { + format!("{}%", value) } else { "".to_owned() - }; + } + } else { + "".to_owned() + }; - let status = get_movie_status(has_file, &app.data.radarr_data.downloads.items, id); - let collection = collection.unwrap_or_default(); + let status = get_movie_status(has_file, &app.data.radarr_data.downloads.items, id); + let collection = collection.unwrap_or_default(); - app.data.radarr_data.movie_details = ScrollableText::with_string(formatdoc!( - "Title: {} + app.data.radarr_data.movie_details = ScrollableText::with_string(formatdoc!( + "Title: {} Year: {} Runtime: {}h {}m Collection: {} @@ -304,253 +267,292 @@ impl<'a> Network<'a> { Path: {} Studio: {} Genres: {}", - title, - year, - hours, - minutes, - collection.title, - status, - overview, - tmdb_rating, - imdb_rating, - rotten_tomatoes_rating, - quality_profile, - size, - path, - studio, - genres.join(", ") - )); + title, + year, + hours, + minutes, + collection.title, + status, + overview, + tmdb_rating, + imdb_rating, + rotten_tomatoes_rating, + quality_profile, + size, + path, + studio, + genres.join(", ") + )); - if let Some(file) = movie_file { - app.data.radarr_data.file_details = formatdoc!( - "Relative Path: {} + if let Some(file) = movie_file { + app.data.radarr_data.file_details = formatdoc!( + "Relative Path: {} Absolute Path: {} Size: {:.2} GB Date Added: {}", - file.relative_path, - file.path, - size, - file.date_added - ); + file.relative_path, + file.path, + size, + file.date_added + ); - let media_info = file.media_info; + let media_info = file.media_info; - app.data.radarr_data.audio_details = formatdoc!( - "Bitrate: {} + app.data.radarr_data.audio_details = formatdoc!( + "Bitrate: {} Channels: {:.1} Codec: {} Languages: {} Stream Count: {}", - media_info.audio_bitrate.as_u64().unwrap(), - 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.as_u64().unwrap() - ); + media_info.audio_bitrate.as_u64().unwrap(), + 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.as_u64().unwrap() + ); - app.data.radarr_data.video_details = formatdoc!( - "Bit Depth: {} + app.data.radarr_data.video_details = formatdoc!( + "Bit Depth: {} Bitrate: {} Codec: {} FPS: {} Resolution: {} Scan Type: {} Runtime: {}", - media_info.video_bit_depth.as_u64().unwrap(), - media_info.video_bitrate.as_u64().unwrap(), - media_info.video_codec, - media_info.video_fps.as_f64().unwrap(), - media_info.resolution, - media_info.scan_type, - media_info.run_time - ); - } - }, - ) + media_info.video_bit_depth.as_u64().unwrap(), + media_info.video_bitrate.as_u64().unwrap(), + media_info.video_codec, + media_info.video_fps.as_f64().unwrap(), + media_info.resolution, + media_info.scan_type, + media_info.run_time + ); + } + }) .await; } - async fn get_movie_history(&self, resource: String) { - type ResponseType = Vec; - self - .handle_request::( - RequestProps { - resource: self.append_movie_id_param(&resource).await, - method: RequestMethod::GET, - body: None::, - }, - |movie_history_vec, mut app| { - let mut reversed_movie_history_vec = movie_history_vec.to_vec(); - reversed_movie_history_vec.reverse(); - app - .data - .radarr_data - .movie_history - .set_items(reversed_movie_history_vec) - }, + async fn get_movie_history(&self) { + info!("Fetching Radarr movie history"); + + let request_props = self + .radarr_request_props_from( + self + .append_movie_id_param(RadarrEvent::GetMovieHistory.resource()) + .await + .as_str(), + RequestMethod::Get, + None::<()>, ) .await; + + self + .handle_request::<(), Vec>(request_props, |movie_history_vec, mut app| { + let mut reversed_movie_history_vec = movie_history_vec.to_vec(); + reversed_movie_history_vec.reverse(); + app + .data + .radarr_data + .movie_history + .set_items(reversed_movie_history_vec) + }) + .await; } - async fn get_collections(&self, resource: String) { - type ResponseType = Vec; - self - .handle_request::( - RequestProps { - resource, - method: RequestMethod::GET, - body: None::, - }, - |collections_vec, mut app| { - app.data.radarr_data.collections.set_items(collections_vec); - }, + async fn get_collections(&self) { + info!("Fetching Radarr collections"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetCollections.resource(), + RequestMethod::Get, + None::<()>, ) .await; + + self + .handle_request::<(), Vec>(request_props, |collections_vec, mut app| { + app.data.radarr_data.collections.set_items(collections_vec); + }) + .await; } - async fn get_downloads(&self, resource: String) { - self - .handle_request::( - RequestProps { - resource, - method: RequestMethod::GET, - body: None::, - }, - |queue_response, mut app| { - app - .data - .radarr_data - .downloads - .set_items(queue_response.records); - }, + async fn get_downloads(&self) { + info!("Fetching Radarr downloads"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetDownloads.resource(), + RequestMethod::Get, + None::<()>, ) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .radarr_data + .downloads + .set_items(queue_response.records); + }) .await } - async fn get_quality_profiles(&self, resource: String) { - type ResponseType = Vec; - self - .handle_request::( - RequestProps { - resource, - method: RequestMethod::GET, - body: None::, - }, - |quality_profiles, mut app| { - app.data.radarr_data.quality_profile_map = quality_profiles - .iter() - .map(|profile| (profile.id.as_u64().unwrap(), profile.name.clone())) - .collect(); - }, + async fn get_quality_profiles(&self) { + info!("Fetching Radarr quality profiles"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetQualityProfiles.resource(), + RequestMethod::Get, + None::<()>, ) .await; + + self + .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { + app.data.radarr_data.quality_profile_map = quality_profiles + .iter() + .map(|profile| (profile.id.as_u64().unwrap(), profile.name.clone())) + .collect(); + }) + .await; } - async fn get_credits(&self, resource: String) { - type ResponseType = Vec; - self - .handle_request::( - RequestProps { - resource: self.append_movie_id_param(&resource).await, - method: RequestMethod::GET, - body: None::, - }, - |credit_vec, mut app| { - let cast_vec: Vec = credit_vec - .iter() - .cloned() - .filter(|credit| credit.credit_type == CreditType::Cast) - .collect(); - let crew_vec: Vec = credit_vec - .iter() - .cloned() - .filter(|credit| credit.credit_type == CreditType::Crew) - .collect(); + async fn get_root_folders(&self) { + info!("Fetching Radarr root folders"); - app.data.radarr_data.movie_cast.set_items(cast_vec); - app.data.radarr_data.movie_crew.set_items(crew_vec); - }, + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetRootFolders.resource(), + RequestMethod::Get, + None::<()>, ) .await; + + self + .handle_request::<(), Vec>(request_props, |root_folders, mut app| { + app.data.radarr_data.root_folders = root_folders; + }) + .await; } - async fn delete_movie(&self, resource: String) { + async fn get_credits(&self) { + info!("Fetching Radarr movie credits"); + + let request_props = self + .radarr_request_props_from( + self + .append_movie_id_param(RadarrEvent::GetMovieCredits.resource()) + .await + .as_str(), + RequestMethod::Get, + None::<()>, + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |credit_vec, mut app| { + let cast_vec: Vec = credit_vec + .iter() + .cloned() + .filter(|credit| credit.credit_type == CreditType::Cast) + .collect(); + let crew_vec: Vec = credit_vec + .iter() + .cloned() + .filter(|credit| credit.credit_type == CreditType::Crew) + .collect(); + + app.data.radarr_data.movie_cast.set_items(cast_vec); + app.data.radarr_data.movie_crew.set_items(crew_vec); + }) + .await; + } + + async fn delete_movie(&self) { let movie_id = self.extract_movie_id().await; - self - .handle_request::<()>( - RequestProps { - resource: format!("{}/{}", resource, movie_id), - method: RequestMethod::DELETE, - body: None::<()>, - }, - |_, _| (), + + info!("Deleting Radarr movie with id: {}", movie_id); + + let request_props = self + .radarr_request_props_from( + format!("{}/{}", RadarrEvent::DeleteMovie.resource(), movie_id).as_str(), + RequestMethod::Delete, + None::<()>, ) .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await; } - async fn call_radarr_api(&self, request_props: RequestProps) -> RequestBuilder { - let RequestProps { - resource, - method, - body, - } = request_props; - debug!("Creating RequestBuilder for resource: {:?}", resource); - let app = self.app.lock().await; - let RadarrConfig { - host, - port, - api_token, - } = &app.config.radarr; - let uri = format!( - "http://{}:{}/api/v3{}", - host, - port.unwrap_or(7878), - resource - ); + async fn add_movie(&self) { + info!("Adding new movie to Radarr"); + let root_folders = self.app.lock().await.data.radarr_data.root_folders.to_vec(); + let current_selection = self + .app + .lock() + .await + .data + .radarr_data + .add_searched_movies + .current_selection_clone(); + let quality_profile_map = self + .app + .lock() + .await + .data + .radarr_data + .quality_profile_map + .clone(); - match method { - RequestMethod::GET => app.client.get(uri).header("X-Api-Key", api_token), - RequestMethod::DELETE => app.client.delete(uri).header("X-Api-Key", api_token), - } - } - - async fn handle_request( - &self, - request_props: RequestProps, - mut app_update_fn: impl FnMut(T, MutexGuard), - ) where - T: DeserializeOwned, - { - let method = request_props.method.clone(); - match self.call_radarr_api(request_props).await.send().await { - Ok(response) => match method { - RequestMethod::GET => match utils::parse_response::(response).await { - Ok(value) => { - let app = self.app.lock().await; - app_update_fn(value, app); - } - Err(e) => { - error!("Failed to parse response! {:?}", e); - self.app.lock().await.handle_error(anyhow!(e)); - } - }, - RequestMethod::DELETE => { - if response.status() != StatusCode::OK { - error!( - "Received the following code for delete operation: {:?}", - response.status() - ); - self.app.lock().await.handle_error(anyhow!( - "Received a non 200 OK response for delete operation" - )); - } + let RootFolder { path, .. } = root_folders + .iter() + .filter(|folder| folder.accessible) + .reduce(|a, b| { + if a.free_space.as_u64().unwrap() > b.free_space.as_u64().unwrap() { + a + } else { + b } + }) + .unwrap(); + let AddMovieSearchResult { tmdb_id, title, .. } = current_selection; + let quality_profile_id = quality_profile_map + .iter() + .filter(|(_, value)| value.to_lowercase() == "any") + .map(|(key, _)| key) + .next() + .unwrap(); + + let body = AddMovieBody { + tmdb_id: tmdb_id.as_u64().unwrap(), + title: title.to_owned(), + root_folder_path: path.to_owned(), + minimum_availability: "released".to_owned(), + monitored: true, + quality_profile_id: *quality_profile_id, + add_options: AddOptions { + search_for_movie: true, }, - Err(e) => { - error!("Failed to send request. {:?}", e); - self.app.lock().await.handle_error(anyhow!(e)); - } - } + }; + + debug!("Add movie body: {:?}", body); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::AddMovie.resource(), + RequestMethod::Post, + Some(body), + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await; } async fn extract_movie_id(&self) -> u64 { @@ -594,6 +596,33 @@ impl<'a> Network<'a> { async fn append_movie_id_param(&self, resource: &str) -> String { let movie_id = self.extract_movie_id().await; - format!("{}?movieId={}", resource.to_owned(), movie_id.to_owned()) + format!("{}?movieId={}", resource, movie_id) + } + + async fn radarr_request_props_from( + &self, + resource: &str, + method: RequestMethod, + body: Option, + ) -> RequestProps { + let app = self.app.lock().await; + let RadarrConfig { + host, + port, + api_token, + } = &app.config.radarr; + let uri = format!( + "http://{}:{}/api/v3{}", + host, + port.unwrap_or(7878), + resource + ); + + RequestProps { + uri, + method, + body, + api_token: api_token.to_owned(), + } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index edc879b..c404f09 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -54,9 +54,8 @@ pub fn ui(f: &mut Frame, app: &mut App) { draw_header_row(f, app, main_chunks[0]); draw_context_row(f, app, main_chunks[1]); - match app.get_current_route() { - Route::Radarr(_) => radarr_ui::draw_radarr_ui(f, app, main_chunks[2]), - _ => (), + if let Route::Radarr(_) = app.get_current_route() { + radarr_ui::draw_radarr_ui(f, app, main_chunks[2]) } } @@ -152,9 +151,8 @@ pub fn draw_large_popup_over( } fn draw_context_row(f: &mut Frame<'_, B>, app: &App, area: Rect) { - match app.get_current_route() { - Route::Radarr(_) => radarr_ui::draw_radarr_context_row(f, app, area), - _ => (), + if let Route::Radarr(_) = app.get_current_route() { + radarr_ui::draw_radarr_context_row(f, app, area) } } diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 7cbc6c6..a32664f 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -6,14 +6,14 @@ use tui::backend::Backend; use tui::layout::{Alignment, Constraint, Rect}; use tui::style::{Color, Style}; use tui::text::Text; -use tui::widgets::{Cell, Paragraph, Row}; +use tui::widgets::{Block, Cell, Paragraph, Row}; use tui::Frame; use crate::app::radarr::{ActiveRadarrBlock, RadarrData}; use crate::app::App; use crate::logos::RADARR_LOGO; -use crate::models::radarr_models::{DiskSpace, DownloadRecord, Movie}; -use crate::models::Route; +use crate::models::radarr_models::{AddMovieSearchResult, DiskSpace, DownloadRecord, Movie}; +use crate::models::{Route, StatefulTable}; use crate::ui::radarr_ui::add_movie_ui::draw_add_movie_search_popup; use crate::ui::radarr_ui::collection_details_ui::draw_collection_details_popup; use crate::ui::radarr_ui::movie_details_ui::draw_movie_info; diff --git a/src/ui/utils.rs b/src/ui/utils.rs index e37f50f..e6c5281 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -6,10 +6,7 @@ use tui::widgets::{Block, Borders, LineGauge}; use tui::{symbols, Frame}; pub fn horizontal_chunks(constraints: Vec, size: Rect) -> Vec { - Layout::default() - .constraints( as AsRef<[Constraint]>>::as_ref( - &constraints, - )) + layout_with_constraints(constraints) .direction(Direction::Horizontal) .split(size) } @@ -19,20 +16,14 @@ pub fn horizontal_chunks_with_margin( size: Rect, margin: u16, ) -> Vec { - Layout::default() - .constraints( as AsRef<[Constraint]>>::as_ref( - &constraints, - )) + layout_with_constraints(constraints) .direction(Direction::Horizontal) .margin(margin) .split(size) } pub fn vertical_chunks(constraints: Vec, size: Rect) -> Vec { - Layout::default() - .constraints( as AsRef<[Constraint]>>::as_ref( - &constraints, - )) + layout_with_constraints(constraints) .direction(Direction::Vertical) .split(size) } @@ -42,15 +33,18 @@ pub fn vertical_chunks_with_margin( size: Rect, margin: u16, ) -> Vec { - Layout::default() - .constraints( as AsRef<[Constraint]>>::as_ref( - &constraints, - )) + layout_with_constraints(constraints) .direction(Direction::Vertical) .margin(margin) .split(size) } +fn layout_with_constraints(constraints: Vec) -> Layout { + Layout::default().constraints( as AsRef<[Constraint]>>::as_ref( + &constraints, + )) +} + pub fn layout_block<'a>() -> Block<'a> { Block::default().borders(Borders::ALL) } @@ -167,17 +161,14 @@ pub fn logo_block<'a>() -> Block<'a> { } pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ] - .as_ref(), - ) - .split(r); + let popup_layout = vertical_chunks( + vec![ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ], + r, + ); Layout::default() .direction(Direction::Horizontal)