Files
managarr/src/ui/radarr_ui/mod.rs
T
Dark-Alex-17 9822597b56 fix(radarr): model the movie status as an enum
Movie.status and AddMovieSearchResult.status were bare Strings compared
against the literal "released" in the row-style decorator and in the
movie details style function. They now carry a MovieStatus enum with the
five values Radarr actually sends (tba, announced, inCinemas, released,
deleted), shaped like MinimumAvailability and DownloadStatus, and guarded
with from_json_or_default so an unrecognised status degrades to Tba
instead of failing the whole fetch.

Radarr never sends "Downloaded" as a movie status; its web UI derives
that label from released plus hasFile. Every fixture that carried
"Downloaded" now holds released, the value it was standing in for, so
none of them degrade to the default through the guard.

The status is never rendered as text in the Radarr tree, so no snapshot
changes; the CLI JSON output is unchanged since the enum serializes to
the same camelCase strings. The style_from_download_status tests now
exercise the unreleased arm, which previously had no case, in place of a
duplicated unmonitored-missing row.
2026-09-17 18:06:37 -06:00

268 lines
8.2 KiB
Rust

use crate::app::App;
use crate::logos::RADARR_LOGO;
use crate::models::Route;
use crate::models::radarr_models::{DownloadRecord, DownloadStatus, Movie, MovieStatus};
use crate::models::servarr_data::radarr::radarr_data::RadarrData;
use crate::models::servarr_models::{DiskSpace, RootFolder};
use crate::ui::DrawUi;
use crate::ui::draw_tabs;
use crate::ui::radarr_ui::blocklist::BlocklistUi;
use crate::ui::radarr_ui::collections::CollectionsUi;
use crate::ui::radarr_ui::downloads::DownloadsUi;
use crate::ui::radarr_ui::history::HistoryUi;
use crate::ui::radarr_ui::indexers::IndexersUi;
use crate::ui::radarr_ui::library::LibraryUi;
use crate::ui::radarr_ui::root_folders::RootFoldersUi;
use crate::ui::radarr_ui::system::SystemUi;
use crate::ui::styles::ManagarrStyle;
#[cfg(test)]
use crate::ui::ui_test_utils::test_utils::Utc;
use crate::ui::utils::{
borderless_block, extract_monitored_disk_space_vec, extract_monitored_root_folders, layout_block,
line_gauge_with_label, line_gauge_with_title, title_block,
};
use crate::ui::widgets::loading_block::LoadingBlock;
use crate::utils::convert_to_gb;
use chrono::Duration;
#[cfg(not(test))]
use chrono::Utc;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::prelude::Stylize;
use ratatui::text::Text;
use ratatui::widgets::{Paragraph, Row};
use std::{cmp, iter};
mod blocklist;
mod collections;
mod downloads;
mod history;
mod indexers;
mod library;
#[cfg(test)]
mod radarr_ui_tests;
mod radarr_ui_utils;
mod root_folders;
mod system;
pub(super) struct RadarrUi;
impl DrawUi for RadarrUi {
fn accepts(route: Route) -> bool {
matches!(route, Route::Radarr(_, _))
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let content_area = draw_tabs(f, area, "Movies", &app.data.radarr_data.main_tabs);
let route = app.get_current_route();
match route {
_ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area),
_ if CollectionsUi::accepts(route) => CollectionsUi::draw(f, app, content_area),
_ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area),
_ if IndexersUi::accepts(route) => IndexersUi::draw(f, app, content_area),
_ if RootFoldersUi::accepts(route) => RootFoldersUi::draw(f, app, content_area),
_ if SystemUi::accepts(route) => SystemUi::draw(f, app, content_area),
_ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area),
_ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area),
_ => (),
}
}
fn draw_context_row(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
let [main_area, logo_area] =
Layout::horizontal([Constraint::Fill(0), Constraint::Length(20)]).areas(area);
let [stats_area, downloads_area] =
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(main_area);
draw_stats_context(f, app, stats_area);
draw_downloads_context(f, app, downloads_area);
draw_radarr_logo(f, logo_area);
}
}
fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
let block = title_block("Stats");
if !app.data.radarr_data.version.is_empty() {
f.render_widget(block, area);
let RadarrData {
root_folders,
disk_space_vec,
start_time,
..
} = &app.data.radarr_data;
let monitored_disk_space_vec = extract_monitored_disk_space_vec(app, disk_space_vec.clone());
let monitored_root_folders = extract_monitored_root_folders(app, root_folders.items.clone());
let mut constraints = vec![
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
];
constraints.append(
&mut iter::repeat_n(
Constraint::Length(1),
monitored_disk_space_vec.len() + monitored_root_folders.len() + 1,
)
.collect(),
);
let stat_item_areas = Layout::vertical(constraints).margin(1).split(area);
let version_paragraph = Paragraph::new(Text::from(format!(
"Radarr Version: {}",
app.data.radarr_data.version
)))
.block(borderless_block())
.bold();
let uptime = Utc::now() - start_time.to_owned();
let days = uptime.num_days();
let day_difference = uptime - Duration::days(days);
let hours = day_difference.num_hours();
let hour_difference = day_difference - Duration::hours(hours);
let minutes = hour_difference.num_minutes();
let seconds = (hour_difference - Duration::minutes(minutes)).num_seconds();
let uptime_paragraph = Paragraph::new(Text::from(format!(
"Uptime: {days}d {hours:0width$}:{minutes:0width$}:{seconds:0width$}",
width = 2
)))
.block(borderless_block())
.bold();
let storage = Paragraph::new(Text::from("Storage:")).block(borderless_block().bold());
let folders = Paragraph::new(Text::from("Root Folders:")).block(borderless_block().bold());
f.render_widget(version_paragraph, stat_item_areas[0]);
f.render_widget(uptime_paragraph, stat_item_areas[1]);
f.render_widget(storage, stat_item_areas[2]);
for i in 0..monitored_disk_space_vec.len() {
let DiskSpace {
path,
free_space,
total_space,
} = &monitored_disk_space_vec[i];
let title = if let Some(path) = path {
path
} else {
&format!("Disk {}", i + 1)
};
let ratio = if *total_space == 0 {
0f64
} else {
1f64 - (*free_space as f64 / *total_space as f64)
};
let space_gauge = line_gauge_with_label(title.as_str(), ratio);
f.render_widget(space_gauge, stat_item_areas[i + 3]);
}
f.render_widget(folders, stat_item_areas[monitored_disk_space_vec.len() + 3]);
let monitored_root_folders = extract_monitored_root_folders(app, root_folders.items.clone());
for i in 0..monitored_root_folders.len() {
let RootFolder {
path, free_space, ..
} = &monitored_root_folders[i];
let space: f64 = convert_to_gb(*free_space);
let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free"))
.block(borderless_block())
.default_color();
f.render_widget(
root_folder_space,
stat_item_areas[i + monitored_disk_space_vec.len() + 4],
)
}
} else {
f.render_widget(LoadingBlock::new(app.is_loading, block), area);
}
}
fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
let block = title_block("Downloads");
let downloads_vec = &app.data.radarr_data.downloads.items;
if !downloads_vec.is_empty() {
f.render_widget(block, area);
let max_items = ((((area.height as f32 / 2.0).floor() * 2.0) as i32) / 2) - 1;
let items = cmp::min(downloads_vec.len(), max_items.unsigned_abs() as usize);
let download_item_areas =
Layout::vertical(iter::repeat_n(Constraint::Length(2), items).collect::<Vec<Constraint>>())
.margin(1)
.split(area);
for i in 0..items {
let DownloadRecord {
title,
sizeleft,
size,
..
} = &downloads_vec[i];
let percent = if *size == 0 {
0.0
} else {
1f64 - (*sizeleft as f64 / *size as f64)
};
let download_gauge = line_gauge_with_title(title, percent);
f.render_widget(download_gauge, download_item_areas[i]);
}
} else {
f.render_widget(LoadingBlock::new(app.is_loading, block), area);
}
}
fn decorate_with_row_style<'a>(
downloads_vec: &[DownloadRecord],
movie: &Movie,
row: Row<'a>,
) -> Row<'a> {
if !movie.has_file {
if let Some(download) = downloads_vec
.iter()
.find(|&download| download.movie_id == movie.id)
{
if download.status == DownloadStatus::Downloading {
return row.downloading();
}
if download.status == DownloadStatus::Completed {
return row.awaiting_import();
}
}
if !movie.monitored {
return row.unmonitored_missing();
}
if movie.status != MovieStatus::Released {
return row.unreleased();
}
return row.missing();
}
if !movie.monitored {
row.unmonitored()
} else {
row.downloaded()
}
}
fn draw_radarr_logo(f: &mut Frame<'_>, area: Rect) {
let logo_text = Text::from(RADARR_LOGO);
let logo = Paragraph::new(logo_text)
.light_yellow()
.block(layout_block().default_color())
.centered();
f.render_widget(logo, area);
}