feat: Initial Lidarr support for searching for new artists

This commit is contained in:
2026-01-07 15:53:18 -07:00
parent d3947d9e15
commit 243de47cae
37 changed files with 1646 additions and 72 deletions
+151
View File
@@ -0,0 +1,151 @@
use std::sync::atomic::Ordering;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::widgets::{Cell, Row};
use crate::App;
use crate::models::Route;
use crate::models::lidarr_models::AddArtistSearchResult;
use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock};
use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{get_width_from_percentage, layout_block, title_block_centered};
use crate::ui::widgets::input_box::InputBox;
use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::message::Message;
use crate::ui::widgets::popup::{Popup, Size};
use crate::ui::{DrawUi, draw_popup};
#[cfg(test)]
#[path = "add_artist_ui_tests.rs"]
mod add_artist_ui_tests;
pub(super) struct AddArtistUi;
impl DrawUi for AddArtistUi {
fn accepts(route: Route) -> bool {
let Route::Lidarr(active_lidarr_block, _) = route else {
return false;
};
ADD_ARTIST_BLOCKS.contains(&active_lidarr_block)
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) {
draw_popup(f, app, draw_add_artist_search, Size::Large);
}
}
fn draw_add_artist_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let is_loading = app.is_loading || app.data.lidarr_data.add_searched_artists.is_none();
let current_selection = if let Some(add_searched_artists) =
app.data.lidarr_data.add_searched_artists.as_ref()
&& !add_searched_artists.is_empty()
{
add_searched_artists.current_selection().clone()
} else {
AddArtistSearchResult::default()
};
let [search_box_area, results_area] =
Layout::vertical([Constraint::Length(3), Constraint::Fill(0)])
.margin(1)
.areas(area);
let block_content = &app
.data
.lidarr_data
.add_artist_search
.as_ref()
.expect("add_artist_search must be populated")
.text;
let offset = app
.data
.lidarr_data
.add_artist_search
.as_ref()
.expect("add_artist_search must be populated")
.offset
.load(Ordering::SeqCst);
let search_results_row_mapping = |artist: &AddArtistSearchResult| {
let rating = artist
.ratings
.as_ref()
.map_or(String::new(), |r| format!("{:.1}", r.value));
let in_library = if app
.data
.lidarr_data
.artists
.items
.iter()
.any(|a| a.foreign_artist_id == artist.foreign_artist_id)
{
""
} else {
""
};
artist.artist_name.scroll_left_or_reset(
get_width_from_percentage(area, 27),
*artist == current_selection,
app.ui_scroll_tick_count == 0,
);
Row::new(vec![
Cell::from(in_library),
Cell::from(artist.artist_name.to_string()),
Cell::from(artist.artist_type.clone().unwrap_or_default()),
Cell::from(artist.status.to_display_str()),
Cell::from(rating),
Cell::from(artist.genres.join(", ")),
])
.primary()
};
if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() {
match active_lidarr_block {
ActiveLidarrBlock::AddArtistSearchInput => {
let search_box = InputBox::new(block_content)
.offset(offset)
.block(title_block_centered("Add Artist"));
search_box.show_cursor(f, search_box_area);
f.render_widget(layout_block().default(), results_area);
f.render_widget(search_box, search_box_area);
}
ActiveLidarrBlock::AddArtistEmptySearchResults => {
let error_message = Message::new("No artists found matching your query!");
let error_message_popup = Popup::new(error_message).size(Size::Message);
f.render_widget(layout_block().default(), results_area);
f.render_widget(error_message_popup, f.area());
}
ActiveLidarrBlock::AddArtistSearchResults => {
let search_results_table = ManagarrTable::new(
app.data.lidarr_data.add_searched_artists.as_mut(),
search_results_row_mapping,
)
.loading(is_loading)
.block(layout_block().default())
.headers(["", "Name", "Type", "Status", "Rating", "Genres"])
.constraints([
Constraint::Percentage(3),
Constraint::Percentage(27),
Constraint::Percentage(12),
Constraint::Percentage(12),
Constraint::Percentage(8),
Constraint::Percentage(38),
]);
f.render_widget(search_results_table, results_area);
}
_ => (),
}
}
f.render_widget(
InputBox::new(block_content)
.offset(offset)
.block(title_block_centered("Add Artist")),
search_box_area,
);
}
@@ -0,0 +1,61 @@
#[cfg(test)]
mod tests {
use strum::IntoEnumIterator;
use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock};
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::add_artist_ui::AddArtistUi;
#[test]
fn test_add_artist_ui_accepts() {
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) {
assert!(AddArtistUi::accepts(active_lidarr_block.into()));
} else {
assert!(!AddArtistUi::accepts(active_lidarr_block.into()));
}
});
}
mod snapshot_tests {
use super::*;
use crate::app::App;
use crate::models::HorizontallyScrollableText;
use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app};
use rstest::rstest;
#[test]
fn test_add_artist_ui_renders_loading_for_search() {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default());
app.data.lidarr_data.add_searched_artists = None;
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
AddArtistUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[rstest]
fn test_add_artist_ui_renders(
#[values(
ActiveLidarrBlock::AddArtistSearchInput,
ActiveLidarrBlock::AddArtistSearchResults,
ActiveLidarrBlock::AddArtistEmptySearchResults
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.add_artist_search = Some("Test Artist".into());
app.push_navigation_stack(active_lidarr_block.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
AddArtistUi::draw(f, app, f.area());
});
insta::assert_snapshot!(format!("add_artist_ui_{active_lidarr_block}"), output);
}
}
}
+2 -1
View File
@@ -4,7 +4,7 @@ mod tests {
use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus};
use crate::models::servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS,
ADD_ARTIST_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS,
};
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style};
@@ -18,6 +18,7 @@ mod tests {
library_ui_blocks.extend(LIBRARY_BLOCKS);
library_ui_blocks.extend(DELETE_ARTIST_BLOCKS);
library_ui_blocks.extend(EDIT_ARTIST_BLOCKS);
library_ui_blocks.extend(ADD_ARTIST_BLOCKS);
for active_lidarr_block in ActiveLidarrBlock::iter() {
if library_ui_blocks.contains(&active_lidarr_block) {
+5 -1
View File
@@ -1,3 +1,4 @@
use add_artist_ui::AddArtistUi;
use delete_artist_ui::DeleteArtistUi;
use edit_artist_ui::EditArtistUi;
use ratatui::{
@@ -26,6 +27,7 @@ use crate::{
},
};
mod add_artist_ui;
mod delete_artist_ui;
mod edit_artist_ui;
@@ -38,7 +40,8 @@ pub(super) struct LibraryUi;
impl DrawUi for LibraryUi {
fn accepts(route: Route) -> bool {
if let Route::Lidarr(active_lidarr_block, _) = route {
return DeleteArtistUi::accepts(route)
return AddArtistUi::accepts(route)
|| DeleteArtistUi::accepts(route)
|| EditArtistUi::accepts(route)
|| LIBRARY_BLOCKS.contains(&active_lidarr_block);
}
@@ -51,6 +54,7 @@ impl DrawUi for LibraryUi {
draw_library(f, app, area);
match route {
_ if AddArtistUi::accepts(route) => AddArtistUi::draw(f, app, area),
_ if DeleteArtistUi::accepts(route) => DeleteArtistUi::draw(f, app, area),
_ if EditArtistUi::accepts(route) => EditArtistUi::draw(f, app, area),
Route::Lidarr(ActiveLidarrBlock::UpdateAllArtistsPrompt, _) => {
@@ -0,0 +1,47 @@
---
source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs
expression: output
---
╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮
│Test Artist │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ ╭─────────────── Error ───────────────╮ │
│ │ No artists found matching your query! │ │
│ │ │ │
│ ╰───────────────────────────────────────╯ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,47 @@
---
source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs
expression: output
---
╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮
│Test Artist │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,47 @@
---
source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs
expression: output
---
╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮
│Test Artist │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✔ Name Type Status Rating Genres │
│=> Test Artist Person Continuing 8.4 soundtrack │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,47 @@
---
source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs
expression: output
---
╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮
│Test Artist │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ ╭─────────────── Error ───────────────╮ │
│ │ No artists found matching your query! │ │
│ │ │ │
│ ╰───────────────────────────────────────╯ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,47 @@
---
source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs
expression: output
---
╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮
│Test Artist │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,47 @@
---
source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs
expression: output
---
╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮
│Test Artist │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✔ Name Type Status Rating Genres │
│=> Test Artist Person Continuing 8.4 soundtrack │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,47 @@
---
source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs
expression: output
---
╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ Loading ... │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -34,12 +34,14 @@ fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, are
let is_loading = app.is_loading || app.data.radarr_data.indexer_test_all_results.is_none();
let block = title_block("Test All Indexers");
let current_selection =
if let Some(test_all_results) = app.data.radarr_data.indexer_test_all_results.as_ref() && !test_all_results.is_empty() {
test_all_results.current_selection().clone()
} else {
IndexerTestResultModalItem::default()
};
let current_selection = if let Some(test_all_results) =
app.data.radarr_data.indexer_test_all_results.as_ref()
&& !test_all_results.is_empty()
{
test_all_results.current_selection().clone()
} else {
IndexerTestResultModalItem::default()
};
f.render_widget(block, area);
let test_results_row_mapping = |result: &IndexerTestResultModalItem| {
result.validation_failures.scroll_left_or_reset(
@@ -33,12 +33,14 @@ impl DrawUi for TestAllIndexersUi {
fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let is_loading = app.is_loading || app.data.sonarr_data.indexer_test_all_results.is_none();
let current_selection =
if let Some(test_all_results) = app.data.sonarr_data.indexer_test_all_results.as_ref() && !test_all_results.is_empty() {
test_all_results.current_selection().clone()
} else {
IndexerTestResultModalItem::default()
};
let current_selection = if let Some(test_all_results) =
app.data.sonarr_data.indexer_test_all_results.as_ref()
&& !test_all_results.is_empty()
{
test_all_results.current_selection().clone()
} else {
IndexerTestResultModalItem::default()
};
f.render_widget(title_block("Test All Indexers"), area);
let test_results_row_mapping = |result: &IndexerTestResultModalItem| {
result.validation_failures.scroll_left_or_reset(