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
+102 -2
View File
@@ -1,7 +1,9 @@
use chrono::{DateTime, Utc};
use derivative::Derivative;
use enum_display_style_derive::EnumDisplayStyle;
use serde::{Deserialize, Serialize};
use serde_json::{Number, Value};
use strum::EnumIter;
use super::{HorizontallyScrollableText, Serdeable};
use crate::serde_enum_from;
@@ -15,7 +17,6 @@ mod lidarr_models_tests;
pub struct Artist {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub mb_id: String,
pub artist_name: HorizontallyScrollableText,
pub foreign_artist_id: String,
pub status: ArtistStatus,
@@ -35,8 +36,20 @@ pub struct Artist {
pub statistics: Option<ArtistStatistics>,
}
#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug)]
#[derive(
Serialize,
Deserialize,
Default,
PartialEq,
Eq,
Clone,
Copy,
Debug,
strum::Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum ArtistStatus {
#[default]
Continuing,
@@ -74,6 +87,86 @@ pub struct ArtistStatistics {
impl Eq for ArtistStatistics {}
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct MetadataProfile {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub name: String,
}
impl From<(&i64, &String)> for MetadataProfile {
fn from(value: (&i64, &String)) -> Self {
MetadataProfile {
id: *value.0,
name: value.1.clone(),
}
}
}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
pub title: String,
pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub album_id: Option<Number>,
pub artist_id: Option<Number>,
#[serde(deserialize_with = "super::from_f64")]
pub size: f64,
#[serde(deserialize_with = "super::from_f64")]
pub sizeleft: f64,
pub output_path: Option<HorizontallyScrollableText>,
#[serde(default)]
pub indexer: String,
pub download_client: Option<String>,
}
impl Eq for DownloadRecord {}
#[derive(
Serialize,
Deserialize,
Default,
PartialEq,
Eq,
Clone,
Copy,
Debug,
EnumIter,
strum::Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum DownloadStatus {
#[default]
Unknown,
Queued,
Paused,
Downloading,
Completed,
Failed,
Warning,
Delay,
#[display_style(name = "Download Client Unavailable")]
DownloadClientUnavailable,
Fallback,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DownloadsResponse {
pub records: Vec<DownloadRecord>,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SystemStatus {
pub version: String,
pub start_time: DateTime<Utc>,
}
impl From<LidarrSerdeable> for Serdeable {
fn from(value: LidarrSerdeable) -> Serdeable {
Serdeable::Lidarr(value)
@@ -83,6 +176,13 @@ impl From<LidarrSerdeable> for Serdeable {
serde_enum_from!(
LidarrSerdeable {
Artists(Vec<Artist>),
DiskSpaces(Vec<super::servarr_models::DiskSpace>),
DownloadsResponse(DownloadsResponse),
MetadataProfiles(Vec<MetadataProfile>),
QualityProfiles(Vec<super::servarr_models::QualityProfile>),
RootFolders(Vec<super::servarr_models::RootFolder>),
SystemStatus(SystemStatus),
Tags(Vec<super::servarr_models::Tag>),
Value(Value),
}
);
-1
View File
@@ -85,7 +85,6 @@ mod tests {
let artist: Artist = serde_json::from_value(artist_json).unwrap();
assert_eq!(artist.id, 1);
assert_str_eq!(artist.mb_id, "test-mb-id");
assert_str_eq!(artist.artist_name.text, "Test Artist");
assert_str_eq!(artist.foreign_artist_id, "test-foreign-id");
assert_eq!(artist.status, ArtistStatus::Continuing);
+114 -1
View File
@@ -1,20 +1,133 @@
use bimap::BiMap;
use chrono::{DateTime, Utc};
use strum::EnumIter;
#[cfg(test)]
use strum::{Display, EnumString};
use crate::models::Route;
use crate::models::{
Route, TabRoute, TabState,
lidarr_models::{Artist, DownloadRecord},
servarr_models::{DiskSpace, RootFolder},
stateful_table::StatefulTable,
};
use crate::network::lidarr_network::LidarrEvent;
#[cfg(test)]
#[path = "lidarr_data_tests.rs"]
mod lidarr_data_tests;
pub struct LidarrData<'a> {
pub artists: StatefulTable<Artist>,
pub disk_space_vec: Vec<DiskSpace>,
pub downloads: StatefulTable<DownloadRecord>,
pub main_tabs: TabState,
pub metadata_profile_map: BiMap<i64, String>,
pub prompt_confirm: bool,
pub prompt_confirm_action: Option<LidarrEvent>,
pub quality_profile_map: BiMap<i64, String>,
pub root_folders: StatefulTable<RootFolder>,
pub selected_block: crate::models::BlockSelectionState<'a, ActiveLidarrBlock>,
pub start_time: DateTime<Utc>,
pub tags_map: BiMap<i64, String>,
pub version: String,
}
impl LidarrData<'_> {
pub fn reset_sorting(&mut self) {
self.artists.sorting(vec![]);
}
}
impl<'a> Default for LidarrData<'a> {
fn default() -> LidarrData<'a> {
LidarrData {
artists: StatefulTable::default(),
disk_space_vec: Vec::new(),
downloads: StatefulTable::default(),
metadata_profile_map: BiMap::new(),
prompt_confirm: false,
prompt_confirm_action: None,
quality_profile_map: BiMap::new(),
root_folders: StatefulTable::default(),
selected_block: crate::models::BlockSelectionState::default(),
start_time: Utc::now(),
tags_map: BiMap::new(),
version: String::new(),
main_tabs: TabState::new(vec![
TabRoute {
title: "Library".to_string(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: Some(&ARTISTS_CONTEXT_CLUES),
config: None,
},
]),
}
}
}
#[cfg(test)]
impl LidarrData<'_> {
pub fn test_default_fully_populated() -> Self {
use crate::models::lidarr_models::{Artist, DownloadRecord};
use crate::models::servarr_models::{DiskSpace, RootFolder};
use crate::models::stateful_table::SortOption;
let mut lidarr_data = LidarrData::default();
lidarr_data.artists.set_items(vec![Artist::default()]);
lidarr_data.artists.sorting(vec![SortOption {
name: "Name",
cmp_fn: Some(|a: &Artist, b: &Artist| a.artist_name.text.cmp(&b.artist_name.text)),
}]);
lidarr_data.quality_profile_map = BiMap::from_iter([(1i64, "Lossless".to_owned())]);
lidarr_data.metadata_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]);
lidarr_data.tags_map = BiMap::from_iter([(1i64, "usenet".to_owned())]);
lidarr_data.disk_space_vec = vec![DiskSpace {
free_space: 50000000000,
total_space: 100000000000,
}];
lidarr_data.downloads.set_items(vec![DownloadRecord::default()]);
lidarr_data.root_folders.set_items(vec![RootFolder::default()]);
lidarr_data.version = "1.0.0".to_owned();
lidarr_data
}
}
use crate::app::context_clues::ContextClue;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 5] = [
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)]
#[cfg_attr(test, derive(Display, EnumString))]
pub enum ActiveLidarrBlock {
#[default]
Artists,
ArtistsSortPrompt,
SearchArtists,
SearchArtistsError,
FilterArtists,
FilterArtistsError,
}
pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 6] = [
ActiveLidarrBlock::Artists,
ActiveLidarrBlock::ArtistsSortPrompt,
ActiveLidarrBlock::SearchArtists,
ActiveLidarrBlock::SearchArtistsError,
ActiveLidarrBlock::FilterArtists,
ActiveLidarrBlock::FilterArtistsError,
];
impl From<ActiveLidarrBlock> for Route {
fn from(active_lidarr_block: ActiveLidarrBlock) -> Route {
Route::Lidarr(active_lidarr_block, None)
+17 -1
View File
@@ -174,9 +174,25 @@ where
}
pub fn set_filtered_items(&mut self, filtered_items: Vec<T>) {
let items_len = filtered_items.len();
self.filtered_items = Some(filtered_items);
let preserved_selection = self
.filtered_state
.as_ref()
.and_then(|state| state.selected())
.map_or(0, |i| {
if i > 0 && i < items_len {
i
} else if i >= items_len && items_len > 0 {
items_len - 1
} else {
0
}
});
let mut filtered_state: TableState = Default::default();
filtered_state.select(Some(0));
filtered_state.select(Some(preserved_selection));
self.filtered_state = Some(filtered_state);
}
+41
View File
@@ -390,6 +390,47 @@ mod tests {
assert_some_eq_x!(&filtered_stateful_table.filtered_items, &filtered_items_vec);
}
#[test]
fn test_stateful_table_set_filtered_items_preserves_selection() {
let filtered_items_vec = vec!["Test 1", "Test 2", "Test 3"];
let mut filtered_stateful_table: StatefulTable<&str> = StatefulTable::default();
filtered_stateful_table.set_filtered_items(filtered_items_vec.clone());
filtered_stateful_table
.filtered_state
.as_mut()
.unwrap()
.select(Some(1));
filtered_stateful_table.set_filtered_items(filtered_items_vec.clone());
assert_some_eq_x!(
filtered_stateful_table
.filtered_state
.as_ref()
.unwrap()
.selected(),
1
);
filtered_stateful_table
.filtered_state
.as_mut()
.unwrap()
.select(Some(5));
filtered_stateful_table.set_filtered_items(filtered_items_vec);
assert_some_eq_x!(
filtered_stateful_table
.filtered_state
.as_ref()
.unwrap()
.selected(),
2
);
}
#[test]
fn test_stateful_table_current_selection() {
let mut stateful_table = create_test_stateful_table();