Added delete movie functionality

This commit is contained in:
2023-08-08 10:50:04 -06:00
parent 24a36443e9
commit cd0cf2e04a
13 changed files with 360 additions and 104 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ tautulli:
- [x] View details of a specific movie including description, history, downloaded file info, or the credits
- [x] View details of any collection and the movies in them
- [x] Search your library or collections
- [ ] Add movies to Radarr
- [ ] Add or delete movies
- [ ] Manage your quality profiles
- [ ] Modify your Radarr settings
+5
View File
@@ -18,6 +18,7 @@ generate_keybindings! {
filter,
home,
end,
delete,
submit,
quit,
esc
@@ -65,6 +66,10 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings {
key: Key::End,
desc: "End",
},
delete: KeyBinding {
key: Key::Delete,
desc: "Delete selected item",
},
submit: KeyBinding {
key: Key::Enter,
desc: "Select",
+4 -1
View File
@@ -29,6 +29,7 @@ pub struct App {
pub network_tick_frequency: Duration,
pub is_routing: bool,
pub is_loading: bool,
pub should_refresh: bool,
pub config: AppConfig,
pub data: Data,
}
@@ -69,7 +70,7 @@ impl App {
}
pub async fn on_tick(&mut self, is_first_render: bool) {
if self.tick_count % self.tick_until_poll == 0 || self.is_routing {
if self.tick_count % self.tick_until_poll == 0 || self.is_routing || self.should_refresh {
match self.get_current_route() {
Route::Radarr(active_radarr_block) => {
self
@@ -80,6 +81,7 @@ impl App {
}
self.is_routing = false;
self.should_refresh = false;
}
self.tick_count += 1;
@@ -133,6 +135,7 @@ impl Default for App {
last_tick: Instant::now(),
is_loading: false,
is_routing: false,
should_refresh: false,
config: AppConfig::default(),
data: Data::default(),
}
+10
View File
@@ -33,6 +33,7 @@ pub struct RadarrData {
pub movie_info_tabs: TabState,
pub search: String,
pub filter: String,
pub prompt_confirm: bool,
pub is_searching: bool,
}
@@ -88,6 +89,7 @@ impl Default for RadarrData {
search: String::default(),
filter: String::default(),
is_searching: false,
prompt_confirm: false,
main_tabs: TabState::new(vec![
TabRoute {
title: "Library".to_owned(),
@@ -146,6 +148,7 @@ pub enum ActiveRadarrBlock {
CollectionDetails,
Cast,
Crew,
DeleteMoviePrompt,
FileInfo,
FilterCollections,
FilterMovies,
@@ -192,6 +195,13 @@ impl App {
self
.dispatch_network_event(RadarrEvent::GetDownloads.into())
.await;
if self.data.radarr_data.prompt_confirm {
self.data.radarr_data.prompt_confirm = false;
self
.dispatch_network_event(RadarrEvent::DeleteMovie.into())
.await;
}
}
ActiveRadarrBlock::MovieDetails | ActiveRadarrBlock::FileInfo => {
self.is_loading = true;
+5
View File
@@ -14,6 +14,7 @@ pub enum Key {
Backspace,
Home,
End,
Delete,
Char(char),
Unknown,
}
@@ -57,6 +58,10 @@ impl From<KeyEvent> for Key {
code: KeyCode::End,
..
} => Key::End,
KeyEvent {
code: KeyCode::Delete,
..
} => Key::Delete,
KeyEvent {
code: KeyCode::Enter,
..
+4 -2
View File
@@ -15,8 +15,9 @@ pub trait KeyEventHandler<'a, T: Into<Route>> {
_ if *key == DEFAULT_KEYBINDINGS.down.key => self.handle_scroll_down(),
_ if *key == DEFAULT_KEYBINDINGS.home.key => self.handle_home(),
_ if *key == DEFAULT_KEYBINDINGS.end.key => self.handle_end(),
_ if *key == DEFAULT_KEYBINDINGS.delete.key => self.handle_delete(),
_ if *key == DEFAULT_KEYBINDINGS.left.key || *key == DEFAULT_KEYBINDINGS.right.key => {
self.handle_tab_action()
self.handle_left_right_action()
}
_ if *key == DEFAULT_KEYBINDINGS.submit.key => self.handle_submit(),
_ if *key == DEFAULT_KEYBINDINGS.esc.key => self.handle_esc(),
@@ -34,7 +35,8 @@ pub trait KeyEventHandler<'a, T: Into<Route>> {
fn handle_scroll_down(&mut self);
fn handle_home(&mut self);
fn handle_end(&mut self);
fn handle_tab_action(&mut self);
fn handle_delete(&mut self);
fn handle_left_right_action(&mut self);
fn handle_submit(&mut self);
fn handle_esc(&mut self);
fn handle_char_key_event(&mut self);
@@ -56,7 +56,9 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for CollectionDetailsHandler<'a>
}
}
fn handle_tab_action(&mut self) {}
fn handle_delete(&mut self) {}
fn handle_left_right_action(&mut self) {}
fn handle_submit(&mut self) {
if ActiveRadarrBlock::CollectionDetails == *self.active_radarr_block {
+25 -1
View File
@@ -171,7 +171,15 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> {
}
}
fn handle_tab_action(&mut self) {
fn handle_delete(&mut self) {
if *self.active_radarr_block == ActiveRadarrBlock::Movies {
self
.app
.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into());
}
}
fn handle_left_right_action(&mut self) {
match self.active_radarr_block {
ActiveRadarrBlock::Movies | ActiveRadarrBlock::Downloads | ActiveRadarrBlock::Collections => {
match self.key {
@@ -202,6 +210,14 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> {
_ => (),
}
}
ActiveRadarrBlock::DeleteMoviePrompt => match self.key {
_ if *self.key == DEFAULT_KEYBINDINGS.left.key
|| *self.key == DEFAULT_KEYBINDINGS.right.key =>
{
self.app.data.radarr_data.prompt_confirm = !self.app.data.radarr_data.prompt_confirm;
}
_ => (),
},
_ => (),
}
}
@@ -268,6 +284,10 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> {
.set_items(filtered_collections);
}
}
ActiveRadarrBlock::DeleteMoviePrompt => {
self.app.should_refresh = self.app.data.radarr_data.prompt_confirm;
self.app.pop_navigation_stack();
}
_ => (),
}
}
@@ -281,6 +301,10 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for RadarrHandler<'a> {
self.app.pop_navigation_stack();
self.app.data.radarr_data.reset_search();
}
ActiveRadarrBlock::DeleteMoviePrompt => {
self.app.pop_navigation_stack();
self.app.data.radarr_data.prompt_confirm = false;
}
_ => {
self.app.data.radarr_data.reset_search();
handle_clear_errors(self.app);
@@ -68,7 +68,9 @@ impl<'a> KeyEventHandler<'a, ActiveRadarrBlock> for MovieDetailsHandler<'a> {
}
}
fn handle_tab_action(&mut self) {
fn handle_delete(&mut self) {}
fn handle_left_right_action(&mut self) {
match self.active_radarr_block {
ActiveRadarrBlock::MovieDetails
| ActiveRadarrBlock::MovieHistory
+181 -54
View File
@@ -1,7 +1,7 @@
use anyhow::anyhow;
use indoc::formatdoc;
use log::{debug, error};
use reqwest::RequestBuilder;
use reqwest::{RequestBuilder, StatusCode};
use serde::de::DeserializeOwned;
use tokio::sync::MutexGuard;
@@ -17,6 +17,7 @@ use crate::utils::{convert_runtime, convert_to_gb};
#[derive(Debug, Eq, PartialEq)]
pub enum RadarrEvent {
DeleteMovie,
GetCollections,
GetDownloads,
GetMovies,
@@ -29,12 +30,24 @@ pub enum RadarrEvent {
HealthCheck,
}
#[derive(Clone)]
enum RequestMethod {
GET,
DELETE,
}
struct RequestProps<T> {
pub resource: String,
pub method: RequestMethod,
pub body: Option<T>,
}
impl RadarrEvent {
const fn resource(self) -> &'static str {
match self {
RadarrEvent::GetCollections => "/collection",
RadarrEvent::GetDownloads => "/queue",
RadarrEvent::GetMovies | RadarrEvent::GetMovieDetails => "/movie",
RadarrEvent::GetMovies | RadarrEvent::GetMovieDetails | RadarrEvent::DeleteMovie => "/movie",
RadarrEvent::GetMovieCredits => "/credit",
RadarrEvent::GetMovieHistory => "/history/movie",
RadarrEvent::GetOverview => "/diskspace",
@@ -56,86 +69,133 @@ impl<'a> Network<'a> {
match radarr_event {
RadarrEvent::GetCollections => {
self
.get_collections(RadarrEvent::GetCollections.resource())
.get_collections(RadarrEvent::GetCollections.resource().to_owned())
.await
}
RadarrEvent::HealthCheck => {
self
.get_healthcheck(RadarrEvent::HealthCheck.resource())
.get_healthcheck(RadarrEvent::HealthCheck.resource().to_owned())
.await
}
RadarrEvent::GetOverview => {
self
.get_diskspace(RadarrEvent::GetOverview.resource())
.get_diskspace(RadarrEvent::GetOverview.resource().to_owned())
.await
}
RadarrEvent::GetStatus => {
self
.get_status(RadarrEvent::GetStatus.resource().to_owned())
.await
}
RadarrEvent::GetMovies => {
self
.get_movies(RadarrEvent::GetMovies.resource().to_owned())
.await
}
RadarrEvent::DeleteMovie => {
self
.delete_movie(RadarrEvent::DeleteMovie.resource().to_owned())
.await
}
RadarrEvent::GetStatus => self.get_status(RadarrEvent::GetStatus.resource()).await,
RadarrEvent::GetMovies => self.get_movies(RadarrEvent::GetMovies.resource()).await,
RadarrEvent::GetMovieCredits => {
self
.get_credits(RadarrEvent::GetMovieCredits.resource())
.get_credits(RadarrEvent::GetMovieCredits.resource().to_owned())
.await
}
RadarrEvent::GetMovieDetails => {
self
.get_movie_details(RadarrEvent::GetMovieDetails.resource())
.get_movie_details(RadarrEvent::GetMovieDetails.resource().to_owned())
.await
}
RadarrEvent::GetMovieHistory => {
self
.get_movie_history(RadarrEvent::GetMovieHistory.resource())
.get_movie_history(RadarrEvent::GetMovieHistory.resource().to_owned())
.await
}
RadarrEvent::GetDownloads => {
self
.get_downloads(RadarrEvent::GetDownloads.resource())
.get_downloads(RadarrEvent::GetDownloads.resource().to_owned())
.await
}
RadarrEvent::GetQualityProfiles => {
self
.get_quality_profiles(RadarrEvent::GetQualityProfiles.resource())
.get_quality_profiles(RadarrEvent::GetQualityProfiles.resource().to_owned())
.await
}
}
}
async fn get_healthcheck(&self, resource: &str) {
if let Err(e) = self.call_radarr_api(resource).await.send().await {
async fn get_healthcheck(&self, resource: String) {
if let Err(e) = self
.call_radarr_api::<()>(RequestProps {
resource,
method: RequestMethod::GET,
body: None::<()>,
})
.await
.send()
.await
{
error!("Healthcheck failed. {:?}", e);
self.app.lock().await.handle_error(anyhow!(e));
}
}
async fn get_diskspace(&self, resource: &str) {
async fn get_diskspace(&self, resource: String) {
type RequestType = Vec<DiskSpace>;
self
.handle_get_request::<Vec<DiskSpace>>(resource, |disk_space_vec, mut app| {
.handle_request::<RequestType>(
RequestProps {
resource,
method: RequestMethod::GET,
body: None::<RequestType>,
},
|disk_space_vec, mut app| {
app.data.radarr_data.disk_space_vec = disk_space_vec;
})
},
)
.await;
}
async fn get_status(&self, resource: &str) {
async fn get_status(&self, resource: String) {
self
.handle_get_request::<SystemStatus>(resource, |system_status, mut app| {
.handle_request::<SystemStatus>(
RequestProps {
resource,
method: RequestMethod::GET,
body: None::<SystemStatus>,
},
|system_status, mut app| {
app.data.radarr_data.version = system_status.version;
app.data.radarr_data.start_time = system_status.start_time;
})
},
)
.await;
}
async fn get_movies(&self, resource: &str) {
async fn get_movies(&self, resource: String) {
type RequestType = Vec<Movie>;
self
.handle_get_request::<Vec<Movie>>(resource, |movie_vec, mut app| {
app.data.radarr_data.movies.set_items(movie_vec)
})
.handle_request::<RequestType>(
RequestProps {
resource,
method: RequestMethod::GET,
body: None::<RequestType>,
},
|movie_vec, mut app| app.data.radarr_data.movies.set_items(movie_vec),
)
.await;
}
async fn get_movie_details(&self, resource: &str) {
async fn get_movie_details(&self, resource: String) {
let movie_id = self.extract_movie_id().await;
self
.handle_get_request::<Movie>(
format!("{}/{}", resource, movie_id).as_str(),
.handle_request::<Movie>(
RequestProps {
resource: format!("{}/{}", resource, movie_id),
method: RequestMethod::GET,
body: None::<Movie>,
},
|movie_response, mut app| {
let Movie {
id,
@@ -277,10 +337,15 @@ impl<'a> Network<'a> {
.await;
}
async fn get_movie_history(&self, resource: &str) {
async fn get_movie_history(&self, resource: String) {
type RequestType = Vec<MovieHistoryItem>;
self
.handle_get_request::<Vec<MovieHistoryItem>>(
self.append_movie_id_param(resource).await.as_str(),
.handle_request::<RequestType>(
RequestProps {
resource: self.append_movie_id_param(&resource).await,
method: RequestMethod::GET,
body: None::<RequestType>,
},
|movie_history_vec, mut app| {
let mut reversed_movie_history_vec = movie_history_vec.to_vec();
reversed_movie_history_vec.reverse();
@@ -294,41 +359,69 @@ impl<'a> Network<'a> {
.await;
}
async fn get_collections(&self, resource: &str) {
async fn get_collections(&self, resource: String) {
type RequestType = Vec<Collection>;
self
.handle_get_request::<Vec<Collection>>(resource, |collections_vec, mut app| {
.handle_request::<RequestType>(
RequestProps {
resource,
method: RequestMethod::GET,
body: None::<RequestType>,
},
|collections_vec, mut app| {
app.data.radarr_data.collections.set_items(collections_vec);
})
},
)
.await;
}
async fn get_downloads(&self, resource: &str) {
async fn get_downloads(&self, resource: String) {
self
.handle_get_request::<DownloadsResponse>(resource, |queue_response, mut app| {
.handle_request::<DownloadsResponse>(
RequestProps {
resource,
method: RequestMethod::GET,
body: None::<DownloadsResponse>,
},
|queue_response, mut app| {
app
.data
.radarr_data
.downloads
.set_items(queue_response.records);
})
},
)
.await
}
async fn get_quality_profiles(&self, resource: &str) {
async fn get_quality_profiles(&self, resource: String) {
type RequestType = Vec<QualityProfile>;
self
.handle_get_request::<Vec<QualityProfile>>(resource, |quality_profiles, mut app| {
.handle_request::<RequestType>(
RequestProps {
resource,
method: RequestMethod::GET,
body: None::<RequestType>,
},
|quality_profiles, mut app| {
app.data.radarr_data.quality_profile_map = quality_profiles
.iter()
.map(|profile| (profile.id.as_u64().unwrap(), profile.name.clone()))
.collect();
})
},
)
.await;
}
async fn get_credits(&self, resource: &str) {
async fn get_credits(&self, resource: String) {
type RequestType = Vec<Credit>;
self
.handle_get_request::<Vec<Credit>>(
self.append_movie_id_param(resource).await.as_str(),
.handle_request::<RequestType>(
RequestProps {
resource: self.append_movie_id_param(&resource).await,
method: RequestMethod::GET,
body: None::<RequestType>,
},
|credit_vec, mut app| {
let cast_vec: Vec<Credit> = credit_vec
.iter()
@@ -348,7 +441,26 @@ impl<'a> Network<'a> {
.await;
}
async fn call_radarr_api(&self, resource: &str) -> RequestBuilder {
async fn delete_movie(&self, resource: String) {
let movie_id = self.extract_movie_id().await;
self
.handle_request::<()>(
RequestProps {
resource: format!("{}/{}", resource, movie_id),
method: RequestMethod::DELETE,
body: None::<()>,
},
|_, _| (),
)
.await;
}
async fn call_radarr_api<T>(&self, request_props: RequestProps<T>) -> RequestBuilder {
let RequestProps {
resource,
method,
body,
} = request_props;
debug!("Creating RequestBuilder for resource: {:?}", resource);
let app = self.app.lock().await;
let RadarrConfig {
@@ -356,27 +468,30 @@ impl<'a> Network<'a> {
port,
api_token,
} = &app.config.radarr;
app
.client
.get(format!(
let uri = format!(
"http://{}:{}/api/v3{}",
host,
port.unwrap_or(7878),
resource
))
.header("X-Api-Key", api_token)
);
match method {
RequestMethod::GET => app.client.get(uri).header("X-Api-Key", api_token),
RequestMethod::DELETE => app.client.delete(uri).header("X-Api-Key", api_token),
}
}
async fn handle_get_request<T>(
async fn handle_request<T>(
&self,
resource: &str,
request_props: RequestProps<T>,
mut app_update_fn: impl FnMut(T, MutexGuard<App>),
) where
T: DeserializeOwned,
{
match self.call_radarr_api(resource).await.send().await {
Ok(response) => match utils::parse_response::<T>(response).await {
let method = request_props.method.clone();
match self.call_radarr_api(request_props).await.send().await {
Ok(response) => match method {
RequestMethod::GET => match utils::parse_response::<T>(response).await {
Ok(value) => {
let app = self.app.lock().await;
app_update_fn(value, app);
@@ -386,8 +501,20 @@ impl<'a> Network<'a> {
self.app.lock().await.handle_error(anyhow!(e));
}
},
RequestMethod::DELETE => {
if response.status() != StatusCode::OK {
error!(
"Received the following code for delete operation: {:?}",
response.status()
);
self.app.lock().await.handle_error(anyhow!(
"Received a non 200 OK response for delete operation"
));
}
}
},
Err(e) => {
error!("Failed to fetch resource. {:?}", e);
error!("Failed to send request. {:?}", e);
self.app.lock().await.handle_error(anyhow!(e));
}
}
+58 -6
View File
@@ -1,8 +1,6 @@
use std::iter::Map;
use std::slice::Iter;
use tui::backend::Backend;
use tui::layout::{Alignment, Constraint, Rect};
use tui::style::{Modifier, Style};
use tui::text::{Span, Spans, Text};
use tui::widgets::Clear;
use tui::widgets::Paragraph;
@@ -15,9 +13,10 @@ use tui::Frame;
use crate::app::App;
use crate::models::{Route, StatefulTable, TabState};
use crate::ui::utils::{
borderless_block, centered_rect, horizontal_chunks_with_margin, layout_block_top_border,
logo_block, style_default_bold, style_failure, style_help, style_highlight, style_primary,
style_secondary, style_system_function, title_block, vertical_chunks_with_margin,
borderless_block, centered_rect, horizontal_chunks, horizontal_chunks_with_margin, layout_block,
layout_block_top_border, logo_block, style_default_bold, style_failure, style_help,
style_highlight, style_primary, style_secondary, style_system_function, title_block,
vertical_chunks_with_margin,
};
mod radarr_ui;
@@ -252,3 +251,56 @@ pub fn loading<B: Backend>(f: &mut Frame<'_, B>, block: Block<'_>, area: Rect, i
f.render_widget(block, area)
}
}
pub fn draw_prompt_box<B: Backend>(
f: &mut Frame<'_, B>,
prompt_area: Rect,
title: &str,
prompt: &str,
yes_no_value: &bool,
) {
f.render_widget(
title_block(title).title_alignment(Alignment::Center),
prompt_area,
);
let chunks = vertical_chunks_with_margin(
vec![
Constraint::Percentage(72),
Constraint::Min(0),
Constraint::Length(3),
],
prompt_area,
1,
);
let prompt_paragraph = Paragraph::new(Text::from(prompt))
.block(borderless_block())
.style(style_primary().add_modifier(Modifier::BOLD))
.wrap(Wrap { trim: false })
.alignment(Alignment::Center);
f.render_widget(prompt_paragraph, chunks[0]);
let horizontal_chunks = horizontal_chunks(
vec![Constraint::Percentage(50), Constraint::Percentage(50)],
chunks[2],
);
draw_button(f, horizontal_chunks[0], "Yes", *yes_no_value);
draw_button(f, horizontal_chunks[1], "No", !*yes_no_value);
}
pub fn draw_button<B: Backend>(f: &mut Frame<'_, B>, area: Rect, label: &str, is_selected: bool) {
let style = if is_selected {
style_system_function().add_modifier(Modifier::BOLD)
} else {
style_default_bold()
};
let label_paragraph = Paragraph::new(Text::from(label))
.block(layout_block())
.alignment(Alignment::Center)
.style(style);
f.render_widget(label_paragraph, area);
}
+25 -1
View File
@@ -23,7 +23,8 @@ use crate::ui::utils::{
vertical_chunks_with_margin,
};
use crate::ui::{
draw_large_popup_over, draw_popup_over, draw_table, draw_tabs, loading, TableProps,
draw_large_popup_over, draw_popup_over, draw_prompt_box, draw_table, draw_tabs, loading,
TableProps,
};
use crate::utils::{convert_runtime, convert_to_gb};
@@ -68,6 +69,15 @@ pub(super) fn draw_radarr_ui<B: Backend>(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,
),
_ => (),
}
}
@@ -141,6 +151,20 @@ fn draw_library<B: Backend>(f: &mut Frame<'_, B>, app: &mut App, area: Rect) {
);
}
fn draw_delete_movie_prompt<B: Backend>(f: &mut Frame<'_, B>, app: &mut App, prompt_area: Rect) {
draw_prompt_box(
f,
prompt_area,
" Confirm Delete Movie? ",
format!(
"Do you really want to delete {}?",
app.data.radarr_data.movies.current_selection().title
)
.as_str(),
&app.data.radarr_data.prompt_confirm,
);
}
fn draw_search_box<B: Backend>(f: &mut Frame<'_, B>, app: &mut App, area: Rect) {
let chunks = vertical_chunks_with_margin(vec![Constraint::Length(3)], area, 1);
if !app.data.radarr_data.is_searching {
+1 -1
View File
@@ -153,7 +153,7 @@ pub fn title_block(title: &str) -> Block<'_> {
}
pub fn logo_block<'a>() -> Block<'a> {
Block::default().borders(Borders::ALL).title(Span::styled(
layout_block().title(Span::styled(
" Managarr - A Servarr management TUI ",
Style::default()
.fg(Color::Magenta)