feat: TUI support for Lidarr library

This commit is contained in:
2026-01-05 13:10:30 -07:00
parent e61537942b
commit bc3aeefa6e
29 changed files with 2113 additions and 91 deletions
@@ -0,0 +1,20 @@
#[cfg(test)]
mod tests {
use strum::IntoEnumIterator;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS};
use crate::models::Route;
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::LibraryUi;
#[test]
fn test_library_ui_accepts() {
for lidarr_block in ActiveLidarrBlock::iter() {
if LIBRARY_BLOCKS.contains(&lidarr_block) {
assert!(LibraryUi::accepts(Route::Lidarr(lidarr_block, None)));
} else {
assert!(!LibraryUi::accepts(Route::Lidarr(lidarr_block, None)));
}
}
}
}
+185
View File
@@ -0,0 +1,185 @@
use ratatui::{
Frame,
layout::{Constraint, Rect},
widgets::{Cell, Row},
};
use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::utils::convert_to_gb;
use crate::{
app::App,
models::{
Route,
lidarr_models::{Artist, ArtistStatus},
servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS},
},
ui::{
DrawUi,
styles::ManagarrStyle,
utils::{get_width_from_percentage, layout_block_top_border},
},
};
#[cfg(test)]
#[path = "library_ui_tests.rs"]
mod library_ui_tests;
pub(super) struct LibraryUi;
impl DrawUi for LibraryUi {
fn accepts(route: Route) -> bool {
if let Route::Lidarr(active_lidarr_block, _) = route {
return LIBRARY_BLOCKS.contains(&active_lidarr_block);
}
false
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
draw_library(f, app, area);
}
}
fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() {
let current_selection = if !app.data.lidarr_data.artists.items.is_empty() {
app.data.lidarr_data.artists.current_selection().clone()
} else {
Artist::default()
};
let quality_profile_map = &app.data.lidarr_data.quality_profile_map;
let metadata_profile_map = &app.data.lidarr_data.metadata_profile_map;
let tags_map = &app.data.lidarr_data.tags_map;
let content = Some(&mut app.data.lidarr_data.artists);
let artists_table_row_mapping = |artist: &Artist| {
artist.artist_name.scroll_left_or_reset(
get_width_from_percentage(area, 25),
*artist == current_selection,
app.ui_scroll_tick_count == 0,
);
let monitored = if artist.monitored { "🏷" } else { "" };
let artist_type = artist.artist_type.clone().unwrap_or_default();
let size = artist
.statistics
.as_ref()
.map_or(0f64, |stats| convert_to_gb(stats.size_on_disk));
let quality_profile = quality_profile_map
.get_by_left(&artist.quality_profile_id)
.cloned()
.unwrap_or_default();
let metadata_profile = metadata_profile_map
.get_by_left(&artist.metadata_profile_id)
.cloned()
.unwrap_or_default();
let albums = artist
.statistics
.as_ref()
.map_or(0, |stats| stats.album_count);
let tracks = artist
.statistics
.as_ref()
.map_or(String::new(), |stats| {
format!("{}/{}", stats.track_file_count, stats.total_track_count)
});
let tags = artist
.tags
.iter()
.filter_map(|tag_id| {
let id = tag_id.as_i64()?;
tags_map.get_by_left(&id).cloned()
})
.collect::<Vec<_>>()
.join(", ");
decorate_artist_row_with_style(
artist,
Row::new(vec![
Cell::from(artist.artist_name.to_string()),
Cell::from(artist_type),
Cell::from(artist.status.to_display_str()),
Cell::from(quality_profile),
Cell::from(metadata_profile),
Cell::from(albums.to_string()),
Cell::from(tracks),
Cell::from(format!("{size:.2} GB")),
Cell::from(monitored.to_owned()),
Cell::from(tags),
]),
)
};
let artists_table = ManagarrTable::new(content, artists_table_row_mapping)
.block(layout_block_top_border())
.loading(app.is_loading)
.sorting(active_lidarr_block == ActiveLidarrBlock::ArtistsSortPrompt)
.searching(active_lidarr_block == ActiveLidarrBlock::SearchArtists)
.filtering(active_lidarr_block == ActiveLidarrBlock::FilterArtists)
.search_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::SearchArtistsError)
.filter_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::FilterArtistsError)
.headers([
"Name",
"Type",
"Status",
"Quality Profile",
"Metadata Profile",
"Albums",
"Tracks",
"Size",
"Monitored",
"Tags",
])
.constraints([
Constraint::Percentage(22),
Constraint::Percentage(8),
Constraint::Percentage(8),
Constraint::Percentage(12),
Constraint::Percentage(12),
Constraint::Percentage(6),
Constraint::Percentage(8),
Constraint::Percentage(7),
Constraint::Percentage(6),
Constraint::Percentage(11),
]);
if [
ActiveLidarrBlock::SearchArtists,
ActiveLidarrBlock::FilterArtists,
]
.contains(&active_lidarr_block)
{
artists_table.show_cursor(f, area);
}
f.render_widget(artists_table, area);
}
}
fn decorate_artist_row_with_style<'a>(artist: &Artist, row: Row<'a>) -> Row<'a> {
if !artist.monitored {
return row.unmonitored();
}
match artist.status {
ArtistStatus::Ended => {
if let Some(ref stats) = artist.statistics {
return if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 {
row.downloaded()
} else {
row.missing()
};
}
row.indeterminate()
}
ArtistStatus::Continuing => {
if let Some(ref stats) = artist.statistics {
return if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 {
row.unreleased()
} else {
row.missing()
};
}
row.indeterminate()
}
_ => row.indeterminate(),
}
}
+16
View File
@@ -0,0 +1,16 @@
#[cfg(test)]
mod tests {
use strum::IntoEnumIterator;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::Route;
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::LidarrUi;
#[test]
fn test_lidarr_ui_accepts() {
for lidarr_block in ActiveLidarrBlock::iter() {
assert!(LidarrUi::accepts(Route::Lidarr(lidarr_block, None)));
}
}
}
+209
View File
@@ -0,0 +1,209 @@
use std::{cmp, iter};
#[cfg(test)]
use crate::ui::ui_test_utils::test_utils::Utc;
use chrono::Duration;
#[cfg(not(test))]
use chrono::Utc;
use library::LibraryUi;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::Stylize,
text::Text,
widgets::Paragraph,
};
use crate::{
app::App,
logos::LIDARR_LOGO,
models::{
Route,
lidarr_models::DownloadRecord,
servarr_data::lidarr::lidarr_data::LidarrData,
servarr_models::{DiskSpace, RootFolder},
},
utils::convert_to_gb,
};
use super::{
DrawUi, draw_tabs,
styles::ManagarrStyle,
utils::{borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block},
widgets::loading_block::LoadingBlock,
};
mod library;
#[cfg(test)]
#[path = "lidarr_ui_tests.rs"]
mod lidarr_ui_tests;
pub(super) struct LidarrUi;
impl DrawUi for LidarrUi {
fn accepts(route: Route) -> bool {
matches!(route, Route::Lidarr(_, _))
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let content_area = draw_tabs(f, area, "Artists", &app.data.lidarr_data.main_tabs);
let route = app.get_current_route();
match route {
_ if LibraryUi::accepts(route) => LibraryUi::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_lidarr_logo(f, logo_area);
}
}
fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
let block = title_block("Stats");
if !app.data.lidarr_data.version.is_empty() {
f.render_widget(block, area);
let LidarrData {
root_folders,
disk_space_vec,
start_time,
..
} = &app.data.lidarr_data;
let mut constraints = vec![
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
];
constraints.append(
&mut iter::repeat_n(
Constraint::Length(1),
disk_space_vec.len() + root_folders.items.len() + 1,
)
.collect(),
);
let stat_item_areas = Layout::vertical(constraints).margin(1).split(area);
let version_paragraph = Paragraph::new(Text::from(format!(
"Lidarr Version: {}",
app.data.lidarr_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..disk_space_vec.len() {
let DiskSpace {
free_space,
total_space,
} = &disk_space_vec[i];
let title = 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[disk_space_vec.len() + 3]);
for i in 0..root_folders.items.len() {
let RootFolder {
path, free_space, ..
} = &root_folders.items[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();
f.render_widget(
root_folder_space,
stat_item_areas[i + 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.lidarr_data.downloads.items;
if !downloads_vec.is_empty() {
f.render_widget(block, area);
let max_items = ((((area.height as f64 / 2.0).floor() * 2.0) as i64) / 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.0
} else {
1f64 - (*sizeleft / *size)
};
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 draw_lidarr_logo(f: &mut Frame<'_>, area: Rect) {
let logo_text = Text::from(LIDARR_LOGO);
let logo = Paragraph::new(logo_text)
.light_green()
.block(layout_block().default())
.centered();
f.render_widget(logo, area);
}
+6
View File
@@ -9,6 +9,7 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Tabs;
use ratatui::widgets::Wrap;
use ratatui::widgets::{Clear, Row};
use lidarr_ui::LidarrUi;
use sonarr_ui::SonarrUi;
use utils::layout_block;
@@ -27,6 +28,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::popup::Size;
mod builtin_themes;
mod lidarr_ui;
mod radarr_ui;
mod sonarr_ui;
mod styles;
@@ -86,6 +88,10 @@ pub fn ui(f: &mut Frame<'_>, app: &mut App<'_>) {
SonarrUi::draw_context_row(f, app, context_area);
SonarrUi::draw(f, app, table_area);
}
route if LidarrUi::accepts(route) => {
LidarrUi::draw_context_row(f, app, context_area);
LidarrUi::draw(f, app, table_area);
}
_ => (),
}