use crate::app::App; use crate::models::Route; use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease, Track}; use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock}; use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::{ borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_top_border, }; 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::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; use ratatui::prelude::{Line, Stylize, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use serde_json::Number; #[cfg(test)] #[path = "album_details_ui_tests.rs"] mod album_details_ui_tests; pub(super) struct AlbumDetailsUi; impl DrawUi for AlbumDetailsUi { fn accepts(route: Route) -> bool { let Route::Lidarr(active_lidarr_block, _) = route else { return false; }; ALBUM_DETAILS_BLOCKS.contains(&active_lidarr_block) } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { if app.data.lidarr_data.album_details_modal.is_some() && let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { let draw_album_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { let content_area = draw_tabs( f, popup_area, &format!( "{} Details", app.data.lidarr_data.albums.current_selection().title.text ), &app .data .lidarr_data .album_details_modal .as_ref() .expect("album_details_modal must exist in this context") .album_details_tabs, ); draw_album_details(f, app, content_area); match active_lidarr_block { ActiveLidarrBlock::AutomaticallySearchAlbumPrompt => { let prompt = format!( "Do you want to trigger an automatic search of your indexers for the album: {}?", app.data.lidarr_data.albums.current_selection().title.text ); let confirmation_prompt = ConfirmationPrompt::new() .title("Automatic Album 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::DeleteTrackFilePrompt => { let prompt = format!( "Do you really want to delete this track file: \n{}?", app .data .lidarr_data .album_details_modal .as_ref() .expect("album_details_modal must exist in this context") .tracks .current_selection() .title ); let confirmation_prompt = ConfirmationPrompt::new() .title("Delete Track File") .prompt(&prompt) .yes_no_value(app.data.lidarr_data.prompt_confirm); f.render_widget( Popup::new(confirmation_prompt).size(Size::MediumPrompt), f.area(), ); } ActiveLidarrBlock::ManualAlbumSearchConfirmPrompt => { draw_manual_album_search_confirm_prompt(f, app); } ActiveLidarrBlock::AlbumHistoryDetails => { draw_history_item_details_popup(f, app); } _ => (), } }; draw_popup(f, app, draw_album_details_popup, Size::XLarge); } } } pub fn draw_album_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Some(album_details_modal) = app.data.lidarr_data.album_details_modal.as_ref() && let Route::Lidarr(active_lidarr_block, _) = album_details_modal.album_details_tabs.get_active_route() { match active_lidarr_block { ActiveLidarrBlock::AlbumDetails => draw_tracks_table(f, app, area), ActiveLidarrBlock::AlbumHistory => draw_album_history_table(f, app, area), ActiveLidarrBlock::ManualAlbumSearch => draw_album_releases(f, app, area), _ => (), } } } fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { let track_files = app .data .lidarr_data .album_details_modal .as_ref() .expect("Album details modal is unpopulated") .track_files .items .clone(); let content = Some( &mut app .data .lidarr_data .album_details_modal .as_mut() .expect("Album details modal is unpopulated") .tracks, ); let track_row_mapping = |track: &Track| { let Track { track_number, title, duration, track_file_id, has_file, .. } = track; let track_file = track_files .iter() .find(|track_file| track_file.id == *track_file_id); let quality = if let Some(track_file) = track_file { track_file.quality.quality.name.to_owned() } else { String::new() }; let audio_info = track_file .and_then(|tf| tf.media_info.as_ref()) .map(|mi| { let codec = mi.audio_codec.as_deref().unwrap_or(""); let channels = format!("{}.0", mi.audio_channels); let bitrate = mi.audio_bit_rate.as_deref().unwrap_or(""); let sample_rate = mi.audio_sample_rate.as_deref().unwrap_or(""); let bits = mi.audio_bits.as_deref().unwrap_or(""); format!("{codec} - {channels} - {bitrate} - {sample_rate} - {bits}") }) .unwrap_or_default(); let duration_secs = duration / 1000; let mins = duration_secs / 60; let secs = duration_secs % 60; let duration_str = format!("{mins}:{secs:02}"); let row = Row::new(vec![ Cell::from(track_number.clone()), Cell::from(title.clone()), Cell::from(duration_str), Cell::from(audio_info), Cell::from(quality), ]); if *has_file { row.downloaded() } else { row.missing() } }; let is_searching = active_lidarr_block == ActiveLidarrBlock::SearchTracks; let tracks_table = ManagarrTable::new(content, track_row_mapping) .block(layout_block_top_border()) .loading(app.is_loading) .searching(is_searching) .search_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::SearchTracksError) .headers(["#", "Title", "Duration", "Audio Info", "Quality"]) .constraints([ Constraint::Percentage(5), Constraint::Percentage(35), Constraint::Percentage(8), Constraint::Percentage(37), Constraint::Percentage(15), ]); if is_searching { tracks_table.show_cursor(f, area); } f.render_widget(tracks_table, area); } } fn draw_album_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { match app.data.lidarr_data.album_details_modal.as_ref() { Some(album_details_modal) if !app.is_loading => { let current_selection = if album_details_modal.album_history.is_empty() { LidarrHistoryItem::default() } else { album_details_modal .album_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 mut album_history_table = &mut app .data .lidarr_data .album_details_modal .as_mut() .expect("album_details_modal must exist in this context") .album_history; let history_table = ManagarrTable::new(Some(&mut album_history_table), history_row_mapping) .block(layout_block_top_border()) .loading(app.is_loading) .sorting(active_lidarr_block == ActiveLidarrBlock::AlbumHistorySortPrompt) .searching(active_lidarr_block == ActiveLidarrBlock::SearchAlbumHistory) .search_produced_empty_results( active_lidarr_block == ActiveLidarrBlock::SearchAlbumHistoryError, ) .filtering(active_lidarr_block == ActiveLidarrBlock::FilterAlbumHistory) .filter_produced_empty_results( active_lidarr_block == ActiveLidarrBlock::FilterAlbumHistoryError, ) .headers(["Source Title", "Event Type", "Quality", "Date"]) .constraints([ Constraint::Percentage(40), Constraint::Percentage(20), Constraint::Percentage(15), Constraint::Percentage(25), ]); if [ ActiveLidarrBlock::SearchAlbumHistory, ActiveLidarrBlock::FilterAlbumHistory, ] .contains(&active_lidarr_block) { history_table.show_cursor(f, area); } f.render_widget(history_table, area); } } _ => f.render_widget( LoadingBlock::new( app.is_loading || app.data.lidarr_data.album_details_modal.is_none(), layout_block_top_border(), ), area, ), } } fn draw_album_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { match app.data.lidarr_data.album_details_modal.as_ref() { Some(album_details_modal) if !app.is_loading => { let (current_selection, is_empty) = if album_details_modal.album_releases.is_empty() { (LidarrRelease::default(), true) } else { ( album_details_modal .album_releases .current_selection() .clone(), album_details_modal.album_releases.is_empty(), ) }; if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { let album_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::ManualAlbumSearchConfirmPrompt, 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(); let leechers = leechers .clone() .unwrap_or(Number::from(0u64)) .as_u64() .unwrap(); 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 album_release_table = &mut app .data .lidarr_data .album_details_modal .as_mut() .expect("album_details_modal must exist in this context") .album_releases; let release_table = ManagarrTable::new(Some(&mut album_release_table), album_release_row_mapping) .block(layout_block_top_border()) .loading(app.is_loading || is_empty) .sorting(active_lidarr_block == ActiveLidarrBlock::ManualAlbumSearchSortPrompt) .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(release_table, area); } } _ => f.render_widget( LoadingBlock::new( app.is_loading || app.data.lidarr_data.album_details_modal.is_none(), layout_block_top_border(), ), area, ), } } fn draw_manual_album_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { let current_selection = app .data .lidarr_data .album_details_modal .as_ref() .expect("album_details_modal must exist in this context") .album_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(), ); } } fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { let current_selection = if let Some(album_details_modal) = app.data.lidarr_data.album_details_modal.as_ref() { if album_details_modal.album_history.is_empty() { LidarrHistoryItem::default() } else { album_details_modal .album_history .current_selection() .clone() } } else { LidarrHistoryItem::default() }; 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()); }