diff --git a/README.md b/README.md index 820953b..8a156ed 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,11 @@ 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 -- [x] Add or delete movies +- [x] Add or delete movies and downloads +- [x] Trigger automatic searches for movies +- [x] Trigger refresh and disk scan for movies +- [ ] Manually search for movies - [ ] Manage your quality profiles -- [ ] Modify your Radarr settings ### Sonarr - [ ] Support for Sonarr diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index 774e1c9..e2c6b44 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -17,6 +17,7 @@ generate_keybindings! { backspace, search, filter, + refresh, home, end, delete, @@ -63,6 +64,10 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { key: Key::Char('f'), desc: "Filter", }, + refresh: KeyBinding { + key: Key::Char('r'), + desc: "Refresh", + }, home: KeyBinding { key: Key::Home, desc: "Home", diff --git a/src/app/mod.rs b/src/app/mod.rs index 18c50bb..b109bc1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -108,6 +108,14 @@ impl App { pub fn get_current_route(&self) -> &Route { self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE) } + + pub fn get_previous_route(&self) -> &Route { + if self.navigation_stack.len() > 1 { + &self.navigation_stack[self.navigation_stack.len() - 2] + } else { + self.get_current_route() + } + } } impl Default for App { diff --git a/src/app/radarr.rs b/src/app/radarr.rs index 25f7e2f..25b3eb5 100644 --- a/src/app/radarr.rs +++ b/src/app/radarr.rs @@ -6,7 +6,7 @@ use chrono::{DateTime, Utc}; use crate::app::{App, Route}; use crate::models::radarr_models::{ AddMovieSearchResult, Collection, CollectionMovie, Credit, DiskSpace, DownloadRecord, - MinimumAvailability, Monitor, Movie, MovieHistoryItem, RootFolder, + MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release, RootFolder, }; use crate::models::{ScrollableText, StatefulList, StatefulTable, TabRoute, TabState}; use crate::network::radarr_network::RadarrEvent; @@ -32,6 +32,7 @@ pub struct RadarrData { pub movie_history: StatefulTable, pub movie_cast: StatefulTable, pub movie_crew: StatefulTable, + pub movie_releases: StatefulTable, pub collections: StatefulTable, pub filtered_collections: StatefulTable, pub collection_movies: StatefulTable, @@ -66,6 +67,7 @@ impl RadarrData { self.movie_history = StatefulTable::default(); self.movie_cast = StatefulTable::default(); self.movie_crew = StatefulTable::default(); + self.movie_releases = StatefulTable::default(); self.movie_info_tabs.index = 0; } @@ -103,6 +105,7 @@ impl Default for RadarrData { movie_history: StatefulTable::default(), movie_cast: StatefulTable::default(), movie_crew: StatefulTable::default(), + movie_releases: StatefulTable::default(), collections: StatefulTable::default(), filtered_collections: StatefulTable::default(), collection_movies: StatefulTable::default(), @@ -134,28 +137,33 @@ impl Default for RadarrData { TabRoute { title: "Details".to_owned(), route: ActiveRadarrBlock::MovieDetails.into(), - help: "←→ change tab | close ".to_owned(), + help: " refresh | auto search | ←→ change tab | close ".to_owned(), }, TabRoute { title: "History".to_owned(), route: ActiveRadarrBlock::MovieHistory.into(), - help: "<↑↓> scroll | ←→ change tab | close ".to_owned(), + help: " refresh | auto search | <↑↓> scroll | ←→ change tab | close ".to_owned(), }, TabRoute { title: "File".to_owned(), route: ActiveRadarrBlock::FileInfo.into(), - help: "←→ change tab | close ".to_owned(), + help: " refresh | auto search | ←→ change tab | close ".to_owned(), }, TabRoute { title: "Cast".to_owned(), route: ActiveRadarrBlock::Cast.into(), - help: "<↑↓> scroll | ←→ change tab | close ".to_owned(), + help: " refresh | auto search | <↑↓> scroll | ←→ change tab | close ".to_owned(), }, TabRoute { title: "Crew".to_owned(), route: ActiveRadarrBlock::Crew.into(), - help: "<↑↓> scroll | ←→ change tab | close ".to_owned(), + help: " refresh | auto search | <↑↓> scroll | ←→ change tab | close ".to_owned(), }, + TabRoute { + title: "Manual Search".to_owned(), + route: ActiveRadarrBlock::ManualSearch.into(), + help: " refresh | auto search | <↑↓> scroll | details | ←→ change tab | close ".to_owned(), + } ]), } } @@ -170,6 +178,7 @@ pub enum ActiveRadarrBlock { AddMovieSelectQualityProfile, AddMovieSelectMonitor, AddMovieConfirmPrompt, + AutomaticallySearchMoviePrompt, Collections, CollectionDetails, Cast, @@ -179,12 +188,14 @@ pub enum ActiveRadarrBlock { FileInfo, FilterCollections, FilterMovies, + ManualSearch, Movies, MovieDetails, MovieHistory, Downloads, SearchMovie, SearchCollection, + RefreshAndScanPrompt, ViewMovieOverview, } @@ -265,12 +276,14 @@ impl App { self .dispatch_network_event(RadarrEvent::GetMovieDetails.into()) .await; + self.check_for_prompt_action().await; } ActiveRadarrBlock::MovieHistory => { self.is_loading = true; self .dispatch_network_event(RadarrEvent::GetMovieHistory.into()) .await; + self.check_for_prompt_action().await; } ActiveRadarrBlock::Cast | ActiveRadarrBlock::Crew => { if self.data.radarr_data.movie_cast.items.is_empty() @@ -281,6 +294,14 @@ impl App { .dispatch_network_event(RadarrEvent::GetMovieCredits.into()) .await; } + self.check_for_prompt_action().await; + } + ActiveRadarrBlock::ManualSearch => { + self.is_loading = true; + self + .dispatch_network_event(RadarrEvent::GetReleases.into()) + .await; + self.check_for_prompt_action().await; } _ => (), } diff --git a/src/handlers/radarr_handlers/mod.rs b/src/handlers/radarr_handlers/mod.rs index 6b7fbd3..c4aee5a 100644 --- a/src/handlers/radarr_handlers/mod.rs +++ b/src/handlers/radarr_handlers/mod.rs @@ -26,7 +26,10 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> { | ActiveRadarrBlock::MovieHistory | ActiveRadarrBlock::FileInfo | ActiveRadarrBlock::Cast - | ActiveRadarrBlock::Crew => { + | ActiveRadarrBlock::Crew + | ActiveRadarrBlock::AutomaticallySearchMoviePrompt + | ActiveRadarrBlock::RefreshAndScanPrompt + | ActiveRadarrBlock::ManualSearch => { MovieDetailsHandler::with(self.key, self.app, self.active_radarr_block).handle() } ActiveRadarrBlock::CollectionDetails | ActiveRadarrBlock::ViewMovieOverview => { diff --git a/src/handlers/radarr_handlers/movie_details_handler.rs b/src/handlers/radarr_handlers/movie_details_handler.rs index 902de40..e06770f 100644 --- a/src/handlers/radarr_handlers/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/movie_details_handler.rs @@ -2,8 +2,9 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::radarr::ActiveRadarrBlock; use crate::app::App; use crate::event::Key; -use crate::handlers::KeyEventHandler; +use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::models::Scrollable; +use crate::network::radarr_network::RadarrEvent; pub(super) struct MovieDetailsHandler<'a> { key: &'a Key, @@ -34,6 +35,7 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for MovieDetailsHandler<'a> { ActiveRadarrBlock::MovieHistory => self.app.data.radarr_data.movie_history.scroll_up(), ActiveRadarrBlock::Cast => self.app.data.radarr_data.movie_cast.scroll_up(), ActiveRadarrBlock::Crew => self.app.data.radarr_data.movie_crew.scroll_up(), + ActiveRadarrBlock::ManualSearch => self.app.data.radarr_data.movie_releases.scroll_up(), _ => (), } } @@ -44,6 +46,7 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for MovieDetailsHandler<'a> { ActiveRadarrBlock::MovieHistory => self.app.data.radarr_data.movie_history.scroll_down(), ActiveRadarrBlock::Cast => self.app.data.radarr_data.movie_cast.scroll_down(), ActiveRadarrBlock::Crew => self.app.data.radarr_data.movie_crew.scroll_down(), + ActiveRadarrBlock::ManualSearch => self.app.data.radarr_data.movie_releases.scroll_down(), _ => (), } } @@ -54,6 +57,7 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for MovieDetailsHandler<'a> { ActiveRadarrBlock::MovieHistory => self.app.data.radarr_data.movie_history.scroll_to_top(), ActiveRadarrBlock::Cast => self.app.data.radarr_data.movie_cast.scroll_to_top(), ActiveRadarrBlock::Crew => self.app.data.radarr_data.movie_crew.scroll_to_top(), + ActiveRadarrBlock::ManualSearch => self.app.data.radarr_data.movie_releases.scroll_to_top(), _ => (), } } @@ -64,6 +68,9 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for MovieDetailsHandler<'a> { ActiveRadarrBlock::MovieHistory => self.app.data.radarr_data.movie_history.scroll_to_bottom(), ActiveRadarrBlock::Cast => self.app.data.radarr_data.movie_cast.scroll_to_bottom(), ActiveRadarrBlock::Crew => self.app.data.radarr_data.movie_crew.scroll_to_bottom(), + ActiveRadarrBlock::ManualSearch => { + self.app.data.radarr_data.movie_releases.scroll_to_bottom() + } _ => (), } } @@ -76,7 +83,8 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for MovieDetailsHandler<'a> { | ActiveRadarrBlock::MovieHistory | ActiveRadarrBlock::FileInfo | ActiveRadarrBlock::Cast - | ActiveRadarrBlock::Crew => match self.key { + | ActiveRadarrBlock::Crew + | ActiveRadarrBlock::ManualSearch => match self.key { _ if *self.key == DEFAULT_KEYBINDINGS.left.key => { self.app.data.radarr_data.movie_info_tabs.previous(); self.app.pop_and_push_navigation_stack( @@ -103,11 +111,32 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for MovieDetailsHandler<'a> { } _ => (), }, + ActiveRadarrBlock::AutomaticallySearchMoviePrompt + | ActiveRadarrBlock::RefreshAndScanPrompt => handle_prompt_toggle(self.app, self.key), _ => (), } } - fn handle_submit(&mut self) {} + fn handle_submit(&mut self) { + match self.active_radarr_block { + ActiveRadarrBlock::AutomaticallySearchMoviePrompt => { + if self.app.data.radarr_data.prompt_confirm { + self.app.data.radarr_data.prompt_confirm_action = + Some(RadarrEvent::TriggerAutomaticSearch); + } + + self.app.pop_navigation_stack(); + } + ActiveRadarrBlock::RefreshAndScanPrompt => { + if self.app.data.radarr_data.prompt_confirm { + self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::RefreshAndScan); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } fn handle_esc(&mut self) { match self.active_radarr_block { @@ -115,13 +144,42 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for MovieDetailsHandler<'a> { | ActiveRadarrBlock::MovieHistory | ActiveRadarrBlock::FileInfo | ActiveRadarrBlock::Cast - | ActiveRadarrBlock::Crew => { + | ActiveRadarrBlock::Crew + | ActiveRadarrBlock::ManualSearch => { self.app.pop_navigation_stack(); self.app.data.radarr_data.reset_movie_info_tabs(); } + ActiveRadarrBlock::AutomaticallySearchMoviePrompt + | ActiveRadarrBlock::RefreshAndScanPrompt => { + self.app.pop_navigation_stack(); + self.app.data.radarr_data.prompt_confirm = false; + } _ => (), } } - fn handle_char_key_event(&mut self) {} + fn handle_char_key_event(&mut self) { + let key = self.key; + match *self.active_radarr_block { + ActiveRadarrBlock::MovieDetails + | ActiveRadarrBlock::MovieHistory + | ActiveRadarrBlock::FileInfo + | ActiveRadarrBlock::Cast + | ActiveRadarrBlock::Crew + | ActiveRadarrBlock::ManualSearch => match self.key { + _ if *key == DEFAULT_KEYBINDINGS.search.key => { + self + .app + .push_navigation_stack(ActiveRadarrBlock::AutomaticallySearchMoviePrompt.into()); + } + _ if *key == DEFAULT_KEYBINDINGS.refresh.key => { + self + .app + .push_navigation_stack(ActiveRadarrBlock::RefreshAndScanPrompt.into()); + } + _ => (), + }, + _ => (), + } + } } diff --git a/src/main.rs b/src/main.rs index 02e3882..799d842 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use std::io; use std::sync::Arc; +use std::time::Duration; use anyhow::Result; use clap::Parser; @@ -52,7 +53,13 @@ async fn main() -> Result<()> { #[tokio::main] async fn start_networking(mut network_rx: Receiver, app: &Arc>) { - let network = Network::new(reqwest::Client::new(), app); + let network = Network::new( + reqwest::Client::builder() + .timeout(Duration::from_secs(45)) + .build() + .unwrap(), + app, + ); while let Some(network_event) = network_rx.recv().await { network.handle_network_event(network_event).await; diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 55211bd..d0207ba 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -192,6 +192,11 @@ pub struct Quality { pub name: String, } +#[derive(Deserialize, Default, Debug, Clone, PartialEq, Eq)] +pub struct QualityWrapper { + pub quality: Quality, +} + #[derive(Deserialize, PartialEq, Eq, Clone, Debug)] #[serde(rename_all = "lowercase")] pub enum CreditType { @@ -210,6 +215,26 @@ pub struct Credit { pub credit_type: CreditType, } +#[derive(Deserialize, Derivative, Clone, Debug, PartialEq, Eq)] +#[derivative(Default)] +pub struct Release { + pub protocol: String, + #[derivative(Default(value = "Number::from(0)"))] + pub age: Number, + pub title: HorizontallyScrollableText, + pub indexer: HorizontallyScrollableText, + #[derivative(Default(value = "Number::from(0)"))] + pub size: Number, + pub rejected: bool, + pub rejections: Option>, + #[derivative(Default(value = "Number::from(0)"))] + pub seeders: Number, + #[derivative(Default(value = "Number::from(0)"))] + pub leechers: Number, + pub languages: Option>, + pub quality: QualityWrapper, +} + #[derive(Default, Derivative, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct AddMovieBody { @@ -247,6 +272,13 @@ pub struct AddMovieSearchResult { pub ratings: RatingsList, } +#[derive(Default, Derivative, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CommandBody { + pub name: String, + pub movie_ids: Vec, +} + #[derive(Default, PartialEq, Eq, Clone, Debug)] pub enum MinimumAvailability { #[default] diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 2f47b31..acf3e02 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -7,8 +7,9 @@ use urlencoding::encode; use crate::app::RadarrConfig; use crate::models::radarr_models::{ - AddMovieBody, AddMovieSearchResult, AddOptions, Collection, Credit, CreditType, DiskSpace, - DownloadsResponse, Movie, MovieHistoryItem, QualityProfile, RootFolder, SystemStatus, + AddMovieBody, AddMovieSearchResult, AddOptions, Collection, CommandBody, Credit, CreditType, + DiskSpace, DownloadsResponse, Movie, MovieHistoryItem, QualityProfile, Release, RootFolder, + SystemStatus, }; use crate::models::ScrollableText; use crate::network::utils::get_movie_status; @@ -28,9 +29,12 @@ pub enum RadarrEvent { GetMovieHistory, GetOverview, GetQualityProfiles, + GetReleases, GetRootFolders, GetStatus, SearchNewMovie, + TriggerAutomaticSearch, + RefreshAndScan, HealthCheck, } @@ -48,8 +52,10 @@ impl RadarrEvent { RadarrEvent::GetMovieHistory => "/history/movie", RadarrEvent::GetOverview => "/diskspace", RadarrEvent::GetQualityProfiles => "/qualityprofile", + RadarrEvent::GetReleases => "/release", RadarrEvent::GetRootFolders => "/rootfolder", RadarrEvent::GetStatus => "/system/status", + RadarrEvent::TriggerAutomaticSearch | RadarrEvent::RefreshAndScan => "/command", RadarrEvent::HealthCheck => "/health", } } @@ -76,9 +82,12 @@ impl<'a> Network<'a> { RadarrEvent::GetMovieHistory => self.get_movie_history().await, RadarrEvent::GetDownloads => self.get_downloads().await, RadarrEvent::GetQualityProfiles => self.get_quality_profiles().await, + RadarrEvent::GetReleases => self.get_releases().await, RadarrEvent::GetRootFolders => self.get_root_folders().await, RadarrEvent::SearchNewMovie => self.search_movie().await, RadarrEvent::AddMovie => self.add_movie().await, + RadarrEvent::TriggerAutomaticSearch => self.trigger_automatic_search().await, + RadarrEvent::RefreshAndScan => self.refresh_and_scan().await, } } @@ -153,6 +162,30 @@ impl<'a> Network<'a> { .await; } + async fn get_releases(&self) { + let movie_id = self.extract_movie_id().await; + info!("Fetching releases for movie with id: {}", movie_id); + + let request_props = self + .radarr_request_props_from( + format!( + "{}?movieId={}", + RadarrEvent::GetReleases.resource(), + movie_id + ) + .as_str(), + RequestMethod::Get, + None::<()>, + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + app.data.radarr_data.movie_releases.set_items(release_vec) + }) + .await; + } + async fn search_movie(&self) { info!("Searching for specific Radarr movie"); @@ -181,6 +214,48 @@ impl<'a> Network<'a> { .await; } + async fn trigger_automatic_search(&self) { + let movie_id = self.extract_movie_id().await; + info!("Searching indexers for movie with id: {}", movie_id); + let body = CommandBody { + name: "MovieSearch".to_owned(), + movie_ids: vec![movie_id], + }; + + let request_props = self + .radarr_request_props_from( + RadarrEvent::TriggerAutomaticSearch.resource(), + RequestMethod::Post, + Some(body), + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await; + } + + async fn refresh_and_scan(&self) { + let movie_id = self.extract_movie_id().await; + info!("Refreshing and scanning movie with id: {}", movie_id); + let body = CommandBody { + name: "RefreshMovie".to_owned(), + movie_ids: vec![movie_id], + }; + + let request_props = self + .radarr_request_props_from( + RadarrEvent::RefreshAndScan.resource(), + RequestMethod::Post, + Some(body), + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await; + } + async fn get_movie_details(&self) { info!("Fetching Radarr movie details"); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f3f360f..f70cef2 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -121,6 +121,16 @@ pub fn draw_popup_over( popup_fn(f, app, popup_area); } +pub fn draw_prompt_popup_over( + f: &mut Frame<'_, B>, + app: &mut App, + area: Rect, + background_fn: fn(&mut Frame<'_, B>, &mut App, Rect), + popup_fn: fn(&mut Frame<'_, B>, &mut App, Rect), +) { + draw_popup_over(f, app, area, background_fn, popup_fn, 30, 30); +} + pub fn draw_small_popup_over( f: &mut Frame<'_, B>, app: &mut App, diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index e529496..f4935f4 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -16,7 +16,7 @@ use crate::models::radarr_models::{DiskSpace, DownloadRecord, Movie}; use crate::models::Route; 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; +use crate::ui::radarr_ui::movie_details_ui::draw_movie_info_popup; use crate::ui::utils::{ borderless_block, get_width, horizontal_chunks, layout_block, layout_block_top_border, line_gauge_with_label, line_gauge_with_title, show_cursor, style_bold, style_default, @@ -24,8 +24,8 @@ use crate::ui::utils::{ vertical_chunks_with_margin, }; use crate::ui::{ - draw_large_popup_over, draw_popup_over, draw_prompt_box, draw_table, draw_tabs, loading, - TableProps, + draw_large_popup_over, draw_popup_over, draw_prompt_box, draw_prompt_popup_over, draw_table, + draw_tabs, loading, TableProps, }; use crate::utils::{convert_runtime, convert_to_gb}; @@ -59,8 +59,11 @@ pub(super) fn draw_radarr_ui(f: &mut Frame<'_, B>, app: &mut App, ar | ActiveRadarrBlock::MovieHistory | ActiveRadarrBlock::FileInfo | ActiveRadarrBlock::Cast - | ActiveRadarrBlock::Crew => { - draw_large_popup_over(f, app, content_rect, draw_library, draw_movie_info) + | ActiveRadarrBlock::Crew + | ActiveRadarrBlock::AutomaticallySearchMoviePrompt + | ActiveRadarrBlock::RefreshAndScanPrompt + | ActiveRadarrBlock::ManualSearch => { + draw_large_popup_over(f, app, content_rect, draw_library, draw_movie_info_popup) } ActiveRadarrBlock::AddMovieSearchInput | ActiveRadarrBlock::AddMovieSearchResults @@ -83,23 +86,15 @@ pub(super) fn draw_radarr_ui(f: &mut Frame<'_, B>, app: &mut App, ar draw_collection_details_popup, ) } - ActiveRadarrBlock::DeleteMoviePrompt => draw_popup_over( - f, - app, - content_rect, - draw_library, - draw_delete_movie_prompt, - 30, - 30, - ), - ActiveRadarrBlock::DeleteDownloadPrompt => draw_popup_over( + ActiveRadarrBlock::DeleteMoviePrompt => { + draw_prompt_popup_over(f, app, content_rect, draw_library, draw_delete_movie_prompt) + } + ActiveRadarrBlock::DeleteDownloadPrompt => draw_prompt_popup_over( f, app, content_rect, draw_downloads, draw_delete_download_prompt, - 30, - 30, ), _ => (), } diff --git a/src/ui/radarr_ui/movie_details_ui.rs b/src/ui/radarr_ui/movie_details_ui.rs index c64c5c0..c9f12a4 100644 --- a/src/ui/radarr_ui/movie_details_ui.rs +++ b/src/ui/radarr_ui/movie_details_ui.rs @@ -4,38 +4,95 @@ use tui::backend::Backend; use tui::layout::{Constraint, Rect}; use tui::style::Style; use tui::text::{Spans, Text}; -use tui::widgets::{Block, Cell, Paragraph, Row, Wrap}; +use tui::widgets::{Cell, Paragraph, Row, Wrap}; use tui::Frame; use crate::app::radarr::ActiveRadarrBlock; use crate::app::App; -use crate::models::radarr_models::{Credit, MovieHistoryItem}; +use crate::models::radarr_models::{Credit, MovieHistoryItem, Release}; use crate::models::Route; use crate::ui::utils::{ - borderless_block, layout_block_bottom_border, spans_info_default, style_bold, style_default, - style_failure, style_success, style_warning, vertical_chunks, + borderless_block, get_width, layout_block_bottom_border, layout_block_top_border, + spans_info_default, style_bold, style_default, style_failure, style_primary, style_success, + style_warning, vertical_chunks, }; -use crate::ui::{draw_table, draw_tabs, loading, TableProps}; +use crate::ui::{ + draw_prompt_box, draw_prompt_popup_over, draw_table, draw_tabs, loading, TableProps, +}; +use crate::utils::convert_to_gb; -pub(super) fn draw_movie_info(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { - let (content_area, block) = - draw_tabs(f, area, "Movie Info", &app.data.radarr_data.movie_info_tabs); +pub(super) fn draw_movie_info_popup(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { + let (content_area, _) = draw_tabs(f, area, "Movie Info", &app.data.radarr_data.movie_info_tabs); + if let Route::Radarr(active_radarr_block) = app.get_current_route() { + match active_radarr_block { + ActiveRadarrBlock::AutomaticallySearchMoviePrompt => draw_prompt_popup_over( + f, + app, + content_area, + draw_movie_info, + draw_search_movie_prompt, + ), + ActiveRadarrBlock::RefreshAndScanPrompt => draw_prompt_popup_over( + f, + app, + content_area, + draw_movie_info, + draw_refresh_and_scan_prompt, + ), + _ => draw_movie_info(f, app, content_area), + } + } +} + +fn draw_movie_info(f: &mut Frame<'_, B>, app: &mut App, area: Rect) { if let Route::Radarr(active_radarr_block) = app.data.radarr_data.movie_info_tabs.get_active_route() { match active_radarr_block { - ActiveRadarrBlock::FileInfo => draw_file_info(f, app, content_area, block), - ActiveRadarrBlock::MovieDetails => draw_movie_details(f, app, content_area, block), - ActiveRadarrBlock::MovieHistory => draw_movie_history(f, app, content_area, block), - ActiveRadarrBlock::Cast => draw_movie_cast(f, app, content_area, block), - ActiveRadarrBlock::Crew => draw_movie_crew(f, app, content_area, block), + ActiveRadarrBlock::FileInfo => draw_file_info(f, app, area), + ActiveRadarrBlock::MovieDetails => draw_movie_details(f, app, area), + ActiveRadarrBlock::MovieHistory => draw_movie_history(f, app, area), + ActiveRadarrBlock::Cast => draw_movie_cast(f, app, area), + ActiveRadarrBlock::Crew => draw_movie_crew(f, app, area), _ => (), } } } -fn draw_file_info(f: &mut Frame<'_, B>, app: &App, content_area: Rect, block: Block) { +fn draw_search_movie_prompt(f: &mut Frame<'_, B>, app: &mut App, prompt_area: Rect) { + draw_prompt_box( + f, + prompt_area, + " Confirm Search Movie? ", + format!( + "Do you want to trigger an automatic search of your indexers for the movie: {}?", + app.data.radarr_data.movies.current_selection().title + ) + .as_str(), + &app.data.radarr_data.prompt_confirm, + ); +} + +fn draw_refresh_and_scan_prompt( + f: &mut Frame<'_, B>, + app: &mut App, + prompt_area: Rect, +) { + draw_prompt_box( + f, + prompt_area, + " Confirm Refresh and Scan? ", + format!( + "Do you want to trigger a refresh and disk scan for the movie: {}?", + app.data.radarr_data.movies.current_selection().title + ) + .as_str(), + &app.data.radarr_data.prompt_confirm, + ); +} + +fn draw_file_info(f: &mut Frame<'_, B>, app: &App, content_area: Rect) { let file_info = app.data.radarr_data.file_details.to_owned(); if !file_info.is_empty() { @@ -86,17 +143,13 @@ fn draw_file_info(f: &mut Frame<'_, B>, app: &App, content_area: Rec f.render_widget(video_details_title_paragraph, chunks[4]); f.render_widget(video_details_paragraph, chunks[5]); } else { - loading(f, block, content_area, app.is_loading); + loading(f, layout_block_top_border(), content_area, app.is_loading); } } -fn draw_movie_details( - f: &mut Frame<'_, B>, - app: &App, - content_area: Rect, - block: Block, -) { +fn draw_movie_details(f: &mut Frame<'_, B>, app: &App, content_area: Rect) { let movie_details = app.data.radarr_data.movie_details.get_text(); + let block = layout_block_top_border(); if !movie_details.is_empty() { let download_status = app @@ -137,17 +190,13 @@ fn draw_movie_details( } } -fn draw_movie_history( - f: &mut Frame<'_, B>, - app: &mut App, - content_area: Rect, - block: Block, -) { +fn draw_movie_history(f: &mut Frame<'_, B>, app: &mut App, content_area: Rect) { 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() }; + let block = layout_block_top_border(); if app.data.radarr_data.movie_history.items.is_empty() && !app.is_loading { let no_history_paragraph = Paragraph::new(Text::from("No history")) @@ -209,16 +258,11 @@ fn draw_movie_history( } } -fn draw_movie_cast( - f: &mut Frame<'_, B>, - app: &mut App, - content_area: Rect, - block: Block, -) { +fn draw_movie_cast(f: &mut Frame<'_, B>, app: &mut App, content_area: Rect) { draw_table( f, content_area, - block, + layout_block_top_border(), TableProps { content: &mut app.data.radarr_data.movie_cast, constraints: iter::repeat(Constraint::Ratio(1, 2)).take(2).collect(), @@ -241,16 +285,11 @@ fn draw_movie_cast( ) } -fn draw_movie_crew( - f: &mut Frame<'_, B>, - app: &mut App, - content_area: Rect, - block: Block, -) { +fn draw_movie_crew(f: &mut Frame<'_, B>, app: &mut App, content_area: Rect) { draw_table( f, content_area, - block, + layout_block_top_border(), TableProps { content: &mut app.data.radarr_data.movie_crew, constraints: iter::repeat(Constraint::Ratio(1, 3)).take(3).collect(), @@ -275,6 +314,84 @@ fn draw_movie_crew( ); } +fn draw_movie_releases(f: &mut Frame<'_, B>, app: &mut App, content_area: Rect) { + let current_selection = if app.data.radarr_data.movie_releases.items.is_empty() { + Release::default() + } else { + app + .data + .radarr_data + .movie_releases + .current_selection_clone() + }; + + draw_table( + f, + content_area, + layout_block_top_border(), + TableProps { + content: &mut app.data.radarr_data.movie_releases, + constraints: vec![ + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(1), + Constraint::Percentage(40), + Constraint::Percentage(10), + Constraint::Length(8), + Constraint::Length(8), + Constraint::Percentage(10), + Constraint::Percentage(10), + ], + table_headers: vec![ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Language", "Quality", + ], + }, + |release| { + let Release { + protocol, + age, + title, + indexer, + size, + rejected, + seeders, + leechers, + languages, + quality, + .. + } = release; + let age = format!("{} days", age.as_u64().unwrap()); + title.scroll_or_reset(get_width(content_area), current_selection == *release); + indexer.scroll_or_reset(get_width(content_area), current_selection == *release); + let size = convert_to_gb(size.as_u64().unwrap()); + let rejected_str = if *rejected { "⛔" } else { "" }; + let seeders = seeders.as_u64().unwrap(); + let leechers = leechers.as_u64().unwrap(); + let peers = format!("{} / {}", seeders, leechers); + let language = if languages.is_some() { + languages.clone().unwrap()[0].name.clone() + } else { + String::default() + }; + let quality = quality.quality.name.clone(); + + Row::new(vec![ + Cell::from(protocol.clone()), + Cell::from(age), + Cell::from(rejected_str).style(determine_style_from_rejection(*rejected)), + Cell::from(title.to_string()), + Cell::from(indexer.to_string()), + Cell::from(format!("{} GB", size)), + Cell::from(peers).style(determine_peer_style(seeders, leechers)), + Cell::from(language), + Cell::from(quality), + ]) + .style(style_primary()) + }, + app.is_loading, + ) +} + fn determine_style_from_download_status(download_status: &str) -> Style { match download_status { "Downloaded" => style_success(), @@ -283,3 +400,21 @@ fn determine_style_from_download_status(download_status: &str) -> Style { _ => style_success(), } } + +fn determine_style_from_rejection(rejected: bool) -> Style { + if rejected { + style_failure() + } else { + style_primary() + } +} + +fn determine_peer_style(seeders: u64, leechers: u64) -> Style { + if seeders == 0 { + style_failure() + } else if seeders < leechers { + style_warning() + } else { + style_success() + } +}