use chrono::Utc; use deunicode::deunicode; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use regex::Regex; use crate::app::App; use crate::models::Route; use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock}; use crate::ui::lidarr_ui::library::album_details_ui::AlbumDetailsUi; use crate::ui::lidarr_ui::library::delete_album_ui::DeleteAlbumUi; use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::decorate_peer_style; use crate::ui::utils::{ borderless_block, get_width_from_percentage, layout_block_top_border, title_block, }; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; 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, draw_tabs}; use crate::utils::convert_to_gb; use ratatui::layout::Alignment; use ratatui::text::Text; use serde_json::Number; #[cfg(test)] #[path = "artist_details_ui_tests.rs"] mod artist_details_ui_tests; pub(super) struct ArtistDetailsUi; impl DrawUi for ArtistDetailsUi { fn accepts(route: Route) -> bool { let Route::Lidarr(active_lidarr_block, _) = route else { return false; }; AlbumDetailsUi::accepts(route) || DeleteAlbumUi::accepts(route) || ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let route = app.get_current_route(); if let Route::Lidarr(active_lidarr_block, _) = route { let draw_artist_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { f.render_widget( title_block( &app .data .lidarr_data .artists .current_selection() .artist_name .text, ), popup_area, ); let [description_area, detail_area] = Layout::vertical([Constraint::Length(14), Constraint::Fill(0)]) .margin(1) .areas(popup_area); draw_artist_description(f, app, description_area); let content_area = draw_tabs( f, detail_area, "Artist Details", &app.data.lidarr_data.artist_info_tabs, ); draw_artist_details(f, app, content_area); match active_lidarr_block { _ if DeleteAlbumUi::accepts(route) => DeleteAlbumUi::draw(f, app, area), ActiveLidarrBlock::ArtistHistoryDetails => { draw_artist_history_item_details_popup(f, app); } ActiveLidarrBlock::AutomaticallySearchArtistPrompt => { let prompt = format!( "Do you want to trigger an automatic search of your indexers for all monitored album(s) for the artist: {}?", app.data.lidarr_data.artists.current_selection().artist_name ); let confirmation_prompt = ConfirmationPrompt::new() .title("Automatic Artist Search") .prompt(&prompt) .yes_no_value(app.data.lidarr_data.prompt_confirm); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), ); } ActiveLidarrBlock::UpdateAndScanArtistPrompt => { let prompt = format!( "Do you want to trigger an update and disk scan for the artist: {}?", app.data.lidarr_data.artists.current_selection().artist_name ); let confirmation_prompt = ConfirmationPrompt::new() .title("Update and Scan") .prompt(&prompt) .yes_no_value(app.data.lidarr_data.prompt_confirm); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), ); } ActiveLidarrBlock::ManualArtistSearchConfirmPrompt => { draw_manual_artist_search_confirm_prompt(f, app); } _ => (), } }; draw_popup(f, app, draw_artist_details_popup, Size::XXLarge); if AlbumDetailsUi::accepts(route) { AlbumDetailsUi::draw(f, app, area); } } } } fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let current_selection = app.data.lidarr_data.artists.current_selection(); let monitored = if current_selection.monitored { "Yes" } else { "No" }; let quality_profile = app .data .lidarr_data .quality_profile_map .get_by_left(¤t_selection.quality_profile_id) .cloned() .unwrap_or_default(); let metadata_profile = app .data .lidarr_data .metadata_profile_map .get_by_left(¤t_selection.metadata_profile_id) .cloned() .unwrap_or_default(); let overview = Regex::new(r"[\r\n\t]") .unwrap() .replace_all( &deunicode( current_selection .overview .as_ref() .unwrap_or(&String::new()), ), "", ) .to_string(); let mut artist_description = vec![ Line::from(vec![ "Artist: ".primary().bold(), current_selection.artist_name.text.clone().primary().bold(), ]), Line::from(vec![ "Overview: ".primary().bold(), overview.default_color(), ]), Line::from(vec![ "Disambiguation: ".primary().bold(), current_selection .disambiguation .clone() .unwrap_or_default() .default_color(), ]), Line::from(vec![ "Type: ".primary().bold(), current_selection .artist_type .clone() .unwrap_or_default() .default_color(), ]), Line::from(vec![ "Status: ".primary().bold(), current_selection.status.to_display_str().default_color(), ]), Line::from(vec![ "Genres: ".primary().bold(), current_selection.genres.join(", ").default_color(), ]), Line::from(vec![ "Rating: ".primary().bold(), current_selection .ratings .as_ref() .map_or_else( || "N/A".to_owned(), |r| format!("{}%", (r.value * 10.0) as i32), ) .default_color(), ]), Line::from(vec![ "Path: ".primary().bold(), current_selection.path.clone().default_color(), ]), Line::from(vec![ "Quality Profile: ".primary().bold(), quality_profile.default_color(), ]), Line::from(vec![ "Metadata Profile: ".primary().bold(), metadata_profile.default_color(), ]), Line::from(vec![ "Monitored: ".primary().bold(), monitored.default_color(), ]), ]; if let Some(stats) = current_selection.statistics.as_ref() { let size = convert_to_gb(stats.size_on_disk); artist_description.extend(vec![ Line::from(vec![ "Albums: ".primary().bold(), stats.album_count.to_string().default_color(), ]), Line::from(vec![ "Tracks: ".primary().bold(), format!("{}/{}", stats.track_file_count, stats.total_track_count).default_color(), ]), Line::from(vec![ "Size on Disk: ".primary().bold(), format!("{size:.2} GB").default_color(), ]), ]); } let description_paragraph = Paragraph::new(artist_description) .block(borderless_block()) .wrap(Wrap { trim: true }); f.render_widget(description_paragraph, area); } fn draw_artist_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Lidarr(active_lidarr_block, _) = app.data.lidarr_data.artist_info_tabs.get_active_route() { match active_lidarr_block { ActiveLidarrBlock::ArtistDetails => draw_albums_table(f, app, area), ActiveLidarrBlock::ArtistHistory => draw_artist_history_table(f, app, area), ActiveLidarrBlock::ManualArtistSearch => draw_artist_releases(f, app, area), _ => (), } } } fn draw_albums_table(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.albums.is_empty() { Album::default() } else { app.data.lidarr_data.albums.current_selection().clone() }; let content = Some(&mut app.data.lidarr_data.albums); let album_row_mapping = |album: &Album| { album.title.scroll_left_or_reset( get_width_from_percentage(area, 33), *album == current_selection, app.ui_scroll_tick_count == 0, ); let monitored = if album.monitored { "🏷" } else { "" }; let album_type = album.album_type.clone().unwrap_or_default(); let release_date = album .release_date .map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string()); let track_count = album.statistics.as_ref().map_or_else( || "0/0".to_owned(), |s| format!("{}/{}", s.track_file_count, s.total_track_count), ); let size = album .statistics .as_ref() .map_or(0f64, |s| convert_to_gb(s.size_on_disk)); let duration_mins = album.duration / 60000; let row = Row::new(vec![ Cell::from(monitored.to_owned()), Cell::from(album.title.to_string()), Cell::from(album_type), Cell::from(track_count), Cell::from(format!("{duration_mins} min")), Cell::from(release_date), Cell::from(format!("{size:.2} GB")), ]); if !album.monitored { row.unmonitored() } else if let Some(stats) = album.statistics.as_ref() { if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 { row.downloaded() } else if let Some(release_date) = album.release_date.as_ref() { if release_date > &Utc::now() { row.unreleased() } else { row.missing() } } else { row.missing() } } else { row.indeterminate() } }; let is_searching = active_lidarr_block == ActiveLidarrBlock::SearchAlbums; let album_table = ManagarrTable::new(content, album_row_mapping) .block(layout_block_top_border()) .loading(app.is_loading) .searching(is_searching) .search_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::SearchAlbumsError) .headers([ "Monitored", "Title", "Type", "Tracks", "Duration", "Release Date", "Size", ]) .constraints([ Constraint::Percentage(7), Constraint::Percentage(35), Constraint::Percentage(10), Constraint::Percentage(10), Constraint::Percentage(10), Constraint::Percentage(13), Constraint::Percentage(15), ]); if is_searching { album_table.show_cursor(f, area); } f.render_widget(album_table, area); } } fn draw_artist_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if !app.is_loading { let current_selection = if app.data.lidarr_data.artist_history.is_empty() { LidarrHistoryItem::default() } else { app .data .lidarr_data .artist_history .current_selection() .clone() }; if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { let history_row_mapping = |history_item: &LidarrHistoryItem| { let LidarrHistoryItem { source_title, quality, event_type, date, .. } = history_item; source_title.scroll_left_or_reset( get_width_from_percentage(area, 40), current_selection == *history_item, app.ui_scroll_tick_count == 0, ); Row::new(vec![ Cell::from(source_title.to_string()), Cell::from(event_type.to_string()), Cell::from(quality.quality.name.to_owned()), Cell::from(date.to_string()), ]) .primary() }; let history_table = ManagarrTable::new( Some(&mut app.data.lidarr_data.artist_history), history_row_mapping, ) .block(layout_block_top_border()) .loading(app.is_loading) .sorting(active_lidarr_block == ActiveLidarrBlock::ArtistHistorySortPrompt) .searching(active_lidarr_block == ActiveLidarrBlock::SearchArtistHistory) .search_produced_empty_results( active_lidarr_block == ActiveLidarrBlock::SearchArtistHistoryError, ) .filtering(active_lidarr_block == ActiveLidarrBlock::FilterArtistHistory) .filter_produced_empty_results( active_lidarr_block == ActiveLidarrBlock::FilterArtistHistoryError, ) .headers(["Source Title", "Event Type", "Quality", "Date"]) .constraints([ Constraint::Percentage(40), Constraint::Percentage(20), Constraint::Percentage(15), Constraint::Percentage(25), ]); if [ ActiveLidarrBlock::SearchArtistHistory, ActiveLidarrBlock::FilterArtistHistory, ] .contains(&active_lidarr_block) { history_table.show_cursor(f, area); } f.render_widget(history_table, area); } } else { f.render_widget( LoadingBlock::new( app.is_loading || app.data.lidarr_data.albums.is_empty(), layout_block_top_border(), ), area, ); } } fn draw_artist_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { let current_selection = if app.data.lidarr_data.artist_history.is_empty() { LidarrHistoryItem::default() } else { app .data .lidarr_data .artist_history .current_selection() .clone() }; let line_vec = create_history_event_details(current_selection); let text = Text::from(line_vec); let message = Message::new(text) .title("Details") .style(secondary_style()) .alignment(Alignment::Left); f.render_widget(Popup::new(message).size(Size::NarrowLongMessage), f.area()); } fn draw_artist_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let (current_selection, is_empty) = if app.data.lidarr_data.discography_releases.is_empty() { (LidarrRelease::default(), true) } else { ( app .data .lidarr_data .discography_releases .current_selection() .clone(), app.data.lidarr_data.discography_releases.is_empty(), ) }; if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { let release_row_mapping = |release: &LidarrRelease| { let LidarrRelease { protocol, age, title, indexer, size, rejected, seeders, leechers, quality, .. } = release; let age = format!("{age} days"); title.scroll_left_or_reset( get_width_from_percentage(area, 35), current_selection == *release && active_lidarr_block != ActiveLidarrBlock::ManualArtistSearchConfirmPrompt, app.ui_scroll_tick_count == 0, ); let size = convert_to_gb(*size); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") } else { let seeders = seeders .clone() .unwrap_or(Number::from(0u64)) .as_u64() .unwrap_or_default(); let leechers = leechers .clone() .unwrap_or(Number::from(0u64)) .as_u64() .unwrap_or_default(); decorate_peer_style( seeders, leechers, Text::from(format!("{seeders} / {leechers}")), ) }; let quality_name = quality.quality.name.clone(); Row::new(vec![ Cell::from(protocol.clone()), Cell::from(age), Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), Cell::from(format!("{size:.1} GB")), Cell::from(peers), Cell::from(quality_name), ]) .primary() }; let mut release_table = &mut app.data.lidarr_data.discography_releases; let artist_release_table = ManagarrTable::new(Some(&mut release_table), release_row_mapping) .block(layout_block_top_border()) .loading(app.is_loading || is_empty) .sorting(active_lidarr_block == ActiveLidarrBlock::ManualArtistSearchSortPrompt) .headers([ "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Quality", ]) .constraints([ Constraint::Length(9), Constraint::Length(10), Constraint::Length(5), Constraint::Percentage(35), Constraint::Percentage(15), Constraint::Length(12), Constraint::Length(12), Constraint::Percentage(10), ]); f.render_widget(artist_release_table, area); } } fn draw_manual_artist_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { let current_selection = app .data .lidarr_data .discography_releases .current_selection(); let title = if current_selection.rejected { "Download Rejected Release" } else { "Download Release" }; let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", ¤t_selection.title.text ) } else { format!( "Do you want to download the release: {}?", ¤t_selection.title.text ) }; if current_selection.rejected { let mut lines_vec = vec![Line::from("Rejection reasons: ".primary().bold())]; let mut rejections_spans = current_selection .rejections .clone() .unwrap_or_default() .iter() .map(|item| Line::from(format!("• {item}").primary().bold())) .collect::>>(); lines_vec.append(&mut rejections_spans); let content_paragraph = Paragraph::new(lines_vec) .block(borderless_block()) .wrap(Wrap { trim: false }) .left_aligned(); let confirmation_prompt = ConfirmationPrompt::new() .title(title) .prompt(&prompt) .content(content_paragraph) .yes_no_value(app.data.lidarr_data.prompt_confirm); f.render_widget(Popup::new(confirmation_prompt).size(Size::Small), f.area()); } else { let confirmation_prompt = ConfirmationPrompt::new() .title(title) .prompt(&prompt) .yes_no_value(app.data.lidarr_data.prompt_confirm); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), ); } }