diff --git a/Cargo.toml b/Cargo.toml index e8878e5..50e5f36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ edition = "2021" [dependencies] anyhow = "1.0.68" backtrace = "0.3.67" +bimap = "0.6.3" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.0.30", features = ["help", "usage", "error-context", "derive"] } confy = { version = "0.5.1", default_features = false, features = ["yaml_conf"] } diff --git a/src/app/mod.rs b/src/app/mod.rs index 70576cd..013b335 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,7 @@ use std::time::Duration; use anyhow::anyhow; -use log::error; +use log::{debug, error}; use reqwest::Client; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc::Sender; @@ -21,6 +21,7 @@ pub struct App { network_tx: Option>, pub server_tabs: TabState, pub error: HorizontallyScrollableText, + pub response: String, pub client: Client, pub title: &'static str, pub tick_until_poll: u64, @@ -45,6 +46,8 @@ impl App { } pub async fn dispatch_network_event(&mut self, action: NetworkEvent) { + debug!("Dispatching network event: {:?}", action); + if let Some(network_tx) = &self.network_tx { if let Err(e) = network_tx.send(action).await { self.is_loading = false; @@ -115,6 +118,7 @@ impl Default for App { navigation_stack: vec![DEFAULT_ROUTE], network_tx: None, error: HorizontallyScrollableText::default(), + response: String::default(), server_tabs: TabState::new(vec![ TabRoute { title: "Radarr".to_owned(), @@ -260,11 +264,11 @@ mod tests { app.handle_error(anyhow!(test_string)); - assert_eq!(app.error.stationary_style(), test_string); + assert_eq!(app.error.text, test_string); app.handle_error(anyhow!("Testing a different error")); - assert_eq!(app.error.stationary_style(), test_string); + assert_eq!(app.error.text, test_string); } #[tokio::test] diff --git a/src/app/radarr.rs b/src/app/radarr.rs index faf0b61..132ac3b 100644 --- a/src/app/radarr.rs +++ b/src/app/radarr.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::time::Duration; +use bimap::BiMap; use chrono::{DateTime, Utc}; use strum::IntoEnumIterator; @@ -9,7 +10,9 @@ use crate::models::radarr_models::{ AddMovieSearchResult, Collection, CollectionMovie, Credit, DiskSpace, DownloadRecord, MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release, ReleaseField, RootFolder, }; -use crate::models::{ScrollableText, StatefulList, StatefulTable, TabRoute, TabState}; +use crate::models::{ + HorizontallyScrollableText, ScrollableText, StatefulList, StatefulTable, TabRoute, TabState, +}; use crate::network::radarr_network::RadarrEvent; pub struct RadarrData { @@ -26,6 +29,7 @@ pub struct RadarrData { pub selected_block: ActiveRadarrBlock, pub downloads: StatefulTable, pub quality_profile_map: HashMap, + pub tags_map: BiMap, pub movie_details: ScrollableText, pub file_details: String, pub audio_details: String, @@ -41,10 +45,10 @@ pub struct RadarrData { pub prompt_confirm_action: Option, pub main_tabs: TabState, pub movie_info_tabs: TabState, - pub search: String, - pub filter: String, - pub edit_path: String, - pub edit_tags: String, + pub search: HorizontallyScrollableText, + pub filter: HorizontallyScrollableText, + pub edit_path: HorizontallyScrollableText, + pub edit_tags: HorizontallyScrollableText, pub edit_monitored: Option, pub sort_ascending: Option, pub prompt_confirm: bool, @@ -59,8 +63,8 @@ impl RadarrData { pub fn reset_search(&mut self) { self.is_searching = false; - self.search = String::default(); - self.filter = String::default(); + self.search = HorizontallyScrollableText::default(); + self.filter = HorizontallyScrollableText::default(); self.filtered_movies = StatefulTable::default(); self.filtered_collections = StatefulTable::default(); self.add_searched_movies = StatefulTable::default(); @@ -68,15 +72,15 @@ impl RadarrData { pub fn reset_filter(&mut self) { self.is_filtering = false; - self.filter = String::default(); + self.filter = HorizontallyScrollableText::default(); self.filtered_movies = StatefulTable::default(); self.filtered_collections = StatefulTable::default(); } pub fn reset_edit_movie(&mut self) { self.edit_monitored = None; - self.edit_path = String::default(); - self.edit_tags = String::default(); + self.edit_path = HorizontallyScrollableText::default(); + self.edit_tags = HorizontallyScrollableText::default(); self.reset_movie_preferences_selections(); } @@ -119,24 +123,37 @@ impl RadarrData { self.populate_movie_preferences_lists(); let Movie { path, + tags, monitored, minimum_availability, quality_profile_id, .. } = if self.filtered_movies.items.is_empty() { - self.movies.current_selection_clone() + self.movies.current_selection() } else { - self.filtered_movies.current_selection_clone() + self.filtered_movies.current_selection() }; - self.edit_path = path; - self.edit_monitored = Some(monitored); + self.edit_path = path.clone().into(); + self.edit_tags = tags + .iter() + .map(|tag_id| { + self + .tags_map + .get_by_left(&tag_id.as_u64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", ") + .into(); + self.edit_monitored = Some(*monitored); let minimum_availability_index = self .movie_minimum_availability_list .items .iter() - .position(|&ma| ma == minimum_availability); + .position(|ma| ma == minimum_availability); self .movie_minimum_availability_list .state @@ -174,6 +191,7 @@ impl Default for RadarrData { filtered_movies: StatefulTable::default(), downloads: StatefulTable::default(), quality_profile_map: HashMap::default(), + tags_map: BiMap::default(), file_details: String::default(), audio_details: String::default(), video_details: String::default(), @@ -187,10 +205,10 @@ impl Default for RadarrData { filtered_collections: StatefulTable::default(), collection_movies: StatefulTable::default(), prompt_confirm_action: None, - search: String::default(), - filter: String::default(), - edit_path: String::default(), - edit_tags: String::default(), + search: HorizontallyScrollableText::default(), + filter: HorizontallyScrollableText::default(), + edit_path: HorizontallyScrollableText::default(), + edit_tags: HorizontallyScrollableText::default(), edit_monitored: None, sort_ascending: None, is_searching: false, @@ -519,6 +537,9 @@ impl App { self .dispatch_network_event(RadarrEvent::GetQualityProfiles.into()) .await; + self + .dispatch_network_event(RadarrEvent::GetTags.into()) + .await; self .dispatch_network_event(RadarrEvent::GetRootFolders.into()) .await; @@ -538,6 +559,12 @@ impl App { .unwrap_or_else(|| Duration::from_secs(0)) .is_zero() { + self + .dispatch_network_event(RadarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(RadarrEvent::GetTags.into()) + .await; self.dispatch_by_radarr_block(&active_radarr_block).await; } } @@ -548,7 +575,8 @@ impl App { .data .radarr_data .filtered_collections - .current_selection_clone() + .current_selection() + .clone() .movies .unwrap_or_default() } else { @@ -556,7 +584,8 @@ impl App { .data .radarr_data .collections - .current_selection_clone() + .current_selection() + .clone() .movies .unwrap_or_default() }; @@ -582,10 +611,10 @@ pub mod radarr_test_utils { let mut radarr_data = RadarrData { is_searching: true, is_filtering: true, - search: "test search".to_owned(), - filter: "test filter".to_owned(), - edit_path: "test path".to_owned(), - edit_tags: "test tag".to_owned(), + search: "test search".to_owned().into(), + filter: "test filter".to_owned().into(), + edit_path: "test path".to_owned().into(), + edit_tags: "usenet, test".to_owned().into(), edit_monitored: Some(true), file_details: "test file details".to_owned(), audio_details: "test audio details".to_owned(), @@ -642,8 +671,8 @@ pub mod radarr_test_utils { macro_rules! assert_search_reset { ($radarr_data:expr) => { assert!(!$radarr_data.is_searching); - assert!($radarr_data.search.is_empty()); - assert!($radarr_data.filter.is_empty()); + assert!($radarr_data.search.text.is_empty()); + assert!($radarr_data.filter.text.is_empty()); assert!($radarr_data.filtered_movies.items.is_empty()); assert!($radarr_data.filtered_collections.items.is_empty()); assert!($radarr_data.add_searched_movies.items.is_empty()); @@ -654,8 +683,8 @@ pub mod radarr_test_utils { macro_rules! assert_edit_movie_reset { ($radarr_data:expr) => { assert!($radarr_data.edit_monitored.is_none()); - assert!($radarr_data.edit_path.is_empty()); - assert!($radarr_data.edit_tags.is_empty()); + assert!($radarr_data.edit_path.text.is_empty()); + assert!($radarr_data.edit_tags.text.is_empty()); }; } @@ -663,7 +692,7 @@ pub mod radarr_test_utils { macro_rules! assert_filter_reset { ($radarr_data:expr) => { assert!(!$radarr_data.is_filtering); - assert!($radarr_data.filter.is_empty()); + assert!($radarr_data.filter.text.is_empty()); assert!($radarr_data.filtered_movies.items.is_empty()); assert!($radarr_data.filtered_collections.items.is_empty()); }; @@ -704,7 +733,8 @@ mod tests { mod radarr_data_tests { use std::collections::HashMap; - use pretty_assertions::assert_eq; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::Number; use strum::IntoEnumIterator; @@ -712,7 +742,7 @@ mod tests { use crate::app::radarr::radarr_test_utils::create_test_radarr_data; use crate::app::radarr::{ActiveRadarrBlock, RadarrData}; use crate::models::radarr_models::{MinimumAvailability, Monitor, Movie}; - use crate::models::{Route, StatefulTable}; + use crate::models::{HorizontallyScrollableText, Route, StatefulTable}; #[test] fn test_from_tuple_to_route_with_context() { @@ -768,8 +798,8 @@ mod tests { fn test_reset_edit_movie() { let mut radarr_data = RadarrData { edit_monitored: Some(true), - edit_path: "test path".to_owned(), - edit_tags: "test tag".to_owned(), + edit_path: "test path".to_owned().into(), + edit_tags: "test tag".to_owned().into(), ..RadarrData::default() }; @@ -816,13 +846,14 @@ mod tests { #[rstest] fn test_populate_edit_movie_fields(#[values(true, false)] test_filtered_movies: bool) { let mut radarr_data = RadarrData { - edit_path: String::default(), - edit_tags: String::default(), + edit_path: HorizontallyScrollableText::default(), + edit_tags: HorizontallyScrollableText::default(), edit_monitored: None, quality_profile_map: HashMap::from([ (2222, "HD - 1080p".to_owned()), (1111, "Any".to_owned()), ]), + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), filtered_movies: StatefulTable::default(), ..create_test_radarr_data() }; @@ -831,6 +862,7 @@ mod tests { monitored: true, quality_profile_id: Number::from(2222), minimum_availability: MinimumAvailability::Released, + tags: vec![Number::from(1), Number::from(2)], ..Movie::default() }; @@ -856,11 +888,12 @@ mod tests { radarr_data.movie_quality_profile_list.items, vec!["Any".to_owned(), "HD - 1080p".to_owned()] ); - assert_eq!( + assert_str_eq!( radarr_data.movie_quality_profile_list.current_selection(), "HD - 1080p" ); - assert_eq!(radarr_data.edit_path, "/nfs/movies/Test".to_owned()); + assert_str_eq!(radarr_data.edit_path.text, "/nfs/movies/Test"); + assert_str_eq!(radarr_data.edit_tags.text, "usenet, test"); assert_eq!(radarr_data.edit_monitored, Some(true)); } } @@ -1325,6 +1358,10 @@ mod tests { sync_network_rx.recv().await.unwrap(), RadarrEvent::GetQualityProfiles.into() ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetTags.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetRootFolders.into() @@ -1365,6 +1402,14 @@ mod tests { .radarr_on_tick(ActiveRadarrBlock::Downloads, false) .await; + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetTags.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetDownloads.into() @@ -1382,6 +1427,14 @@ mod tests { .radarr_on_tick(ActiveRadarrBlock::Downloads, false) .await; + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + sync_network_rx.recv().await.unwrap(), + RadarrEvent::GetTags.into() + ); assert_eq!( sync_network_rx.recv().await.unwrap(), RadarrEvent::GetDownloads.into() diff --git a/src/event/input_event.rs b/src/event/input_event.rs index 6e406f4..da6b7e4 100644 --- a/src/event/input_event.rs +++ b/src/event/input_event.rs @@ -20,7 +20,7 @@ pub struct Events { impl Events { pub fn new() -> Self { let (tx, rx) = mpsc::channel(); - let tick_rate: Duration = Duration::from_millis(250); + let tick_rate: Duration = Duration::from_millis(50); thread::spawn(move || { let mut last_tick = Instant::now(); diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 0d16983..4d16b63 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -65,6 +65,21 @@ fn handle_prompt_toggle(app: &mut App, key: &Key) { } } +#[macro_export] +macro_rules! handle_text_box_left_right_keys { + ($self:expr, $key:expr, $input:expr) => { + match $self.key { + _ if *$key == DEFAULT_KEYBINDINGS.left.key => { + $input.scroll_left(); + } + _ if *$key == DEFAULT_KEYBINDINGS.right.key => { + $input.scroll_right(); + } + _ => (), + } + }; +} + #[macro_export] macro_rules! handle_text_box_keys { ($self:expr, $key:expr, $input:expr) => { @@ -221,6 +236,27 @@ mod test_utils { ); } }; + ($func:ident, $handler:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident) => { + #[rstest] + fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { + let mut app = App::default(); + app.data.radarr_data.$data_ref.set_items($items); + + $handler::with(&key, &mut app, &$block, &$context).handle(); + + assert_str_eq!( + app.data.radarr_data.$data_ref.current_selection().$field, + "Test 2" + ); + + $handler::with(&key, &mut app, &$block, &$context).handle(); + + assert_str_eq!( + app.data.radarr_data.$data_ref.current_selection().$field, + "Test 1" + ); + } + }; ($func:ident, $handler:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident, $conversion_fn:ident) => { #[rstest] fn $func(#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key) { @@ -338,6 +374,27 @@ mod test_utils { ); } }; + ($func:ident, $handler:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident) => { + #[test] + fn $func() { + let mut app = App::default(); + app.data.radarr_data.$data_ref.set_items($items); + + $handler::with(&DEFAULT_KEYBINDINGS.end.key, &mut app, &$block, &$context).handle(); + + assert_str_eq!( + app.data.radarr_data.$data_ref.current_selection().$field, + "Test 3" + ); + + $handler::with(&DEFAULT_KEYBINDINGS.home.key, &mut app, &$block, &$context).handle(); + + assert_str_eq!( + app.data.radarr_data.$data_ref.current_selection().$field, + "Test 1" + ); + } + }; ($func:ident, $handler:ident, $data_ref:ident, $items:expr, $block:expr, $context:expr, $field:ident, $conversion_fn:ident) => { #[test] fn $func() { @@ -403,6 +460,38 @@ mod test_utils { }; } + #[macro_export] + macro_rules! test_text_box_home_end_keys { + ($handler:ident, $block:expr, $field:ident) => { + let mut app = App::default(); + app.data.radarr_data.$field = "Test".to_owned().into(); + + $handler::with(&DEFAULT_KEYBINDINGS.home.key, &mut app, &$block, &None).handle(); + + assert_eq!(*app.data.radarr_data.$field.offset.borrow(), 4); + + $handler::with(&DEFAULT_KEYBINDINGS.end.key, &mut app, &$block, &None).handle(); + + assert_eq!(*app.data.radarr_data.$field.offset.borrow(), 0); + }; + } + + #[macro_export] + macro_rules! test_text_box_left_right_keys { + ($handler:ident, $block:expr, $field:ident) => { + let mut app = App::default(); + app.data.radarr_data.$field = "Test".to_owned().into(); + + $handler::with(&DEFAULT_KEYBINDINGS.left.key, &mut app, &$block, &None).handle(); + + assert_eq!(*app.data.radarr_data.$field.offset.borrow(), 1); + + $handler::with(&DEFAULT_KEYBINDINGS.right.key, &mut app, &$block, &None).handle(); + + assert_eq!(*app.data.radarr_data.$field.offset.borrow(), 0); + }; + } + #[macro_export] macro_rules! test_handler_delegation { ($base:expr, $active_block:expr) => { diff --git a/src/handlers/radarr_handlers/add_movie_handler.rs b/src/handlers/radarr_handlers/add_movie_handler.rs index d309425..d724550 100644 --- a/src/handlers/radarr_handlers/add_movie_handler.rs +++ b/src/handlers/radarr_handlers/add_movie_handler.rs @@ -3,7 +3,7 @@ use crate::app::radarr::ActiveRadarrBlock; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::{Scrollable, StatefulTable}; use crate::network::radarr_network::RadarrEvent; -use crate::{handle_text_box_keys, App, Key}; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys, App, Key}; pub(super) struct AddMovieHandler<'a> { key: &'a Key, @@ -119,6 +119,7 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for AddMovieHandler<'a> { .radarr_data .movie_quality_profile_list .scroll_to_top(), + ActiveRadarrBlock::AddMovieSearchInput => self.app.data.radarr_data.search.scroll_home(), _ => (), } } @@ -149,6 +150,7 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for AddMovieHandler<'a> { .radarr_data .movie_quality_profile_list .scroll_to_bottom(), + ActiveRadarrBlock::AddMovieSearchInput => self.app.data.radarr_data.search.reset_offset(), _ => (), } } @@ -156,8 +158,12 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for AddMovieHandler<'a> { fn handle_delete(&mut self) {} fn handle_left_right_action(&mut self) { - if let ActiveRadarrBlock::AddMoviePrompt = self.active_radarr_block { - handle_prompt_toggle(self.app, self.key) + match self.active_radarr_block { + ActiveRadarrBlock::AddMoviePrompt => handle_prompt_toggle(self.app, self.key), + ActiveRadarrBlock::AddMovieSearchInput => { + handle_text_box_left_right_keys!(self, self.key, self.app.data.radarr_data.search) + } + _ => (), } } @@ -298,7 +304,7 @@ mod tests { ActiveRadarrBlock::AddMovieSearchResults, None, title, - stationary_style + to_string ); test_enum_scroll!( @@ -349,10 +355,12 @@ mod tests { } mod test_handle_home_end { + use rstest::rstest; use strum::IntoEnumIterator; use crate::{ extended_stateful_iterable_vec, test_enum_home_and_end, test_iterable_home_and_end, + test_text_box_home_end_keys, }; use super::*; @@ -365,7 +373,7 @@ mod tests { ActiveRadarrBlock::AddMovieSearchResults, None, title, - stationary_style + to_string ); test_enum_home_and_end!( @@ -393,11 +401,22 @@ mod tests { ActiveRadarrBlock::AddMovieSelectQualityProfile, None ); + + #[test] + fn test_add_movie_search_input_home_end_keys() { + test_text_box_home_end_keys!( + AddMovieHandler, + ActiveRadarrBlock::AddMovieSearchInput, + search + ); + } } mod test_handle_left_right_action { use rstest::rstest; + use crate::test_text_box_left_right_keys; + use super::*; #[rstest] @@ -412,6 +431,15 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); } + + #[test] + fn test_add_movie_search_input_left_right_keys() { + test_text_box_left_right_keys!( + AddMovieHandler, + ActiveRadarrBlock::AddMovieSearchInput, + search + ); + } } mod test_handle_submit { @@ -805,7 +833,7 @@ mod tests { #[test] fn test_add_movie_search_input_backspace() { let mut app = App::default(); - app.data.radarr_data.search = "Test".to_owned(); + app.data.radarr_data.search = "Test".to_owned().into(); AddMovieHandler::with( &DEFAULT_KEYBINDINGS.backspace.key, @@ -815,7 +843,7 @@ mod tests { ) .handle(); - assert_str_eq!(app.data.radarr_data.search, "Tes"); + assert_str_eq!(app.data.radarr_data.search.text, "Tes"); } #[test] @@ -830,7 +858,7 @@ mod tests { ) .handle(); - assert_str_eq!(app.data.radarr_data.search, "h"); + assert_str_eq!(app.data.radarr_data.search.text, "h"); } } } diff --git a/src/handlers/radarr_handlers/collection_details_handler.rs b/src/handlers/radarr_handlers/collection_details_handler.rs index 3f9c125..c7cdc7a 100644 --- a/src/handlers/radarr_handlers/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collection_details_handler.rs @@ -141,7 +141,7 @@ mod tests { ActiveRadarrBlock::CollectionDetails, None, title, - stationary_style + to_string ); } @@ -158,7 +158,7 @@ mod tests { ActiveRadarrBlock::CollectionDetails, None, title, - stationary_style + to_string ); } diff --git a/src/handlers/radarr_handlers/edit_movie_handler.rs b/src/handlers/radarr_handlers/edit_movie_handler.rs index 41e722b..81aa689 100644 --- a/src/handlers/radarr_handlers/edit_movie_handler.rs +++ b/src/handlers/radarr_handlers/edit_movie_handler.rs @@ -2,10 +2,10 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::radarr::ActiveRadarrBlock; use crate::app::App; use crate::event::Key; -use crate::handle_text_box_keys; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::Scrollable; use crate::network::radarr_network::RadarrEvent; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys}; pub(super) struct EditMovieHandler<'a> { key: &'a Key, @@ -100,6 +100,8 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for EditMovieHandler<'a> { .radarr_data .movie_quality_profile_list .scroll_to_top(), + ActiveRadarrBlock::EditMoviePathInput => self.app.data.radarr_data.edit_path.scroll_home(), + ActiveRadarrBlock::EditMovieTagsInput => self.app.data.radarr_data.edit_tags.scroll_home(), _ => (), } } @@ -118,6 +120,8 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for EditMovieHandler<'a> { .radarr_data .movie_quality_profile_list .scroll_to_bottom(), + ActiveRadarrBlock::EditMoviePathInput => self.app.data.radarr_data.edit_path.reset_offset(), + ActiveRadarrBlock::EditMovieTagsInput => self.app.data.radarr_data.edit_tags.reset_offset(), _ => (), } } @@ -125,8 +129,15 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for EditMovieHandler<'a> { fn handle_delete(&mut self) {} fn handle_left_right_action(&mut self) { - if let ActiveRadarrBlock::EditMoviePrompt = self.active_radarr_block { - handle_prompt_toggle(self.app, self.key) + match self.active_radarr_block { + ActiveRadarrBlock::EditMoviePrompt => handle_prompt_toggle(self.app, self.key), + ActiveRadarrBlock::EditMoviePathInput => { + handle_text_box_left_right_keys!(self, self.key, self.app.data.radarr_data.edit_path) + } + ActiveRadarrBlock::EditMovieTagsInput => { + handle_text_box_left_right_keys!(self, self.key, self.app.data.radarr_data.edit_tags) + } + _ => (), } } @@ -262,7 +273,7 @@ mod tests { mod test_handle_home_end { use strum::IntoEnumIterator; - use crate::{test_enum_home_and_end, test_iterable_home_and_end}; + use crate::{test_enum_home_and_end, test_iterable_home_and_end, test_text_box_home_end_keys}; use super::*; @@ -282,11 +293,31 @@ mod tests { ActiveRadarrBlock::EditMovieSelectQualityProfile, None ); + + #[test] + fn test_edit_movie_path_input_home_end_keys() { + test_text_box_home_end_keys!( + EditMovieHandler, + ActiveRadarrBlock::EditMoviePathInput, + edit_path + ); + } + + #[test] + fn test_edit_movie_tags_input_home_end_keys() { + test_text_box_home_end_keys!( + EditMovieHandler, + ActiveRadarrBlock::EditMovieTagsInput, + edit_tags + ); + } } mod test_handle_left_right_action { use rstest::rstest; + use crate::{test_text_box_home_end_keys, test_text_box_left_right_keys}; + use super::*; #[rstest] @@ -301,6 +332,24 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); } + + #[test] + fn test_edit_movie_path_input_left_right_keys() { + test_text_box_left_right_keys!( + EditMovieHandler, + ActiveRadarrBlock::EditMoviePathInput, + edit_path + ); + } + + #[test] + fn test_edit_movie_tags_input_left_right_keys() { + test_text_box_left_right_keys!( + EditMovieHandler, + ActiveRadarrBlock::EditMovieTagsInput, + edit_tags + ); + } } mod test_handle_submit { @@ -321,7 +370,7 @@ mod tests { fn test_edit_movie_path_input_submit() { let mut app = App::default(); app.should_ignore_quit_key = true; - app.data.radarr_data.edit_path = "Test Path".to_owned(); + app.data.radarr_data.edit_path = "Test Path".to_owned().into(); app.push_navigation_stack(ActiveRadarrBlock::EditMoviePrompt.into()); app.push_navigation_stack(ActiveRadarrBlock::EditMoviePathInput.into()); @@ -334,7 +383,7 @@ mod tests { .handle(); assert!(!app.should_ignore_quit_key); - assert!(!app.data.radarr_data.edit_path.is_empty()); + assert!(!app.data.radarr_data.edit_path.text.is_empty()); assert_eq!( app.get_current_route(), &ActiveRadarrBlock::EditMoviePrompt.into() @@ -345,7 +394,7 @@ mod tests { fn test_edit_movie_tags_input_submit() { let mut app = App::default(); app.should_ignore_quit_key = true; - app.data.radarr_data.edit_tags = "Test Tags".to_owned(); + app.data.radarr_data.edit_tags = "Test Tags".to_owned().into(); app.push_navigation_stack(ActiveRadarrBlock::EditMoviePrompt.into()); app.push_navigation_stack(ActiveRadarrBlock::EditMoviePathInput.into()); @@ -358,7 +407,7 @@ mod tests { .handle(); assert!(!app.should_ignore_quit_key); - assert!(!app.data.radarr_data.edit_tags.is_empty()); + assert!(!app.data.radarr_data.edit_tags.text.is_empty()); assert_eq!( app.get_current_route(), &ActiveRadarrBlock::EditMoviePrompt.into() @@ -599,7 +648,7 @@ mod tests { #[test] fn test_edit_movie_path_input_backspace() { let mut app = App::default(); - app.data.radarr_data.edit_path = "Test".to_owned(); + app.data.radarr_data.edit_path = "Test".to_owned().into(); EditMovieHandler::with( &DEFAULT_KEYBINDINGS.backspace.key, @@ -609,13 +658,13 @@ mod tests { ) .handle(); - assert_str_eq!(app.data.radarr_data.edit_path, "Tes"); + assert_str_eq!(app.data.radarr_data.edit_path.text, "Tes"); } #[test] fn test_edit_movie_tags_input_backspace() { let mut app = App::default(); - app.data.radarr_data.edit_tags = "Test".to_owned(); + app.data.radarr_data.edit_tags = "Test".to_owned().into(); EditMovieHandler::with( &DEFAULT_KEYBINDINGS.backspace.key, @@ -625,7 +674,7 @@ mod tests { ) .handle(); - assert_str_eq!(app.data.radarr_data.edit_tags, "Tes"); + assert_str_eq!(app.data.radarr_data.edit_tags.text, "Tes"); } #[test] @@ -640,7 +689,7 @@ mod tests { ) .handle(); - assert_str_eq!(app.data.radarr_data.edit_path, "h"); + assert_str_eq!(app.data.radarr_data.edit_path.text, "h"); } #[test] @@ -655,7 +704,7 @@ mod tests { ) .handle(); - assert_str_eq!(app.data.radarr_data.edit_tags, "h"); + assert_str_eq!(app.data.radarr_data.edit_tags.text, "h"); } } } diff --git a/src/handlers/radarr_handlers/mod.rs b/src/handlers/radarr_handlers/mod.rs index 0ffae8a..5daa12a 100644 --- a/src/handlers/radarr_handlers/mod.rs +++ b/src/handlers/radarr_handlers/mod.rs @@ -11,7 +11,7 @@ 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::{handle_text_box_keys, App, Key}; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys, App, Key}; mod add_movie_handler; mod collection_details_handler; @@ -149,6 +149,12 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> { } } ActiveRadarrBlock::Downloads => self.app.data.radarr_data.downloads.scroll_to_top(), + ActiveRadarrBlock::SearchMovie | ActiveRadarrBlock::SearchCollection => { + self.app.data.radarr_data.search.scroll_home() + } + ActiveRadarrBlock::FilterMovies | ActiveRadarrBlock::FilterCollections => { + self.app.data.radarr_data.filter.scroll_home() + } _ => (), } } @@ -182,6 +188,12 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> { } } ActiveRadarrBlock::Downloads => self.app.data.radarr_data.downloads.scroll_to_bottom(), + ActiveRadarrBlock::SearchMovie | ActiveRadarrBlock::SearchCollection => { + self.app.data.radarr_data.search.reset_offset() + } + ActiveRadarrBlock::FilterMovies | ActiveRadarrBlock::FilterCollections => { + self.app.data.radarr_data.filter.reset_offset() + } _ => (), } } @@ -222,6 +234,12 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> { | ActiveRadarrBlock::RefreshAllMoviesPrompt | ActiveRadarrBlock::RefreshAllCollectionsPrompt | ActiveRadarrBlock::RefreshDownloadsPrompt => handle_prompt_toggle(self.app, self.key), + ActiveRadarrBlock::SearchMovie | ActiveRadarrBlock::SearchCollection => { + handle_text_box_left_right_keys!(self, self.key, self.app.data.radarr_data.search) + } + ActiveRadarrBlock::FilterMovies | ActiveRadarrBlock::FilterCollections => { + handle_text_box_left_right_keys!(self, self.key, self.app.data.radarr_data.filter) + } _ => (), } } @@ -476,14 +494,7 @@ impl RadarrHandler<'_> { where F: Fn(&T) -> &str, { - let search_string = self - .app - .data - .radarr_data - .search - .drain(..) - .collect::() - .to_lowercase(); + let search_string = self.app.data.radarr_data.search.drain().to_lowercase(); let search_index = rows.iter().position(|item| { strip_non_alphanumeric_characters(field_selection_fn(item)).contains(&search_string) }); @@ -503,15 +514,7 @@ impl RadarrHandler<'_> { F: Fn(&T) -> &str, T: Clone, { - let filter = strip_non_alphanumeric_characters( - &self - .app - .data - .radarr_data - .filter - .drain(..) - .collect::(), - ); + let filter = strip_non_alphanumeric_characters(&self.app.data.radarr_data.filter.drain()); let filter_matches: Vec = rows .iter() .filter(|&item| strip_non_alphanumeric_characters(field_selection_fn(item)).contains(&filter)) @@ -537,21 +540,23 @@ mod radarr_handler_test_utils { ($handler:ident, $block:expr, $context:expr) => { let mut app = App::default(); let mut radarr_data = RadarrData { - edit_path: String::default(), - edit_tags: String::default(), + edit_path: HorizontallyScrollableText::default(), + edit_tags: HorizontallyScrollableText::default(), edit_monitored: None, quality_profile_map: HashMap::from([ (2222, "HD - 1080p".to_owned()), (1111, "Any".to_owned()), ]), + tags_map: BiMap::from_iter([(1, "test".to_owned())]), filtered_movies: StatefulTable::default(), ..create_test_radarr_data() }; radarr_data.movies.set_items(vec![Movie { - path: "/nfs/movies/Test".to_owned(), + path: "/nfs/movies/Test".to_owned().into(), monitored: true, quality_profile_id: Number::from(2222), minimum_availability: MinimumAvailability::Released, + tags: vec![Number::from(1)], ..Movie::default() }]); app.data.radarr_data = radarr_data; @@ -582,7 +587,7 @@ mod radarr_handler_test_utils { app.data.radarr_data.movie_quality_profile_list.items, vec!["Any".to_owned(), "HD - 1080p".to_owned()] ); - assert_eq!( + assert_str_eq!( app .data .radarr_data @@ -590,10 +595,8 @@ mod radarr_handler_test_utils { .current_selection(), "HD - 1080p" ); - assert_eq!( - app.data.radarr_data.edit_path, - "/nfs/movies/Test".to_owned() - ); + assert_str_eq!(app.data.radarr_data.edit_path.text, "/nfs/movies/Test"); + assert_str_eq!(app.data.radarr_data.edit_tags.text, "test"); assert_eq!(app.data.radarr_data.edit_monitored, Some(true)); }; } @@ -673,8 +676,12 @@ mod tests { } mod test_handle_home_end { + use pretty_assertions::assert_eq; + use crate::models::radarr_models::DownloadRecord; - use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + use crate::{ + extended_stateful_iterable_vec, test_iterable_home_and_end, test_text_box_home_end_keys, + }; use super::*; @@ -727,6 +734,22 @@ mod tests { None, title ); + + #[rstest] + fn test_search_boxes_home_end_keys( + #[values(ActiveRadarrBlock::SearchMovie, ActiveRadarrBlock::SearchCollection)] + active_radarr_block: ActiveRadarrBlock, + ) { + test_text_box_home_end_keys!(RadarrHandler, active_radarr_block, search); + } + + #[rstest] + fn test_filter_boxes_home_end_keys( + #[values(ActiveRadarrBlock::FilterMovies, ActiveRadarrBlock::FilterCollections)] + active_radarr_block: ActiveRadarrBlock, + ) { + test_text_box_home_end_keys!(RadarrHandler, active_radarr_block, filter); + } } mod test_handle_delete { @@ -765,6 +788,8 @@ mod tests { use pretty_assertions::assert_eq; use rstest::rstest; + use crate::test_text_box_left_right_keys; + use super::*; #[rstest] @@ -843,6 +868,22 @@ mod tests { assert!(!app.data.radarr_data.prompt_confirm); } + + #[rstest] + fn test_search_boxes_left_right_keys( + #[values(ActiveRadarrBlock::SearchMovie, ActiveRadarrBlock::SearchCollection)] + active_radarr_block: ActiveRadarrBlock, + ) { + test_text_box_left_right_keys!(RadarrHandler, active_radarr_block, search); + } + + #[rstest] + fn test_filter_boxes_left_right_keys( + #[values(ActiveRadarrBlock::FilterMovies, ActiveRadarrBlock::FilterCollections)] + active_radarr_block: ActiveRadarrBlock, + ) { + test_text_box_left_right_keys!(RadarrHandler, active_radarr_block, filter); + } } mod test_handle_submit { @@ -877,7 +918,7 @@ mod tests { .radarr_data .movies .set_items(extended_stateful_iterable_vec!(Movie)); - app.data.radarr_data.search = "Test 2".to_owned(); + app.data.radarr_data.search = "Test 2".to_owned().into(); RadarrHandler::with( &SUBMIT_KEY, @@ -901,7 +942,7 @@ mod tests { .radarr_data .filtered_movies .set_items(extended_stateful_iterable_vec!(Movie)); - app.data.radarr_data.search = "Test 2".to_owned(); + app.data.radarr_data.search = "Test 2".to_owned().into(); RadarrHandler::with( &SUBMIT_KEY, @@ -930,7 +971,7 @@ mod tests { .radarr_data .collections .set_items(extended_stateful_iterable_vec!(Collection)); - app.data.radarr_data.search = "Test 2".to_owned(); + app.data.radarr_data.search = "Test 2".to_owned().into(); RadarrHandler::with( &SUBMIT_KEY, @@ -954,7 +995,7 @@ mod tests { .radarr_data .filtered_collections .set_items(extended_stateful_iterable_vec!(Collection)); - app.data.radarr_data.search = "Test 2".to_owned(); + app.data.radarr_data.search = "Test 2".to_owned().into(); RadarrHandler::with( &SUBMIT_KEY, @@ -983,7 +1024,7 @@ mod tests { .radarr_data .movies .set_items(extended_stateful_iterable_vec!(Movie)); - app.data.radarr_data.filter = "Test".to_owned(); + app.data.radarr_data.filter = "Test".to_owned().into(); RadarrHandler::with( &SUBMIT_KEY, @@ -1013,7 +1054,7 @@ mod tests { .radarr_data .collections .set_items(extended_stateful_iterable_vec!(Collection)); - app.data.radarr_data.filter = "Test".to_owned(); + app.data.radarr_data.filter = "Test".to_owned().into(); RadarrHandler::with( &SUBMIT_KEY, @@ -1210,7 +1251,8 @@ mod tests { mod test_handle_key_char { use std::collections::HashMap; - use pretty_assertions::assert_eq; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::Number; use strum::IntoEnumIterator; @@ -1219,6 +1261,7 @@ mod tests { use crate::app::radarr::radarr_test_utils::create_test_radarr_data; use crate::app::radarr::RadarrData; use crate::models::radarr_models::MinimumAvailability; + use crate::models::HorizontallyScrollableText; use crate::models::StatefulTable; use super::*; @@ -1328,7 +1371,7 @@ mod tests { active_radarr_block: ActiveRadarrBlock, ) { let mut app = App::default(); - app.data.radarr_data.search = "Test".to_owned(); + app.data.radarr_data.search = "Test".to_owned().into(); RadarrHandler::with( &DEFAULT_KEYBINDINGS.backspace.key, @@ -1338,7 +1381,7 @@ mod tests { ) .handle(); - assert_str_eq!(app.data.radarr_data.search, "Tes"); + assert_str_eq!(app.data.radarr_data.search.text, "Tes"); } #[rstest] @@ -1347,7 +1390,7 @@ mod tests { active_radarr_block: ActiveRadarrBlock, ) { let mut app = App::default(); - app.data.radarr_data.filter = "Test".to_owned(); + app.data.radarr_data.filter = "Test".to_owned().into(); RadarrHandler::with( &DEFAULT_KEYBINDINGS.backspace.key, @@ -1357,7 +1400,7 @@ mod tests { ) .handle(); - assert_str_eq!(app.data.radarr_data.filter, "Tes"); + assert_str_eq!(app.data.radarr_data.filter.text, "Tes"); } #[rstest] @@ -1369,7 +1412,7 @@ mod tests { RadarrHandler::with(&Key::Char('h'), &mut app, &active_radarr_block, &None).handle(); - assert_str_eq!(app.data.radarr_data.search, "h"); + assert_str_eq!(app.data.radarr_data.search.text, "h"); } #[rstest] @@ -1381,7 +1424,7 @@ mod tests { RadarrHandler::with(&Key::Char('h'), &mut app, &active_radarr_block, &None).handle(); - assert_str_eq!(app.data.radarr_data.filter, "h"); + assert_str_eq!(app.data.radarr_data.filter.text, "h"); } } @@ -1393,7 +1436,7 @@ mod tests { .radarr_data .movies .set_items(extended_stateful_iterable_vec!(Movie)); - app.data.radarr_data.search = "Test 2".to_owned(); + app.data.radarr_data.search = "Test 2".to_owned().into(); app.data.radarr_data.is_searching = true; app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); @@ -1412,7 +1455,7 @@ mod tests { assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); assert!(!app.data.radarr_data.is_searching); assert!(!app.should_ignore_quit_key); - assert!(app.data.radarr_data.search.is_empty()); + assert!(app.data.radarr_data.search.text.is_empty()); } #[test] @@ -1423,7 +1466,7 @@ mod tests { .radarr_data .movies .set_items(extended_stateful_iterable_vec!(Movie)); - app.data.radarr_data.search = "Test 5".to_owned(); + app.data.radarr_data.search = "Test 5".to_owned().into(); app.data.radarr_data.is_searching = true; app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveRadarrBlock::SearchMovie.into()); @@ -1445,7 +1488,7 @@ mod tests { ); assert!(!app.data.radarr_data.is_searching); assert!(!app.should_ignore_quit_key); - assert!(app.data.radarr_data.search.is_empty()); + assert!(app.data.radarr_data.search.text.is_empty()); } #[test] @@ -1456,7 +1499,7 @@ mod tests { .radarr_data .movies .set_items(extended_stateful_iterable_vec!(Movie)); - app.data.radarr_data.filter = "Test 2".to_owned(); + app.data.radarr_data.filter = "Test 2".to_owned().into(); app.data.radarr_data.is_searching = true; app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); @@ -1476,7 +1519,7 @@ mod tests { assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); assert!(!app.data.radarr_data.is_filtering); assert!(!app.should_ignore_quit_key); - assert!(app.data.radarr_data.filter.is_empty()); + assert!(app.data.radarr_data.filter.text.is_empty()); } #[test] @@ -1487,7 +1530,7 @@ mod tests { .radarr_data .movies .set_items(extended_stateful_iterable_vec!(Movie)); - app.data.radarr_data.filter = "Test 5".to_owned(); + app.data.radarr_data.filter = "Test 5".to_owned().into(); app.data.radarr_data.is_filtering = true; app.should_ignore_quit_key = true; app.push_navigation_stack(ActiveRadarrBlock::FilterMovies.into()); @@ -1509,7 +1552,7 @@ mod tests { ); assert!(!app.data.radarr_data.is_searching); assert!(!app.should_ignore_quit_key); - assert!(app.data.radarr_data.filter.is_empty()); + assert!(app.data.radarr_data.filter.text.is_empty()); } #[rstest] diff --git a/src/handlers/radarr_handlers/movie_details_handler.rs b/src/handlers/radarr_handlers/movie_details_handler.rs index 8c120db..0eda5c8 100644 --- a/src/handlers/radarr_handlers/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/movie_details_handler.rs @@ -271,12 +271,7 @@ fn sort_releases_by_selected_field( ReleaseField::Source => |release_a, release_b| release_a.protocol.cmp(&release_b.protocol), ReleaseField::Age => |release_a, release_b| release_a.age.as_u64().cmp(&release_b.age.as_u64()), ReleaseField::Rejected => |release_a, release_b| release_a.rejected.cmp(&release_b.rejected), - ReleaseField::Title => |release_a, release_b| { - release_a - .title - .stationary_style() - .cmp(&release_b.title.stationary_style()) - }, + ReleaseField::Title => |release_a, release_b| release_a.title.text.cmp(&release_b.title.text), ReleaseField::Indexer => |release_a, release_b| release_a.indexer.cmp(&release_b.indexer), ReleaseField::Size => |release_a, release_b| { release_a @@ -394,7 +389,7 @@ mod tests { ActiveRadarrBlock::MovieHistory, None, source_title, - stationary_style + to_string ); test_iterable_scroll!( @@ -427,7 +422,7 @@ mod tests { ActiveRadarrBlock::ManualSearch, None, title, - stationary_style + to_string ); test_enum_scroll!( @@ -484,7 +479,7 @@ mod tests { ActiveRadarrBlock::MovieHistory, None, source_title, - stationary_style + to_string ); test_iterable_home_and_end!( @@ -517,7 +512,7 @@ mod tests { ActiveRadarrBlock::ManualSearch, None, title, - stationary_style + to_string ); test_enum_home_and_end!( @@ -717,11 +712,13 @@ mod tests { } mod test_handle_esc { - use pretty_assertions::assert_eq; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use crate::app::radarr::radarr_test_utils::create_test_radarr_data; use crate::assert_movie_info_tabs_reset; + use crate::models::HorizontallyScrollableText; use super::*; @@ -775,7 +772,8 @@ mod tests { mod test_handle_key_char { use std::collections::HashMap; - use pretty_assertions::assert_eq; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use strum::IntoEnumIterator; @@ -783,6 +781,7 @@ mod tests { use crate::app::radarr::RadarrData; use crate::handlers::radarr_handlers::RadarrHandler; use crate::models::radarr_models::{MinimumAvailability, Movie}; + use crate::models::HorizontallyScrollableText; use crate::models::StatefulTable; use crate::test_edit_movie_key; diff --git a/src/models/mod.rs b/src/models/mod.rs index 3973f21..2a43f38 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -101,10 +101,6 @@ macro_rules! stateful_iterable { pub fn current_selection(&self) -> &T { &self.items[self.state.selected().unwrap_or(0)] } - - pub fn current_selection_clone(&self) -> T { - self.items[self.state.selected().unwrap_or(0)].clone() - } } }; } @@ -169,8 +165,8 @@ pub struct HorizontallyScrollableText { } impl From for HorizontallyScrollableText { - fn from(input: String) -> HorizontallyScrollableText { - HorizontallyScrollableText::new(input) + fn from(text: String) -> HorizontallyScrollableText { + HorizontallyScrollableText::new(text) } } @@ -193,32 +189,60 @@ impl Display for HorizontallyScrollableText { } impl HorizontallyScrollableText { - pub fn new(input: String) -> HorizontallyScrollableText { + pub fn new(text: String) -> HorizontallyScrollableText { HorizontallyScrollableText { - text: format!("{} ", input), + text, offset: RefCell::new(0), } } - pub fn scroll_text(&self) { - let new_offset = *self.offset.borrow() + 1; - *self.offset.borrow_mut() = new_offset % self.text.len(); + pub fn scroll_left(&self) { + if *self.offset.borrow() < self.text.len() { + let new_offset = *self.offset.borrow() + 1; + *self.offset.borrow_mut() = new_offset; + } + } + + pub fn scroll_right(&self) { + if *self.offset.borrow() > 0 { + let new_offset = *self.offset.borrow() - 1; + *self.offset.borrow_mut() = new_offset; + } + } + + pub fn scroll_home(&self) { + *self.offset.borrow_mut() = self.text.len(); } pub fn reset_offset(&self) { *self.offset.borrow_mut() = 0; } - pub fn scroll_or_reset(&self, width: usize, is_current_selection: bool) { - if is_current_selection && self.text.len().saturating_sub(4) > width { - self.scroll_text(); + pub fn scroll_left_or_reset(&self, width: usize, is_current_selection: bool) { + if is_current_selection && self.text.len() >= width && *self.offset.borrow() < self.text.len() { + self.scroll_left(); } else { self.reset_offset(); } } - pub fn stationary_style(&self) -> String { - self.text.clone().trim().to_owned() + pub fn drain(&mut self) -> String { + self.reset_offset(); + self.text.drain(..).collect() + } + + pub fn pop(&mut self) { + if *self.offset.borrow() < self.text.len() { + self + .text + .remove(self.text.len() - *self.offset.borrow() - 1); + } + } + + pub fn push(&mut self, character: char) { + self + .text + .insert(self.text.len() - *self.offset.borrow(), character); } } @@ -345,23 +369,6 @@ mod tests { assert_str_eq!(stateful_table.current_selection(), &stateful_table.items[1]); } - #[test] - fn test_stateful_table_current_selection_clone() { - let mut stateful_table = create_test_stateful_table(); - - assert_str_eq!( - stateful_table.current_selection_clone(), - stateful_table.items[0] - ); - - stateful_table.state.select(Some(1)); - - assert_str_eq!( - stateful_table.current_selection_clone(), - stateful_table.items[1] - ); - } - #[test] fn test_stateful_table_select_index() { let mut stateful_table = create_test_stateful_table(); @@ -444,10 +451,7 @@ mod tests { let horizontally_scrollable_text = HorizontallyScrollableText::from(test_text.to_owned()); assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); - assert_str_eq!( - horizontally_scrollable_text.text, - format!("{} ", test_text) - ); + assert_str_eq!(horizontally_scrollable_text.text, test_text); } #[test] @@ -455,10 +459,7 @@ mod tests { let test_text = "Test string"; let horizontally_scrollable_text = HorizontallyScrollableText::from(test_text.to_owned()); - assert_str_eq!( - horizontally_scrollable_text.to_string(), - format!("{} ", test_text) - ); + assert_str_eq!(horizontally_scrollable_text.to_string(), test_text); let horizontally_scrollable_text = HorizontallyScrollableText { text: test_text.to_owned(), @@ -481,29 +482,60 @@ mod tests { let horizontally_scrollable_text = HorizontallyScrollableText::new(test_text.to_owned()); assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); - assert_str_eq!( - horizontally_scrollable_text.text, - format!("{} ", test_text) - ); + assert_str_eq!(horizontally_scrollable_text.text, test_text); } #[test] - fn test_horizontally_scrollable_text_scroll_text() { + fn test_horizontally_scrollable_text_scroll_text_left() { let horizontally_scrollable_text = HorizontallyScrollableText::from("Test string".to_owned()); assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); - for i in 1..horizontally_scrollable_text.text.len() { - horizontally_scrollable_text.scroll_text(); + for i in 1..horizontally_scrollable_text.text.len() - 1 { + horizontally_scrollable_text.scroll_left(); assert_eq!(*horizontally_scrollable_text.offset.borrow(), i); } - horizontally_scrollable_text.scroll_text(); + horizontally_scrollable_text.scroll_left(); + + assert_eq!( + *horizontally_scrollable_text.offset.borrow(), + horizontally_scrollable_text.text.len() - 1 + ); + } + + #[test] + fn test_horizontally_scrollable_text_scroll_text_right() { + let horizontally_scrollable_text = HorizontallyScrollableText::from("Test string".to_owned()); + *horizontally_scrollable_text.offset.borrow_mut() = horizontally_scrollable_text.text.len(); + + for i in 1..horizontally_scrollable_text.text.len() { + horizontally_scrollable_text.scroll_right(); + + assert_eq!( + *horizontally_scrollable_text.offset.borrow(), + horizontally_scrollable_text.text.len() - i + ); + } + + horizontally_scrollable_text.scroll_right(); assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); } + #[test] + fn test_horizontally_scrollable_text_scroll_home() { + let horizontally_scrollable_text = HorizontallyScrollableText::from("Test string".to_owned()); + + horizontally_scrollable_text.scroll_home(); + + assert_eq!( + *horizontally_scrollable_text.offset.borrow(), + horizontally_scrollable_text.text.len() + ); + } + #[test] fn test_horizontally_scrollable_text_reset_offset() { let horizontally_scrollable_text = HorizontallyScrollableText { @@ -522,29 +554,83 @@ mod tests { let test_text = "Test string"; let horizontally_scrollable_text = HorizontallyScrollableText::from(test_text.to_owned()); - horizontally_scrollable_text.scroll_or_reset(width, true); + horizontally_scrollable_text.scroll_left_or_reset(width, true); assert_eq!(*horizontally_scrollable_text.offset.borrow(), 1); - horizontally_scrollable_text.scroll_or_reset(width, false); + horizontally_scrollable_text.scroll_left_or_reset(width, false); assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); - horizontally_scrollable_text.scroll_or_reset(width, true); + horizontally_scrollable_text.scroll_left_or_reset(width, true); assert_eq!(*horizontally_scrollable_text.offset.borrow(), 1); - horizontally_scrollable_text.scroll_or_reset(test_text.len(), false); + horizontally_scrollable_text.scroll_left_or_reset(test_text.len(), false); assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); } #[test] - fn test_horizontally_scrollable_text_stationary_style() { + fn test_horizontally_scrollable_text_drain() { let test_text = "Test string"; - let horizontally_scrollable_text = HorizontallyScrollableText::from(test_text.to_owned()); + let mut horizontally_scrollable_text = HorizontallyScrollableText::from(test_text.to_owned()); - assert_eq!(horizontally_scrollable_text.stationary_style(), test_text); + assert_str_eq!(horizontally_scrollable_text.drain(), test_text); + assert!(horizontally_scrollable_text.text.is_empty()); + assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); + } + + #[test] + fn test_horizontally_scrollable_text_pop() { + let test_text = "Test string"; + let mut horizontally_scrollable_text = HorizontallyScrollableText::from(test_text.to_owned()); + horizontally_scrollable_text.pop(); + + assert_str_eq!(horizontally_scrollable_text.text, "Test strin"); + assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); + + horizontally_scrollable_text.scroll_left(); + horizontally_scrollable_text.pop(); + + assert_str_eq!(horizontally_scrollable_text.text, "Test strn"); + assert_eq!(*horizontally_scrollable_text.offset.borrow(), 1); + + horizontally_scrollable_text.scroll_right(); + horizontally_scrollable_text.scroll_right(); + horizontally_scrollable_text.pop(); + + assert_str_eq!(horizontally_scrollable_text.text, "Test str"); + assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); + + horizontally_scrollable_text.scroll_home(); + horizontally_scrollable_text.pop(); + + assert_str_eq!(horizontally_scrollable_text.text, "Test str"); + assert_eq!(*horizontally_scrollable_text.offset.borrow(), 8); + } + + #[test] + fn test_horizontally_scrollable_text_push() { + let test_text = "Test string"; + let mut horizontally_scrollable_text = HorizontallyScrollableText::from(test_text.to_owned()); + horizontally_scrollable_text.push('h'); + + assert_str_eq!(horizontally_scrollable_text.text, "Test stringh"); + assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); + + horizontally_scrollable_text.scroll_left(); + horizontally_scrollable_text.push('l'); + + assert_str_eq!(horizontally_scrollable_text.text, "Test stringlh"); + assert_eq!(*horizontally_scrollable_text.offset.borrow(), 1); + + horizontally_scrollable_text.scroll_right(); + horizontally_scrollable_text.scroll_right(); + horizontally_scrollable_text.push('0'); + + assert_str_eq!(horizontally_scrollable_text.text, "Test stringlh0"); + assert_eq!(*horizontally_scrollable_text.offset.borrow(), 0); } #[test] diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 5772544..cda4329 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -57,6 +57,7 @@ pub struct Movie { pub quality_profile_id: Number, pub minimum_availability: MinimumAvailability, pub certification: Option, + pub tags: Vec, pub ratings: RatingsList, pub movie_file: Option, pub collection: Option, @@ -169,13 +170,20 @@ pub struct DownloadRecord { #[derive(Derivative, Deserialize, Debug)] #[derivative(Default)] -#[serde(rename_all = "camelCase")] pub struct QualityProfile { #[derivative(Default(value = "Number::from(0)"))] pub id: Number, pub name: String, } +#[derive(Derivative, Deserialize, Debug)] +#[derivative(Default)] +pub struct Tag { + #[derivative(Default(value = "Number::from(0)"))] + pub id: Number, + pub label: String, +} + #[derive(Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct MovieHistoryItem { @@ -265,6 +273,7 @@ pub struct AddMovieBody { pub quality_profile_id: u64, pub minimum_availability: String, pub monitored: bool, + pub tags: Vec, pub add_options: AddOptions, } diff --git a/src/network/mod.rs b/src/network/mod.rs index 4d944fe..359018f 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -22,7 +22,6 @@ pub enum NetworkEvent { pub struct Network<'a> { pub client: Client, - pub app: &'a Arc>, } @@ -40,12 +39,12 @@ impl<'a> Network<'a> { app.is_loading = false; } - pub async fn handle_request( + pub async fn handle_request( &self, - request_props: RequestProps, + request_props: RequestProps, mut app_update_fn: impl FnMut(R, MutexGuard<'_, App>), ) where - T: Serialize + Default + Debug, + B: Serialize + Default + Debug, R: DeserializeOwned, { let method = request_props.method; @@ -53,21 +52,23 @@ impl<'a> Network<'a> { 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); + RequestMethod::Get | RequestMethod::Post => { + 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!("Failed to parse response! {:?}", e)); + } } - Err(e) => { - error!("Failed to parse response! {:?}", e); - self - .app - .lock() - .await - .handle_error(anyhow!("Failed to parse response! {:?}", e)); - } - }, - RequestMethod::Delete | RequestMethod::Post | RequestMethod::Put => (), + } + RequestMethod::Delete | RequestMethod::Put => (), } } else { error!( @@ -224,16 +225,19 @@ mod tests { async_server.assert_async().await; } + #[rstest] #[tokio::test] - async fn test_handle_request_get() { - let (async_server, app_arc, server) = mock_api(RequestMethod::Get, 200, true).await; + async fn test_handle_request_with_response_body( + #[values(RequestMethod::Get, RequestMethod::Post)] request_method: RequestMethod, + ) { + let (async_server, app_arc, server) = mock_api(request_method, 200, true).await; let network = Network::new(reqwest::Client::new(), &app_arc); network .handle_request::<(), Test>( RequestProps { uri: format!("{}/test", server.url()), - method: RequestMethod::Get, + method: request_method, body: None, api_token: "test1234".to_owned(), }, @@ -242,7 +246,7 @@ mod tests { .await; async_server.assert_async().await; - assert_str_eq!(app_arc.lock().await.error.stationary_style(), "Test"); + assert_str_eq!(app_arc.lock().await.error.text, "Test"); } #[tokio::test] @@ -275,7 +279,7 @@ mod tests { .lock() .await .error - .stationary_style() + .text .starts_with("Failed to parse response!")); } @@ -300,7 +304,7 @@ mod tests { .lock() .await .error - .stationary_style() + .text .starts_with("Failed to send request.")); } @@ -332,7 +336,7 @@ mod tests { async_server.assert_async().await; assert_str_eq!( - app_arc.lock().await.error.stationary_style(), + app_arc.lock().await.error.text, "Request failed. Received 404 Not Found response code" ); } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 6ddfbe8..e82aae2 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -11,7 +11,7 @@ use crate::app::RadarrConfig; use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, Collection, CollectionMovie, CommandBody, Credit, CreditType, DiskSpace, DownloadRecord, DownloadsResponse, Movie, MovieCommandBody, - MovieHistoryItem, QualityProfile, Release, ReleaseDownloadBody, RootFolder, SystemStatus, + MovieHistoryItem, QualityProfile, Release, ReleaseDownloadBody, RootFolder, SystemStatus, Tag, }; use crate::models::{Route, ScrollableText}; use crate::network::{Network, NetworkEvent, RequestMethod, RequestProps}; @@ -35,6 +35,7 @@ pub enum RadarrEvent { GetReleases, GetRootFolders, GetStatus, + GetTags, HealthCheck, RefreshAndScan, RefreshCollections, @@ -62,6 +63,7 @@ impl RadarrEvent { RadarrEvent::GetReleases | RadarrEvent::DownloadRelease => "/release", RadarrEvent::GetRootFolders => "/rootfolder", RadarrEvent::GetStatus => "/system/status", + RadarrEvent::GetTags => "/tag", RadarrEvent::TriggerAutomaticSearch | RadarrEvent::RefreshAndScan | RadarrEvent::UpdateAllMovies @@ -97,6 +99,7 @@ impl<'a> Network<'a> { RadarrEvent::GetReleases => self.get_releases().await, RadarrEvent::GetRootFolders => self.get_root_folders().await, RadarrEvent::GetStatus => self.get_status().await, + RadarrEvent::GetTags => self.get_tags().await, RadarrEvent::HealthCheck => self.get_healthcheck().await, RadarrEvent::RefreshAndScan => self.refresh_and_scan().await, RadarrEvent::RefreshCollections => self.refresh_collections().await, @@ -205,13 +208,13 @@ impl<'a> Network<'a> { async fn search_movie(&self) { info!("Searching for specific Radarr movie"); - let search_string = self.app.lock().await.data.radarr_data.search.clone(); + let search_string = &self.app.lock().await.data.radarr_data.search.text; let request_props = self .radarr_request_props_from( format!( "{}?term={}", RadarrEvent::SearchNewMovie.resource(), - encode(&search_string) + encode(search_string) ) .as_str(), RequestMethod::Get, @@ -567,13 +570,56 @@ impl<'a> Network<'a> { 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())) + .into_iter() + .map(|profile| (profile.id.as_u64().unwrap(), profile.name)) .collect(); }) .await; } + async fn get_tags(&self) { + info!("Fetching Radarr tags"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetTags.resource(), + RequestMethod::Get, + None::<()>, + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { + app.data.radarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id.as_u64().unwrap(), tag.label)) + .collect(); + }) + .await; + } + + async fn add_tag(&self, tag: String) { + info!("Adding a new Radarr tag"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetTags.resource(), + RequestMethod::Post, + Some(json!({ "label": tag })), + ) + .await; + + self + .handle_request::(request_props, |tag, mut app| { + app + .data + .radarr_data + .tags_map + .insert(tag.id.as_u64().unwrap(), tag.label); + }) + .await; + } + async fn get_root_folders(&self) { info!("Fetching Radarr root folders"); @@ -677,34 +723,26 @@ impl<'a> Network<'a> { async fn add_movie(&self) { info!("Adding new movie to Radarr"); let body = { + let quality_profile_id = self.extract_quality_profile_id().await; + let tag_ids_vec = self.extract_and_add_tag_ids_vec().await; let app = self.app.lock().await; let root_folders = app.data.radarr_data.root_folders.to_vec(); let (tmdb_id, title) = if let Route::Radarr(active_radarr_block, _) = app.get_current_route() { if *active_radarr_block == ActiveRadarrBlock::CollectionDetails { - let CollectionMovie { tmdb_id, title, .. } = app - .data - .radarr_data - .collection_movies - .current_selection_clone(); - (tmdb_id, title.stationary_style()) + let CollectionMovie { tmdb_id, title, .. } = + app.data.radarr_data.collection_movies.current_selection(); + (tmdb_id, title.text.clone()) } else { - let AddMovieSearchResult { tmdb_id, title, .. } = app - .data - .radarr_data - .add_searched_movies - .current_selection_clone(); - (tmdb_id, title.stationary_style()) + let AddMovieSearchResult { tmdb_id, title, .. } = + app.data.radarr_data.add_searched_movies.current_selection(); + (tmdb_id, title.text.clone()) } } else { - let AddMovieSearchResult { tmdb_id, title, .. } = app - .data - .radarr_data - .add_searched_movies - .current_selection_clone(); - (tmdb_id, title.stationary_style()) + let AddMovieSearchResult { tmdb_id, title, .. } = + app.data.radarr_data.add_searched_movies.current_selection(); + (tmdb_id, title.text.clone()) }; - let quality_profile_map = app.data.radarr_data.quality_profile_map.clone(); let RootFolder { path, .. } = root_folders .iter() @@ -729,17 +767,6 @@ impl<'a> Network<'a> { .movie_minimum_availability_list .current_selection() .to_string(); - let quality_profile = app - .data - .radarr_data - .movie_quality_profile_list - .current_selection_clone(); - let quality_profile_id = quality_profile_map - .iter() - .filter(|(_, value)| **value == quality_profile) - .map(|(key, _)| key) - .next() - .unwrap(); AddMovieBody { tmdb_id: tmdb_id.as_u64().unwrap(), @@ -747,7 +774,8 @@ impl<'a> Network<'a> { root_folder_path: path.to_owned(), minimum_availability, monitored: true, - quality_profile_id: *quality_profile_id, + quality_profile_id, + tags: tag_ids_vec, add_options: AddOptions { monitor, search_for_movie: true, @@ -785,22 +813,20 @@ impl<'a> Network<'a> { self .handle_request::<(), Value>(request_props, |detailed_movie_body, mut app| { - app.data.radarr_data.movie_details = - ScrollableText::with_string(detailed_movie_body.to_string()) + app.response = detailed_movie_body.to_string() }) .await; info!("Constructing edit movie body"); let body = { + let quality_profile_id = self.extract_quality_profile_id().await; + let tag_ids_vec = self.extract_and_add_tag_ids_vec().await; let mut app = self.app.lock().await; - let mut detailed_movie_body: Value = - serde_json::from_str(&app.data.radarr_data.movie_details.get_text()).unwrap(); - app.data.radarr_data.movie_details = ScrollableText::default(); + let mut detailed_movie_body: Value = serde_json::from_str(&app.response).unwrap(); + app.response = String::default(); - let quality_profile_map = app.data.radarr_data.quality_profile_map.clone(); - let path: String = app.data.radarr_data.edit_path.drain(..).collect(); - let _tags: String = app.data.radarr_data.edit_tags.drain(..).collect(); + let path: String = app.data.radarr_data.edit_path.drain(); let monitored = app.data.radarr_data.edit_monitored.unwrap_or_default(); let minimum_availability = app @@ -809,22 +835,12 @@ impl<'a> Network<'a> { .movie_minimum_availability_list .current_selection() .to_string(); - let quality_profile = app - .data - .radarr_data - .movie_quality_profile_list - .current_selection_clone(); - let quality_profile_id = quality_profile_map - .iter() - .filter(|(_, value)| **value == quality_profile) - .map(|(key, _)| key) - .next() - .unwrap(); *detailed_movie_body.get_mut("monitored").unwrap() = json!(monitored); *detailed_movie_body.get_mut("minimumAvailability").unwrap() = json!(minimum_availability); *detailed_movie_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); *detailed_movie_body.get_mut("path").unwrap() = json!(path); + *detailed_movie_body.get_mut("tags").unwrap() = json!(tag_ids_vec); detailed_movie_body }; @@ -845,25 +861,21 @@ impl<'a> Network<'a> { } async fn download_release(&self) { - let Release { - guid, - title, - indexer_id, - .. - } = self - .app - .lock() - .await - .data - .radarr_data - .movie_releases - .current_selection_clone(); + let (guid, title, indexer_id) = { + let app = self.app.lock().await; + let Release { + guid, + title, + indexer_id, + .. + } = app.data.radarr_data.movie_releases.current_selection(); + + (guid.clone(), title.clone(), indexer_id.as_u64().unwrap()) + }; + info!("Downloading release: {}", title); - let download_release_body = ReleaseDownloadBody { - guid, - indexer_id: indexer_id.as_u64().unwrap(), - }; + let download_release_body = ReleaseDownloadBody { guid, indexer_id }; let request_props = self .radarr_request_props_from( @@ -878,6 +890,65 @@ impl<'a> Network<'a> { .await; } + async fn extract_quality_profile_id(&self) -> u64 { + let app = self.app.lock().await; + let quality_profile = app + .data + .radarr_data + .movie_quality_profile_list + .current_selection(); + *app + .data + .radarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap() + } + + async fn extract_and_add_tag_ids_vec(&self) -> Vec { + let tags_map = self.app.lock().await.data.radarr_data.tags_map.clone(); + let edit_tags = &self + .app + .lock() + .await + .data + .radarr_data + .edit_tags + .text + .clone(); + let missing_tags_vec = edit_tags + .split(',') + .into_iter() + .filter(|&tag| !tag.is_empty() && tags_map.get_by_right(tag.trim()).is_none()) + .collect::>(); + + for tag in missing_tags_vec { + self.add_tag(tag.trim().to_owned()).await; + } + + let app = self.app.lock().await; + app + .data + .radarr_data + .edit_tags + .text + .split(',') + .into_iter() + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .radarr_data + .tags_map + .get_by_right(tag.trim()) + .unwrap() + }) + .collect() + } + async fn extract_movie_id(&self) -> u64 { if !self .app @@ -898,7 +969,6 @@ impl<'a> Network<'a> { .filtered_movies .current_selection() .id - .clone() .as_u64() .unwrap() } else { @@ -911,7 +981,6 @@ impl<'a> Network<'a> { .movies .current_selection() .id - .clone() .as_u64() .unwrap() } @@ -972,6 +1041,7 @@ mod test { use std::collections::HashMap; use std::sync::Arc; + use bimap::BiMap; use chrono::{DateTime, Utc}; use mockito::{Matcher, Mock, Server, ServerGuard}; use pretty_assertions::{assert_eq, assert_str_eq}; @@ -1010,6 +1080,7 @@ mod test { "qualityProfileId": 2222, "minimumAvailability": "announced", "certification": "R", + "tags": [1], "ratings": { "imdb": { "value": 9.9 @@ -1324,7 +1395,7 @@ mod test { .as_str(), ) .await; - app_arc.lock().await.data.radarr_data.search = "test term".to_owned(); + app_arc.lock().await.data.radarr_data.search = "test term".to_owned().into(); let network = Network::new(reqwest::Client::new(), &app_arc); network @@ -1565,6 +1636,7 @@ mod test { "runtime": 120, "tmdbId": 1234, "qualityProfileId": 2222, + "tags": [1], "minimumAvailability": "released", "ratings": {} }); @@ -1782,6 +1854,56 @@ mod test { ); } + #[tokio::test] + async fn test_handle_get_tags_event() { + let tags_json = json!([{ + "id": 2222, + "label": "usenet" + }]); + let (async_server, app_arc, _server) = mock_radarr_api( + RequestMethod::Get, + None, + Some(tags_json), + RadarrEvent::GetTags.resource(), + ) + .await; + let network = Network::new(reqwest::Client::new(), &app_arc); + + network.handle_radarr_event(RadarrEvent::GetTags).await; + + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.tags_map, + BiMap::from_iter([(2222u64, "usenet".to_owned())]) + ); + } + + #[tokio::test] + async fn test_add_tag() { + let (async_server, app_arc, _server) = mock_radarr_api( + RequestMethod::Post, + Some(json!({ "label": "testing" })), + Some(json!({ "id": 3, "label": "testing" })), + RadarrEvent::GetTags.resource(), + ) + .await; + app_arc.lock().await.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + let network = Network::new(reqwest::Client::new(), &app_arc); + + network.add_tag("testing".to_owned()).await; + + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.radarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } + #[tokio::test] async fn test_handle_get_root_folders_event() { let root_folder_json = json!([{ @@ -1915,6 +2037,7 @@ mod test { "minimumAvailability": "announced", "monitored": true, "qualityProfileId": 2222, + "tags": [1, 2], "addOptions": { "monitor": "movieOnly", "searchForMovie": true @@ -1940,6 +2063,9 @@ mod test { }, ]; app.data.radarr_data.quality_profile_map = HashMap::from([(2222, "HD - 1080p".to_owned())]); + app.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app.data.radarr_data.edit_tags = "usenet, testing".to_owned().into(); app .data .radarr_data @@ -1984,6 +2110,7 @@ mod test { *expected_body.get_mut("minimumAvailability").unwrap() = json!("announced"); *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); let (async_details_server, app_arc, mut server) = mock_radarr_api( RequestMethod::Get, @@ -2004,8 +2131,10 @@ mod test { .await; { let mut app = app_arc.lock().await; - app.data.radarr_data.edit_tags = "test tag".to_owned(); - app.data.radarr_data.edit_path = "/nfs/Test Path".to_owned(); + app.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app.data.radarr_data.edit_tags = "usenet, testing".to_owned().into(); + app.data.radarr_data.edit_path = "/nfs/Test Path".to_owned().into(); app.data.radarr_data.edit_monitored = Some(false); app .data @@ -2033,8 +2162,7 @@ mod test { { let app = app_arc.lock().await; - assert!(app.data.radarr_data.edit_path.is_empty()); - assert!(app.data.radarr_data.edit_tags.is_empty()); + assert!(app.data.radarr_data.edit_path.text.is_empty()); assert!(app.data.radarr_data.movie_details.items.is_empty()); } } @@ -2067,9 +2195,74 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_extract_quality_profile_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + { + let mut app = app_arc.lock().await; + app + .data + .radarr_data + .movie_quality_profile_list + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + app.data.radarr_data.quality_profile_map = + HashMap::from_iter([(1, "Any".to_owned()), (2, "HD - 1080p".to_owned())]); + } + let network = Network::new(reqwest::Client::new(), &app_arc); + + assert_eq!(network.extract_quality_profile_id().await, 1); + } + + #[tokio::test] + async fn test_extract_and_add_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::default())); + { + let mut app = app_arc.lock().await; + app.data.radarr_data.edit_tags = " test,hi ,, usenet ".to_owned().into(); + app.data.radarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + let network = Network::new(reqwest::Client::new(), &app_arc); + + assert_eq!(network.extract_and_add_tag_ids_vec().await, vec![2, 3, 1]); + } + + #[tokio::test] + async fn test_extract_and_add_tag_ids_vec_add_missing_tags_first() { + let (async_server, app_arc, _server) = mock_radarr_api( + RequestMethod::Post, + Some(json!({ "label": "testing" })), + Some(json!({ "id": 3, "label": "testing" })), + RadarrEvent::GetTags.resource(), + ) + .await; + { + let mut app = app_arc.lock().await; + app.data.radarr_data.edit_tags = "usenet, test, testing".to_owned().into(); + app.data.radarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + let network = Network::new(reqwest::Client::new(), &app_arc); + + let tag_ids_vec = network.extract_and_add_tag_ids_vec().await; + + async_server.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app_arc.lock().await.data.radarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } + #[tokio::test] async fn test_extract_movie_id() { - let id = Number::from(1); let app_arc = Arc::new(Mutex::new(App::default())); app_arc .lock() @@ -2078,7 +2271,7 @@ mod test { .radarr_data .movies .set_items(vec![Movie { - id: id.clone(), + id: Number::from(1), ..Movie::default() }]); let network = Network::new(reqwest::Client::new(), &app_arc); @@ -2088,7 +2281,6 @@ mod test { #[tokio::test] async fn test_extract_movie_id_filtered_movies() { - let id = Number::from(1); let app_arc = Arc::new(Mutex::new(App::default())); app_arc .lock() @@ -2097,7 +2289,7 @@ mod test { .radarr_data .filtered_movies .set_items(vec![Movie { - id: id.clone(), + id: Number::from(1), ..Movie::default() }]); let network = Network::new(reqwest::Client::new(), &app_arc); @@ -2107,7 +2299,6 @@ mod test { #[tokio::test] async fn test_append_movie_id_param() { - let id = Number::from(1); let app_arc = Arc::new(Mutex::new(App::default())); app_arc .lock() @@ -2116,7 +2307,7 @@ mod test { .radarr_data .movies .set_items(vec![Movie { - id: id.clone(), + id: Number::from(1), ..Movie::default() }]); let network = Network::new(reqwest::Client::new(), &app_arc); @@ -2342,6 +2533,7 @@ mod test { quality_profile_id: Number::from(2222), minimum_availability: MinimumAvailability::Announced, certification: Some("R".to_owned()), + tags: vec![Number::from(1)], ratings: ratings_list(), movie_file: Some(movie_file()), collection: Some(collection()), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a599041..e30740c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -93,7 +93,7 @@ fn draw_error(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { .borders(Borders::ALL); if app.error.text.len() > area.width as usize { - app.error.scroll_text(); + app.error.scroll_left(); } let mut text = Text::from(app.error.to_string()); @@ -497,6 +497,7 @@ pub fn draw_text_box( text_box_area: Rect, block_title: Option<&str>, block_content: &str, + offset: usize, should_show_cursor: bool, is_selected: bool, ) { @@ -518,7 +519,7 @@ pub fn draw_text_box( f.render_widget(search_paragraph, text_box_area); if should_show_cursor { - show_cursor(f, text_box_area, block_content); + show_cursor(f, text_box_area, offset, block_content); } } @@ -527,6 +528,7 @@ pub fn draw_text_box_with_label( area: Rect, label: &str, text: &str, + offset: usize, is_selected: bool, should_show_cursor: bool, ) { @@ -547,6 +549,7 @@ pub fn draw_text_box_with_label( horizontal_chunks[1], None, text, + offset, should_show_cursor, is_selected, ); diff --git a/src/ui/radarr_ui/add_movie_ui.rs b/src/ui/radarr_ui/add_movie_ui.rs index 1e5770a..375d0f4 100644 --- a/src/ui/radarr_ui/add_movie_ui.rs +++ b/src/ui/radarr_ui/add_movie_ui.rs @@ -70,7 +70,8 @@ fn draw_add_movie_search(f: &mut Frame<'_, B>, app: &mut App, area: .data .radarr_data .add_searched_movies - .current_selection_clone() + .current_selection() + .clone() }; let chunks = vertical_chunks_with_margin( @@ -82,12 +83,21 @@ fn draw_add_movie_search(f: &mut Frame<'_, B>, app: &mut App, area: area, 1, ); - let block_content = app.data.radarr_data.search.as_str(); + let block_content = &app.data.radarr_data.search.text; + let offset = *app.data.radarr_data.search.offset.borrow(); if let Route::Radarr(active_radarr_block, _) = *app.get_current_route() { match active_radarr_block { ActiveRadarrBlock::AddMovieSearchInput => { - draw_text_box(f, chunks[0], Some("Add Movie"), block_content, true, false); + draw_text_box( + f, + chunks[0], + Some("Add Movie"), + block_content, + offset, + true, + false, + ); f.render_widget(layout_block(), chunks[1]); let mut help_text = Text::from(" close"); @@ -184,7 +194,7 @@ fn draw_add_movie_search(f: &mut Frame<'_, B>, app: &mut App, area: "" }; - movie.title.scroll_or_reset( + movie.title.scroll_left_or_reset( get_width_from_percentage(area, 27), *movie == current_selection, ); @@ -208,7 +218,15 @@ fn draw_add_movie_search(f: &mut Frame<'_, B>, app: &mut App, area: } } - draw_text_box(f, chunks[0], Some("Add Movie"), block_content, false, false); + draw_text_box( + f, + chunks[0], + Some("Add Movie"), + block_content, + offset, + false, + false, + ); } fn draw_confirmation_popup(f: &mut Frame<'_, B>, app: &mut App, prompt_area: Rect) { @@ -260,13 +278,13 @@ fn draw_confirmation_prompt(f: &mut Frame<'_, B>, app: &mut App, pro let title = "Add Movie"; let (movie_title, movie_overview) = if let Route::Radarr(_, Some(_)) = app.get_current_route() { ( - app + &app .data .radarr_data .collection_movies .current_selection() .title - .stationary_style(), + .text, app .data .radarr_data @@ -277,13 +295,13 @@ fn draw_confirmation_prompt(f: &mut Frame<'_, B>, app: &mut App, pro ) } else { ( - app + &app .data .radarr_data .add_searched_movies .current_selection() .title - .stationary_style(), + .text, app .data .radarr_data diff --git a/src/ui/radarr_ui/collection_details_ui.rs b/src/ui/radarr_ui/collection_details_ui.rs index 53d4ca5..e06fb33 100644 --- a/src/ui/radarr_ui/collection_details_ui.rs +++ b/src/ui/radarr_ui/collection_details_ui.rs @@ -75,7 +75,8 @@ pub(super) fn draw_collection_details( .data .radarr_data .collection_movies - .current_selection_clone() + .current_selection() + .clone() }; let mut help_text = Text::from("<↑↓> scroll table | show overview/add movie | close"); @@ -151,7 +152,7 @@ pub(super) fn draw_collection_details( } else { "" }; - movie.title.scroll_or_reset( + movie.title.scroll_left_or_reset( get_width_from_percentage(chunks[1], 20), current_selection == *movie, ); @@ -212,7 +213,8 @@ fn draw_movie_overview(f: &mut Frame<'_, B>, app: &mut App, content_ .data .radarr_data .collection_movies - .current_selection_clone() + .current_selection() + .clone() .overview, ); overview.patch_style(style_default()); diff --git a/src/ui/radarr_ui/edit_movie_ui.rs b/src/ui/radarr_ui/edit_movie_ui.rs index cbff227..93bb269 100644 --- a/src/ui/radarr_ui/edit_movie_ui.rs +++ b/src/ui/radarr_ui/edit_movie_ui.rs @@ -140,7 +140,8 @@ fn draw_edit_confirmation_prompt( f, chunks[4], "Path", - &app.data.radarr_data.edit_path, + &app.data.radarr_data.edit_path.text, + *app.data.radarr_data.edit_path.offset.borrow(), *selected_block == ActiveRadarrBlock::EditMoviePathInput, active_radarr_block == ActiveRadarrBlock::EditMoviePathInput, ); @@ -148,7 +149,8 @@ fn draw_edit_confirmation_prompt( f, chunks[5], "Tags", - &app.data.radarr_data.edit_tags, + &app.data.radarr_data.edit_tags.text, + *app.data.radarr_data.edit_tags.offset.borrow(), *selected_block == ActiveRadarrBlock::EditMovieTagsInput, active_radarr_block == ActiveRadarrBlock::EditMovieTagsInput, ); diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 54f5911..d4c51fb 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -158,6 +158,7 @@ pub(super) fn draw_radarr_context_row(f: &mut Frame<'_, B>, app: &Ap fn draw_library(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { let quality_profile_map = &app.data.radarr_data.quality_profile_map; + let tags_map = &app.data.radarr_data.tags_map; let downloads_vec = &app.data.radarr_data.downloads.items; let content = if !app.data.radarr_data.filtered_movies.items.is_empty() && !app.data.radarr_data.is_filtering @@ -208,7 +209,21 @@ fn draw_library(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { let (hours, minutes) = convert_runtime(movie.runtime.as_u64().unwrap()); let file_size: f64 = convert_to_gb(movie.size_on_disk.as_u64().unwrap()); let certification = movie.certification.clone().unwrap_or_else(|| "".to_owned()); - let tags = ""; + let quality_profile = quality_profile_map + .get(&movie.quality_profile_id.as_u64().unwrap()) + .unwrap() + .to_owned(); + let tags = movie + .tags + .iter() + .map(|tag_id| { + tags_map + .get_by_left(&tag_id.as_u64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", "); Row::new(vec![ Cell::from(movie.title.to_owned()), @@ -218,14 +233,9 @@ fn draw_library(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { Cell::from(certification), Cell::from(movie.original_language.name.to_owned()), Cell::from(format!("{:.2} GB", file_size)), - Cell::from( - quality_profile_map - .get(&movie.quality_profile_id.as_u64().unwrap()) - .unwrap() - .to_owned(), - ), + Cell::from(quality_profile), Cell::from(monitored.to_owned()), - Cell::from(tags.to_owned()), + Cell::from(tags), ]) .style(determine_row_style(downloads_vec, movie)) }, @@ -322,20 +332,23 @@ fn draw_search_box(f: &mut Frame<'_, B>, app: &mut App, area: Rect) f.render_widget(input, chunks[0]); } else { - let (block_title, block_content) = match app.get_current_route() { + let default_content = String::default(); + let (block_title, offset, block_content) = match app.get_current_route() { Route::Radarr(active_radarr_block, _) => match active_radarr_block { - _ if SEARCH_BLOCKS.contains(active_radarr_block) => { - ("Search", app.data.radarr_data.search.as_str()) - } - _ => ("", ""), + _ if SEARCH_BLOCKS.contains(active_radarr_block) => ( + "Search", + *app.data.radarr_data.search.offset.borrow(), + &app.data.radarr_data.search.text, + ), + _ => ("", 0, &default_content), }, - _ => ("", ""), + _ => ("", 0, &default_content), }; - let input = Paragraph::new(block_content) + let input = Paragraph::new(block_content.as_str()) .style(style_default()) .block(title_block_centered(block_title)); - show_cursor(f, chunks[0], block_content); + show_cursor(f, chunks[0], offset, block_content); f.render_widget(input, chunks[0]); } @@ -360,20 +373,23 @@ fn draw_filter_box(f: &mut Frame<'_, B>, app: &mut App, area: Rect) f.render_widget(input, chunks[0]); } else { - let (block_title, block_content) = match app.get_current_route() { + let default_content = String::default(); + let (block_title, offset, block_content) = match app.get_current_route() { Route::Radarr(active_radarr_block, _) => match active_radarr_block { - _ if FILTER_BLOCKS.contains(active_radarr_block) => { - ("Filter", app.data.radarr_data.filter.as_str()) - } - _ => ("", ""), + _ if FILTER_BLOCKS.contains(active_radarr_block) => ( + "Filter", + *app.data.radarr_data.filter.offset.borrow(), + &app.data.radarr_data.filter.text, + ), + _ => ("", 0, &default_content), }, - _ => ("", ""), + _ => ("", 0, &default_content), }; - let input = Paragraph::new(block_content) + let input = Paragraph::new(block_content.as_str()) .style(style_default()) .block(title_block_centered(block_title)); - show_cursor(f, chunks[0], block_content); + show_cursor(f, chunks[0], offset, block_content); f.render_widget(input, chunks[0]); } @@ -413,7 +429,7 @@ fn draw_downloads(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { let current_selection = if app.data.radarr_data.downloads.items.is_empty() { DownloadRecord::default() } else { - app.data.radarr_data.downloads.current_selection_clone() + app.data.radarr_data.downloads.current_selection().clone() }; draw_table( @@ -456,7 +472,7 @@ fn draw_downloads(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { } = download_record; let path = output_path.clone().unwrap_or_default(); - path.scroll_or_reset( + path.scroll_left_or_reset( get_width_from_percentage(area, 18), current_selection == *download_record, ); diff --git a/src/ui/radarr_ui/movie_details_ui.rs b/src/ui/radarr_ui/movie_details_ui.rs index 2e2ef4b..8542dea 100644 --- a/src/ui/radarr_ui/movie_details_ui.rs +++ b/src/ui/radarr_ui/movie_details_ui.rs @@ -113,7 +113,7 @@ fn draw_refresh_and_scan_prompt( "Refresh and Scan", format!( "Do you want to trigger a refresh and disk scan for the movie: {}?", - app.data.radarr_data.movies.current_selection_clone().title + app.data.radarr_data.movies.current_selection().title ) .as_str(), &app.data.radarr_data.prompt_confirm, @@ -223,7 +223,12 @@ fn draw_movie_history(f: &mut Frame<'_, B>, app: &mut App, content_a let current_selection = if app.data.radarr_data.movie_history.items.is_empty() { MovieHistoryItem::default() } else { - app.data.radarr_data.movie_history.current_selection_clone() + app + .data + .radarr_data + .movie_history + .current_selection() + .clone() }; let block = layout_block_top_border(); @@ -263,7 +268,7 @@ fn draw_movie_history(f: &mut Frame<'_, B>, app: &mut App, content_a event_type, } = movie_history_item; - movie_history_item.source_title.scroll_or_reset( + movie_history_item.source_title.scroll_left_or_reset( get_width_from_percentage(content_area, 34), current_selection == *movie_history_item, ); @@ -362,7 +367,8 @@ fn draw_movie_releases(f: &mut Frame<'_, B>, app: &mut App, content_ .data .radarr_data .movie_releases - .current_selection_clone() + .current_selection() + .clone() }; let current_route = *app.get_current_route(); let mut table_headers_vec = vec![ @@ -432,7 +438,7 @@ fn draw_movie_releases(f: &mut Frame<'_, B>, app: &mut App, content_ .. } = release; let age = format!("{} days", age.as_u64().unwrap_or(0)); - title.scroll_or_reset( + title.scroll_left_or_reset( get_width_from_percentage(content_area, 30), current_selection == *release && current_route != ActiveRadarrBlock::ManualSearchConfirmPrompt.into(), @@ -488,12 +494,12 @@ fn draw_manual_search_confirm_prompt( let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - current_selection.title.stationary_style() + ¤t_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - current_selection.title.stationary_style() + ¤t_selection.title.text ) }; diff --git a/src/ui/utils.rs b/src/ui/utils.rs index 9e7cea7..93ae268 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -266,8 +266,8 @@ pub fn line_gauge_with_label(title: &str, ratio: f64) -> LineGauge<'_> { .label(Spans::from(format!("{}: {:.0}%", title, ratio * 100.0))) } -pub fn show_cursor(f: &mut Frame<'_, B>, area: Rect, string: &str) { - f.set_cursor(area.x + string.len() as u16 + 1, area.y + 1); +pub fn show_cursor(f: &mut Frame<'_, B>, area: Rect, offset: usize, string: &str) { + f.set_cursor(area.x + (string.len() - offset) as u16 + 1, area.y + 1); } pub fn get_width_from_percentage(area: Rect, percentage: u16) -> usize {