Every size cell converted bytes to gigabytes and formatted the float in place, so anything below a gigabyte rendered as 0.00 GB. Books and tracks are almost always that small, which made the Size columns in Readarr and Lidarr useless. Replace convert_to_gb and convert_f64_to_gb with one format_size helper that renders gigabytes at or above 1 GiB and megabytes below it, at the precision each site already used. Every render site across the four servarr trees now calls it, including the two movie and episode detail strings built on the network side. Values at or above a gigabyte render byte for byte as before. Give the Readarr book statistics and book file fixtures and the Lidarr album statistics fixture realistic sub-gigabyte sizes so the megabyte path is pinned by the committed snapshots.
543 lines
17 KiB
Rust
543 lines
17 KiB
Rust
use crate::ui::styles::{
|
|
awaiting_import_style, downloaded_style, downloading_style, missing_style,
|
|
unmonitored_missing_style, unreleased_style,
|
|
};
|
|
use std::iter;
|
|
|
|
use ratatui::Frame;
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
use ratatui::style::{Style, Stylize};
|
|
use ratatui::text::{Line, Span, Text};
|
|
use ratatui::widgets::{Cell, Paragraph, Row, Wrap};
|
|
use serde_json::Number;
|
|
|
|
use crate::app::App;
|
|
use crate::models::Route;
|
|
use crate::models::radarr_models::{Credit, MovieHistoryItem, MovieStatus, RadarrRelease};
|
|
use crate::models::servarr_data::radarr::modals::MovieDetailsModal;
|
|
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS};
|
|
use crate::ui::styles::ManagarrStyle;
|
|
use crate::ui::utils::{
|
|
borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_bottom_border,
|
|
layout_block_top_border,
|
|
};
|
|
use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt;
|
|
use crate::ui::widgets::loading_block::LoadingBlock;
|
|
use crate::ui::widgets::managarr_table::ManagarrTable;
|
|
use crate::ui::widgets::popup::{Popup, Size};
|
|
use crate::ui::{DrawUi, draw_popup, draw_tabs};
|
|
use crate::utils::format_size;
|
|
|
|
#[cfg(test)]
|
|
#[path = "movie_details_ui_tests.rs"]
|
|
mod movie_details_ui_tests;
|
|
|
|
pub(super) struct MovieDetailsUi;
|
|
|
|
impl DrawUi for MovieDetailsUi {
|
|
fn accepts(route: Route) -> bool {
|
|
let Route::Radarr(active_radarr_block, _) = route else {
|
|
return false;
|
|
};
|
|
MOVIE_DETAILS_BLOCKS.contains(&active_radarr_block)
|
|
}
|
|
|
|
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) {
|
|
if let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() {
|
|
let draw_movie_info_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| {
|
|
let content_area = draw_tabs(
|
|
f,
|
|
popup_area,
|
|
"Movie Info",
|
|
&app.data.radarr_data.movie_info_tabs,
|
|
);
|
|
draw_movie_info(f, app, content_area);
|
|
|
|
match context_option.unwrap_or(active_radarr_block) {
|
|
ActiveRadarrBlock::AutomaticallySearchMoviePrompt => {
|
|
let prompt = format!(
|
|
"Do you want to trigger an automatic search of your indexers for the movie: {}?",
|
|
app.data.radarr_data.movies.current_selection().title
|
|
);
|
|
let confirmation_prompt = ConfirmationPrompt::new()
|
|
.title("Automatic Movie Search")
|
|
.prompt(&prompt)
|
|
.yes_no_value(app.data.radarr_data.prompt_confirm);
|
|
|
|
f.render_widget(
|
|
Popup::new(confirmation_prompt).size(Size::MediumPrompt),
|
|
f.area(),
|
|
);
|
|
}
|
|
ActiveRadarrBlock::UpdateAndScanPrompt => {
|
|
let prompt = format!(
|
|
"Do you want to trigger an update and disk scan for the movie: {}?",
|
|
app.data.radarr_data.movies.current_selection().title
|
|
);
|
|
let confirmation_prompt = ConfirmationPrompt::new()
|
|
.title("Update and Scan")
|
|
.prompt(&prompt)
|
|
.yes_no_value(app.data.radarr_data.prompt_confirm);
|
|
|
|
f.render_widget(
|
|
Popup::new(confirmation_prompt).size(Size::MediumPrompt),
|
|
f.area(),
|
|
);
|
|
}
|
|
ActiveRadarrBlock::ManualSearchConfirmPrompt => {
|
|
draw_manual_search_confirm_prompt(f, app);
|
|
}
|
|
_ => (),
|
|
}
|
|
};
|
|
|
|
draw_popup(f, app, draw_movie_info_popup, Size::Large);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_movie_info(f: &mut Frame<'_>, 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, 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),
|
|
ActiveRadarrBlock::ManualSearch => draw_movie_releases(f, app, area),
|
|
_ => (),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_file_info(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
|
|
match app.data.radarr_data.movie_details_modal.as_ref() {
|
|
Some(movie_details_modal)
|
|
if !movie_details_modal.file_details.is_empty() && !app.is_loading =>
|
|
{
|
|
let file_info = movie_details_modal.file_details.to_owned();
|
|
let audio_details = movie_details_modal.audio_details.to_owned();
|
|
let video_details = movie_details_modal.video_details.to_owned();
|
|
let [
|
|
file_details_title_area,
|
|
file_details_area,
|
|
audio_details_title_area,
|
|
audio_details_area,
|
|
video_details_title_area,
|
|
video_details_area,
|
|
] = Layout::vertical([
|
|
Constraint::Length(2),
|
|
Constraint::Length(5),
|
|
Constraint::Length(1),
|
|
Constraint::Length(6),
|
|
Constraint::Length(1),
|
|
Constraint::Length(7),
|
|
])
|
|
.areas(area);
|
|
|
|
let file_details_title_paragraph =
|
|
Paragraph::new("File Details".bold()).block(layout_block_top_border());
|
|
let audio_details_title_paragraph =
|
|
Paragraph::new("Audio Details".bold()).block(borderless_block());
|
|
let video_details_title_paragraph =
|
|
Paragraph::new("Video Details".bold()).block(borderless_block());
|
|
|
|
let file_details = Text::from(file_info);
|
|
let audio_details = Text::from(audio_details);
|
|
let video_details = Text::from(video_details);
|
|
|
|
let file_details_paragraph = Paragraph::new(file_details)
|
|
.block(layout_block_bottom_border())
|
|
.wrap(Wrap { trim: false });
|
|
let audio_details_paragraph = Paragraph::new(audio_details)
|
|
.block(layout_block_bottom_border())
|
|
.wrap(Wrap { trim: false });
|
|
let video_details_paragraph = Paragraph::new(video_details)
|
|
.block(borderless_block())
|
|
.wrap(Wrap { trim: false });
|
|
|
|
f.render_widget(file_details_title_paragraph, file_details_title_area);
|
|
f.render_widget(file_details_paragraph, file_details_area);
|
|
f.render_widget(audio_details_title_paragraph, audio_details_title_area);
|
|
f.render_widget(audio_details_paragraph, audio_details_area);
|
|
f.render_widget(video_details_title_paragraph, video_details_title_area);
|
|
f.render_widget(video_details_paragraph, video_details_area);
|
|
}
|
|
_ => f.render_widget(
|
|
LoadingBlock::new(app.is_loading, layout_block_top_border()),
|
|
area,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
|
|
let block = layout_block_top_border();
|
|
let unknown_download_status = "Status: Unknown".to_owned();
|
|
|
|
match app.data.radarr_data.movie_details_modal.as_ref() {
|
|
Some(movie_details_modal) if !app.is_loading => {
|
|
let is_monitored = app.data.radarr_data.movies.current_selection().monitored;
|
|
let status = app.data.radarr_data.movies.current_selection().status;
|
|
let movie_details = &movie_details_modal.movie_details;
|
|
let download_status = movie_details
|
|
.items
|
|
.iter()
|
|
.find(|&line| line.starts_with("Status: "))
|
|
.unwrap_or(&unknown_download_status)
|
|
.split(": ")
|
|
.collect::<Vec<&str>>()[1];
|
|
let text = Text::from(
|
|
movie_details
|
|
.items
|
|
.iter()
|
|
.map(|line| {
|
|
let split = line.split(':').collect::<Vec<&str>>();
|
|
let title = format!("{}:", split[0]);
|
|
let style = style_from_download_status(download_status, is_monitored, status);
|
|
|
|
Line::from(vec![
|
|
Span::styled(title, style.bold()),
|
|
Span::styled(split[1..].join(":"), style),
|
|
])
|
|
})
|
|
.collect::<Vec<Line<'_>>>(),
|
|
);
|
|
|
|
let paragraph = Paragraph::new(text)
|
|
.block(block)
|
|
.wrap(Wrap { trim: false })
|
|
.scroll((movie_details.offset, 0));
|
|
|
|
f.render_widget(paragraph, area);
|
|
}
|
|
_ => f.render_widget(
|
|
LoadingBlock::new(
|
|
app.is_loading || app.data.radarr_data.movie_details_modal.is_none(),
|
|
block,
|
|
),
|
|
area,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn draw_movie_history(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
|
|
if let Some(movie_details_modal) = app.data.radarr_data.movie_details_modal.as_mut() {
|
|
let current_selection = if movie_details_modal.movie_history.items.is_empty() {
|
|
MovieHistoryItem::default()
|
|
} else {
|
|
movie_details_modal
|
|
.movie_history
|
|
.current_selection()
|
|
.clone()
|
|
};
|
|
let history_row_mapping = |movie_history_item: &MovieHistoryItem| {
|
|
let MovieHistoryItem {
|
|
source_title,
|
|
quality,
|
|
languages,
|
|
date,
|
|
event_type,
|
|
} = movie_history_item;
|
|
|
|
movie_history_item.source_title.scroll_left_or_reset(
|
|
get_width_from_percentage(area, 34),
|
|
current_selection == *movie_history_item,
|
|
app.should_text_scroll,
|
|
);
|
|
|
|
Row::new(vec![
|
|
Cell::from(source_title.to_string()),
|
|
Cell::from(event_type.to_owned()),
|
|
Cell::from(
|
|
languages
|
|
.iter()
|
|
.map(|language| language.name.to_owned())
|
|
.collect::<Vec<String>>()
|
|
.join(","),
|
|
),
|
|
Cell::from(quality.quality.name.to_owned()),
|
|
Cell::from(date.to_string()),
|
|
])
|
|
.primary()
|
|
};
|
|
let history_table = ManagarrTable::new(
|
|
Some(&mut movie_details_modal.movie_history),
|
|
history_row_mapping,
|
|
)
|
|
.block(layout_block_top_border())
|
|
.loading(app.is_loading)
|
|
.headers(["Source Title", "Event Type", "Languages", "Quality", "Date"])
|
|
.constraints([
|
|
Constraint::Percentage(34),
|
|
Constraint::Percentage(17),
|
|
Constraint::Percentage(14),
|
|
Constraint::Percentage(14),
|
|
Constraint::Percentage(21),
|
|
]);
|
|
|
|
f.render_widget(history_table, area);
|
|
}
|
|
}
|
|
|
|
fn draw_movie_cast(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
|
|
match app.data.radarr_data.movie_details_modal.as_mut() {
|
|
Some(movie_details_modal) if !app.is_loading => {
|
|
let cast_row_mapping = |cast_member: &Credit| {
|
|
let Credit {
|
|
person_name,
|
|
character,
|
|
..
|
|
} = cast_member;
|
|
|
|
Row::new(vec![
|
|
Cell::from(person_name.to_owned()),
|
|
Cell::from(character.clone().unwrap_or_default()),
|
|
])
|
|
.primary()
|
|
};
|
|
let content = Some(&mut movie_details_modal.movie_cast);
|
|
let cast_table = ManagarrTable::new(content, cast_row_mapping)
|
|
.block(layout_block_top_border())
|
|
.loading(app.is_loading)
|
|
.headers(["Cast Member", "Character"])
|
|
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]);
|
|
|
|
f.render_widget(cast_table, area);
|
|
}
|
|
_ => f.render_widget(
|
|
LoadingBlock::new(
|
|
app.is_loading || app.data.radarr_data.movie_details_modal.is_none(),
|
|
layout_block_top_border(),
|
|
),
|
|
area,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn draw_movie_crew(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
|
|
match app.data.radarr_data.movie_details_modal.as_mut() {
|
|
Some(movie_details_modal) if !app.is_loading => {
|
|
let crew_row_mapping = |crew_member: &Credit| {
|
|
let Credit {
|
|
person_name,
|
|
job,
|
|
department,
|
|
..
|
|
} = crew_member;
|
|
|
|
Row::new(vec![
|
|
Cell::from(person_name.to_owned()),
|
|
Cell::from(job.clone().unwrap_or_default()),
|
|
Cell::from(department.clone().unwrap_or_default()),
|
|
])
|
|
.primary()
|
|
};
|
|
let content = Some(&mut movie_details_modal.movie_crew);
|
|
let crew_table = ManagarrTable::new(content, crew_row_mapping)
|
|
.block(layout_block_top_border())
|
|
.loading(app.is_loading)
|
|
.headers(["Crew Member", "Job", "Department"])
|
|
.constraints(iter::repeat_n(Constraint::Ratio(1, 3), 3));
|
|
|
|
f.render_widget(crew_table, area);
|
|
}
|
|
|
|
_ => f.render_widget(
|
|
LoadingBlock::new(
|
|
app.is_loading || app.data.radarr_data.movie_details_modal.is_none(),
|
|
layout_block_top_border(),
|
|
),
|
|
area,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
|
|
if let Route::Radarr(active_radarr_block, _) = app.get_current_route() {
|
|
let (current_selection, is_empty) = match app.data.radarr_data.movie_details_modal.as_ref() {
|
|
Some(movie_details_modal) if !movie_details_modal.movie_releases.items.is_empty() => (
|
|
movie_details_modal
|
|
.movie_releases
|
|
.current_selection()
|
|
.clone(),
|
|
movie_details_modal.movie_releases.items.is_empty(),
|
|
),
|
|
_ => (RadarrRelease::default(), true),
|
|
};
|
|
let current_route = app.get_current_route();
|
|
let mut default_movie_details_modal = MovieDetailsModal::default();
|
|
let content = Some(
|
|
&mut app
|
|
.data
|
|
.radarr_data
|
|
.movie_details_modal
|
|
.as_mut()
|
|
.unwrap_or(&mut default_movie_details_modal)
|
|
.movie_releases,
|
|
);
|
|
let releases_row_mapping = |release: &RadarrRelease| {
|
|
let RadarrRelease {
|
|
protocol,
|
|
age,
|
|
title,
|
|
indexer,
|
|
size,
|
|
rejected,
|
|
seeders,
|
|
leechers,
|
|
languages,
|
|
quality,
|
|
..
|
|
} = release;
|
|
let age = format!("{age} days");
|
|
title.scroll_left_or_reset(
|
|
get_width_from_percentage(area, 30),
|
|
current_selection == *release
|
|
&& current_route != ActiveRadarrBlock::ManualSearchConfirmPrompt.into(),
|
|
app.should_text_scroll,
|
|
);
|
|
let size = format_size(*size, 1);
|
|
let rejected_str = if *rejected { "⛔" } else { "" };
|
|
let peers = if seeders.is_none() || leechers.is_none() {
|
|
Text::from("")
|
|
} else {
|
|
let seeders = seeders
|
|
.clone()
|
|
.unwrap_or(Number::from(0u64))
|
|
.as_u64()
|
|
.unwrap_or_default();
|
|
let leechers = leechers
|
|
.clone()
|
|
.unwrap_or(Number::from(0u64))
|
|
.as_u64()
|
|
.unwrap_or_default();
|
|
|
|
decorate_peer_style(
|
|
seeders,
|
|
leechers,
|
|
Text::from(format!("{seeders} / {leechers}")),
|
|
)
|
|
};
|
|
|
|
let language = if languages.is_some() {
|
|
languages.clone().unwrap()[0].name.clone()
|
|
} else {
|
|
String::new()
|
|
};
|
|
let quality = quality.quality.name.clone();
|
|
|
|
Row::new(vec![
|
|
Cell::from(protocol.clone()),
|
|
Cell::from(age),
|
|
Cell::from(rejected_str),
|
|
Cell::from(title.to_string()),
|
|
Cell::from(indexer.clone()),
|
|
Cell::from(size),
|
|
Cell::from(peers),
|
|
Cell::from(language),
|
|
Cell::from(quality),
|
|
])
|
|
.primary()
|
|
};
|
|
let releases_table = ManagarrTable::new(content, releases_row_mapping)
|
|
.block(layout_block_top_border())
|
|
.loading(app.is_loading || is_empty)
|
|
.sorting(active_radarr_block == ActiveRadarrBlock::ManualSearchSortPrompt)
|
|
.headers([
|
|
"Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Language", "Quality",
|
|
])
|
|
.constraints([
|
|
Constraint::Length(9),
|
|
Constraint::Length(10),
|
|
Constraint::Length(5),
|
|
Constraint::Percentage(30),
|
|
Constraint::Percentage(18),
|
|
Constraint::Length(12),
|
|
Constraint::Length(12),
|
|
Constraint::Percentage(7),
|
|
Constraint::Percentage(10),
|
|
]);
|
|
|
|
f.render_widget(releases_table, area);
|
|
}
|
|
}
|
|
|
|
fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) {
|
|
let current_selection = app
|
|
.data
|
|
.radarr_data
|
|
.movie_details_modal
|
|
.as_ref()
|
|
.expect("movie_details_modal must exist in this context")
|
|
.movie_releases
|
|
.current_selection();
|
|
let title = if current_selection.rejected {
|
|
"Download Rejected Release"
|
|
} else {
|
|
"Download Release"
|
|
};
|
|
let prompt = if current_selection.rejected {
|
|
format!(
|
|
"Do you really want to download the rejected release: {}?",
|
|
current_selection.title.text
|
|
)
|
|
} else {
|
|
format!(
|
|
"Do you want to download the release: {}?",
|
|
current_selection.title.text
|
|
)
|
|
};
|
|
|
|
if current_selection.rejected {
|
|
let mut lines_vec = vec![Line::from("Rejection reasons: ".primary().bold())];
|
|
let mut rejections_spans = current_selection
|
|
.rejections
|
|
.clone()
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|item| Line::from(format!("• {item}").primary().bold()))
|
|
.collect::<Vec<Line<'_>>>();
|
|
lines_vec.append(&mut rejections_spans);
|
|
|
|
let content_paragraph = Paragraph::new(lines_vec)
|
|
.block(borderless_block())
|
|
.wrap(Wrap { trim: false })
|
|
.left_aligned();
|
|
let confirmation_prompt = ConfirmationPrompt::new()
|
|
.title(title)
|
|
.prompt(&prompt)
|
|
.content(content_paragraph)
|
|
.yes_no_value(app.data.radarr_data.prompt_confirm);
|
|
|
|
f.render_widget(Popup::new(confirmation_prompt).size(Size::Small), f.area());
|
|
} else {
|
|
let confirmation_prompt = ConfirmationPrompt::new()
|
|
.title(title)
|
|
.prompt(&prompt)
|
|
.yes_no_value(app.data.radarr_data.prompt_confirm);
|
|
|
|
f.render_widget(
|
|
Popup::new(confirmation_prompt).size(Size::MediumPrompt),
|
|
f.area(),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn style_from_download_status(
|
|
download_status: &str,
|
|
is_monitored: bool,
|
|
status: MovieStatus,
|
|
) -> Style {
|
|
match download_status {
|
|
"Downloaded" => downloaded_style(),
|
|
"Awaiting Import" => awaiting_import_style(),
|
|
"Downloading" => downloading_style(),
|
|
_ if !is_monitored && download_status == "Missing" => unmonitored_missing_style(),
|
|
_ if status != MovieStatus::Released && download_status == "Missing" => unreleased_style(),
|
|
"Missing" => missing_style(),
|
|
_ => downloaded_style(),
|
|
}
|
|
}
|