feat: TUI support for Lidarr library
This commit is contained in:
+102
-2
@@ -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),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user