From 6e7be67f0bd743eb5711fee7e60d0d3a66ff1bcf Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 20:55:04 +0800 Subject: [PATCH] Overhaul breadarr-tui UX: filter/sort, color, help overlay, cross-nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library tab gets a cycling filter (all/series/movies/missing/unmonitored) and sort (title/missing/kind), color-coded kind tags and missing-count severity, and monitor-toggle without opening detail. Keybinding hints move out of cramped block titles into a context-aware status bar plus a `?` help overlay, both driven by one shared keybinding table so they can't drift apart. Episode status icons and review-queue confidence get the same severity coloring. Stuck tab gains real selection/focus and can jump straight to a show's Library detail (needed adding media_item_id to StalledGrab's query — the one small backend touch in this pass). Adds a manual refresh-now key. --- breadarr-shared/src/dto.rs | 1 + breadarr-tui/src/app.rs | 254 +++++++++++++++++++++++- breadarr-tui/src/main.rs | 35 +++- breadarr-tui/src/ui.rs | 309 ++++++++++++++++++++++++------ breadarrd/src/api/routes/stuck.rs | 9 +- 5 files changed, 534 insertions(+), 74 deletions(-) diff --git a/breadarr-shared/src/dto.rs b/breadarr-shared/src/dto.rs index 72e330b..6e103d3 100644 --- a/breadarr-shared/src/dto.rs +++ b/breadarr-shared/src/dto.rs @@ -56,6 +56,7 @@ pub struct ReviewQueueEntry { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StalledGrab { pub release_id: i64, + pub media_item_id: i64, pub media_title: String, pub raw_title: String, pub grabbed_at: String, diff --git a/breadarr-tui/src/app.rs b/breadarr-tui/src/app.rs index f3b1ac5..e97c265 100644 --- a/breadarr-tui/src/app.rs +++ b/breadarr-tui/src/app.rs @@ -74,6 +74,88 @@ impl AddKind { } } +/// Client-side filter over the already-fetched `media_items` — the server +/// has no filter/sort query params, and the library is small enough that +/// refiltering the in-memory `Vec` on every keypress is free. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LibraryFilter { + All, + Series, + Movies, + Missing, + Unmonitored, +} + +impl LibraryFilter { + pub const ALL: [LibraryFilter; 5] = [ + LibraryFilter::All, + LibraryFilter::Series, + LibraryFilter::Movies, + LibraryFilter::Missing, + LibraryFilter::Unmonitored, + ]; + + pub fn next(self) -> Self { + let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0); + Self::ALL[(idx + 1) % Self::ALL.len()] + } + + pub fn label(&self) -> &'static str { + match self { + LibraryFilter::All => "all", + LibraryFilter::Series => "series", + LibraryFilter::Movies => "movies", + LibraryFilter::Missing => "missing", + LibraryFilter::Unmonitored => "unmonitored", + } + } + + fn matches(&self, m: &MediaItemSummary) -> bool { + match self { + LibraryFilter::All => true, + LibraryFilter::Series => m.kind == "series", + LibraryFilter::Movies => m.kind == "movie", + LibraryFilter::Missing => m.missing_count > 0, + LibraryFilter::Unmonitored => !m.monitored, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LibrarySort { + TitleAsc, + MissingDesc, + Kind, +} + +impl LibrarySort { + pub const ALL: [LibrarySort; 3] = [ + LibrarySort::TitleAsc, + LibrarySort::MissingDesc, + LibrarySort::Kind, + ]; + + pub fn next(self) -> Self { + let idx = Self::ALL.iter().position(|s| *s == self).unwrap_or(0); + Self::ALL[(idx + 1) % Self::ALL.len()] + } + + pub fn label(&self) -> &'static str { + match self { + LibrarySort::TitleAsc => "title", + LibrarySort::MissingDesc => "missing", + LibrarySort::Kind => "kind", + } + } +} + +/// Which half of the Stuck tab has selection/arrow-key focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StuckSection { + Stalled, + Maxed, +} + /// A weight axis's display name plus a getter/setter pair, so /// `WEIGHT_FIELDS` can enumerate `WeightsDto`'s fields by position instead /// of every caller matching on an index. @@ -123,9 +205,24 @@ pub struct App { pub focus: Focus, pub status: String, pub should_quit: bool, + /// Toggled by `?`; intercepted at the top of `main.rs`'s key handler + /// like `Focus::AddSearchInput`/`Focus::WeightInput`, but kept as its + /// own bool (not folded into `Focus`) since it needs to open from any + /// tab rather than being scoped to one. + pub help_visible: bool, + /// Set by the `R` key; consumed by `main.rs`'s refresh loop to bypass + /// the normal 3s throttle for one immediate refresh. + pub force_refresh: bool, pub media_items: Vec, pub media_state: ListState, + /// Filtered+sorted indices into `media_items`, recomputed by + /// `recompute_library_view` — the top-level Library list and + /// `media_state` always operate over this, never over `media_items` + /// directly, so filter/sort never has to touch the raw fetched data. + pub library_view: Vec, + pub library_filter: LibraryFilter, + pub library_sort: LibrarySort, pub detail: Option, /// Selection within `detail.episodes` — separate from `media_state` /// since they're two different lists sharing the same tab. @@ -142,6 +239,9 @@ pub struct App { pub add_results_state: ListState, pub stuck: Option, + pub stuck_focus: StuckSection, + pub stalled_state: ListState, + pub maxed_state: ListState, pub calendar: Vec, pub library_health: Option, @@ -185,8 +285,13 @@ impl App { focus: Focus::List, status: String::new(), should_quit: false, + help_visible: false, + force_refresh: false, media_items: Vec::new(), media_state: ListState::default(), + library_view: Vec::new(), + library_filter: LibraryFilter::All, + library_sort: LibrarySort::TitleAsc, detail: None, episode_state: ListState::default(), releases: Vec::new(), @@ -197,6 +302,9 @@ impl App { add_results: Vec::new(), add_results_state: ListState::default(), stuck: None, + stuck_focus: StuckSection::Stalled, + stalled_state: ListState::default(), + maxed_state: ListState::default(), calendar: Vec::new(), library_health: None, candidates: Vec::new(), @@ -236,9 +344,7 @@ impl App { } } else { self.media_items = self.client.list_media().await?; - if self.media_state.selected().is_none() && !self.media_items.is_empty() { - self.media_state.select(Some(0)); - } + self.recompute_library_view(); } } Tab::History => { @@ -255,7 +361,17 @@ impl App { } Tab::Add => {} Tab::Stuck => { - self.stuck = Some(self.client.stuck().await?); + let report = self.client.stuck().await?; + if self.stalled_state.selected().is_none() && !report.stalled_grabs.is_empty() + { + self.stalled_state.select(Some(0)); + } + if self.maxed_state.selected().is_none() + && !report.maxed_out_search_targets.is_empty() + { + self.maxed_state.select(Some(0)); + } + self.stuck = Some(report); } Tab::Calendar => { self.calendar = self.client.calendar().await?; @@ -287,13 +403,68 @@ impl App { self.detail.as_ref().map(|d| d.id) } + /// Rebuilds `library_view` from `media_items` under the current + /// filter/sort, then re-selects whichever item was selected before (by + /// id, not raw index) if it's still in view — falls back to the first + /// item, or no selection if the view is now empty. Called after + /// `media_items` changes, and after `library_filter`/`library_sort` + /// change. + pub fn recompute_library_view(&mut self) { + let previously_selected_id = self.selected_media_item().map(|m| m.id); + + let mut indices: Vec = self + .media_items + .iter() + .enumerate() + .filter(|(_, m)| self.library_filter.matches(m)) + .map(|(i, _)| i) + .collect(); + + match self.library_sort { + LibrarySort::TitleAsc => indices.sort_by(|&a, &b| { + self.media_items[a] + .title + .to_lowercase() + .cmp(&self.media_items[b].title.to_lowercase()) + }), + LibrarySort::MissingDesc => indices.sort_by(|&a, &b| { + self.media_items[b] + .missing_count + .cmp(&self.media_items[a].missing_count) + }), + LibrarySort::Kind => indices.sort_by(|&a, &b| { + self.media_items[a].kind.cmp(&self.media_items[b].kind).then_with(|| { + self.media_items[a] + .title + .to_lowercase() + .cmp(&self.media_items[b].title.to_lowercase()) + }) + }), + } + self.library_view = indices; + + match previously_selected_id + .and_then(|id| self.library_view.iter().position(|&i| self.media_items[i].id == id)) + { + Some(pos) => self.media_state.select(Some(pos)), + None if !self.library_view.is_empty() => self.media_state.select(Some(0)), + None => self.media_state.select(None), + } + } + + pub fn selected_media_item(&self) -> Option<&MediaItemSummary> { + let idx = self.media_state.selected()?; + let real_idx = *self.library_view.get(idx)?; + self.media_items.get(real_idx) + } + pub fn move_selection(&mut self, delta: i32) { let (state, len) = match self.tab { Tab::Library if matches!(self.focus, Focus::Candidates) => { (&mut self.candidates_state, self.candidates.len()) } Tab::Library if self.detail.is_none() => { - (&mut self.media_state, self.media_items.len()) + (&mut self.media_state, self.library_view.len()) } Tab::Library => ( &mut self.episode_state, @@ -302,6 +473,15 @@ impl App { Tab::History => (&mut self.releases_state, self.releases.len()), Tab::Review => (&mut self.review_state, self.review_items.len()), Tab::Add => (&mut self.add_results_state, self.add_results.len()), + Tab::Stuck => { + let report_len = self.stuck.as_ref().map_or((0, 0), |r| { + (r.stalled_grabs.len(), r.maxed_out_search_targets.len()) + }); + match self.stuck_focus { + StuckSection::Stalled => (&mut self.stalled_state, report_len.0), + StuckSection::Maxed => (&mut self.maxed_state, report_len.1), + } + } Tab::Profiles if self.profile_detail.is_some() => { (&mut self.profile_weight_state, WEIGHT_FIELDS.len()) } @@ -320,10 +500,7 @@ impl App { if !matches!(self.tab, Tab::Library) || self.detail.is_some() { return; } - let Some(idx) = self.media_state.selected() else { - return; - }; - let Some(item) = self.media_items.get(idx) else { + let Some(item) = self.selected_media_item() else { return; }; match self.client.media_detail(item.id).await { @@ -707,6 +884,28 @@ impl App { } } + /// Toggles monitored for whatever's selected in the top-level Library + /// list, without needing to open its detail view first. + pub async fn toggle_monitor_list_selected(&mut self) { + let Some(item) = self.selected_media_item() else { + return; + }; + let (id, monitored) = (item.id, item.monitored); + let result = if monitored { + self.client.unmonitor(id).await + } else { + self.client.monitor(id).await + }; + match result { + Ok(()) => { + self.status = "monitor state updated".to_string(); + self.media_items = self.client.list_media().await.unwrap_or_default(); + self.recompute_library_view(); + } + Err(e) => self.status = format!("monitor toggle failed: {e}"), + } + } + pub async fn toggle_monitor_selected(&mut self) { let Some(detail) = &self.detail else { return; @@ -745,6 +944,7 @@ impl App { self.status = "deleted".to_string(); self.detail = None; self.media_items = self.client.list_media().await.unwrap_or_default(); + self.recompute_library_view(); } Err(e) => self.status = format!("delete failed: {e}"), } @@ -789,4 +989,40 @@ impl App { Err(e) => self.status = format!("file delete failed: {e}"), } } + + /// Jumps from whichever Stuck-tab row is selected straight to that + /// show's Library detail view. + pub async fn jump_to_stuck_target(&mut self) { + let Some(report) = &self.stuck else { + return; + }; + let media_item_id = match self.stuck_focus { + StuckSection::Maxed => self + .maxed_state + .selected() + .and_then(|i| report.maxed_out_search_targets.get(i)) + .map(|t| t.media_item_id), + StuckSection::Stalled => self + .stalled_state + .selected() + .and_then(|i| report.stalled_grabs.get(i)) + .map(|g| g.media_item_id), + }; + let Some(id) = media_item_id else { + return; + }; + match self.client.media_detail(id).await { + Ok(detail) => { + self.tab = Tab::Library; + self.episode_state.select(if detail.episodes.is_empty() { + None + } else { + Some(0) + }); + self.detail = Some(detail); + self.focus = Focus::List; + } + Err(e) => self.status = format!("error loading detail: {e}"), + } + } } diff --git a/breadarr-tui/src/main.rs b/breadarr-tui/src/main.rs index bfff7bd..627b771 100644 --- a/breadarr-tui/src/main.rs +++ b/breadarr-tui/src/main.rs @@ -14,7 +14,7 @@ use crossterm::terminal::{ use ratatui::backend::CrosstermBackend; use ratatui::Terminal; -use app::{App, Focus, Tab}; +use app::{App, Focus, StuckSection, Tab}; #[tokio::main] async fn main() -> Result<()> { @@ -47,8 +47,9 @@ async fn run( let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10); loop { - if last_refresh.elapsed() >= Duration::from_secs(3) { + if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh { app.refresh_active_tab().await; + app.force_refresh = false; last_refresh = tokio::time::Instant::now(); } @@ -101,6 +102,16 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) { return; } + // Help overlay intercepts everything while open, same + // priority-over-global-keys idiom as the two input modes above. + if app.help_visible { + match code { + KeyCode::Char('?') | KeyCode::Esc => app.help_visible = false, + _ => {} + } + return; + } + // Any key other than a second `x`/`d` clears a pending delete // confirmation — the confirmation must be the very next keypress, not // just "any keypress before the user gets distracted." @@ -136,8 +147,28 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) { app.start_editing_selected_weight(); } Tab::Profiles => app.open_profile_detail(), + Tab::Stuck => app.jump_to_stuck_target().await, _ => {} }, + KeyCode::Left | KeyCode::Right if matches!(app.tab, Tab::Stuck) => { + app.stuck_focus = match app.stuck_focus { + StuckSection::Stalled => StuckSection::Maxed, + StuckSection::Maxed => StuckSection::Stalled, + }; + } + KeyCode::Char('?') => app.help_visible = true, + KeyCode::Char('R') => app.force_refresh = true, + KeyCode::Char('f') if matches!(app.tab, Tab::Library) && app.detail.is_none() => { + app.library_filter = app.library_filter.next(); + app.recompute_library_view(); + } + KeyCode::Char('o') if matches!(app.tab, Tab::Library) && app.detail.is_none() => { + app.library_sort = app.library_sort.next(); + app.recompute_library_view(); + } + KeyCode::Char('m') if matches!(app.tab, Tab::Library) && app.detail.is_none() => { + app.toggle_monitor_list_selected().await; + } KeyCode::Char('a') if matches!(app.tab, Tab::Review) => { app.approve_selected_review().await; } diff --git a/breadarr-tui/src/ui.rs b/breadarr-tui/src/ui.rs index 20d6910..c09c49d 100644 --- a/breadarr-tui/src/ui.rs +++ b/breadarr-tui/src/ui.rs @@ -1,10 +1,20 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs}; use ratatui::Frame; -use crate::app::{App, Focus, Tab}; +use crate::app::{App, Focus, StuckSection, Tab}; + +// Shared color palette — kept to these meanings so a color never has to be +// second-guessed at a glance: +// Green healthy / 0 missing / high confidence / not stuck +// Yellow warning / low missing / mid confidence / at backoff ceiling +// Red critical / high missing / low confidence / past ceiling +// DarkGray unmonitored / muted / not currently relevant +// Blue TV kind tag (categorical, not severity) +// Magenta Movie kind tag (categorical, not severity) +// Cyan focus/interactive accent (tab highlight, active input, focused section) — never severity pub fn draw(frame: &mut Frame, app: &App) { let chunks = Layout::default() @@ -30,6 +40,102 @@ pub fn draw(frame: &mut Frame, app: &App) { } draw_status(frame, chunks[2], app); + + if app.help_visible { + draw_help_overlay(frame, frame.area(), app); + } +} + +/// Keybindings relevant to the current tab/focus/detail state, in the order +/// they should be shown — the single source of truth shared by the status +/// bar (which shows a short prefix) and the help overlay (which shows all of +/// it plus `GLOBAL_KEYS`), so the two can't drift apart. +fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { + match app.tab { + Tab::Library if matches!(app.focus, Focus::Candidates) => { + vec![("Enter", "grab"), ("Esc", "cancel")] + } + Tab::Library if app.detail.is_some() => vec![ + ("Esc", "back"), + ("s", "search now"), + ("m", "monitor show"), + ("e", "monitor episode"), + ("S", "monitor season"), + ("x", "delete show"), + ("d", "delete file"), + ("c", "pick release"), + ], + Tab::Library => vec![ + ("Enter", "open detail"), + ("f", "cycle filter"), + ("o", "cycle sort"), + ("m", "monitor toggle"), + ], + Tab::Review => vec![("a", "approve"), ("r", "reject")], + Tab::Add => match app.focus { + Focus::AddSearchInput => vec![("Enter", "search")], + _ => vec![("Enter", "add"), ("Esc", "back to search")], + }, + Tab::Profiles if app.profile_detail.is_some() => { + vec![("Enter", "edit weight"), ("Esc", "back")] + } + Tab::Profiles => vec![("Enter", "open profile")], + Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")], + _ => vec![], + } +} + +const GLOBAL_KEYS: &[(&str, &str)] = &[ + ("Tab", "switch tab"), + ("j/k", "move"), + ("?", "help"), + ("R", "refresh now"), + ("q", "quit"), +]; + +/// Centers a `width` x `height` rect inside `area` — standard ratatui idiom +/// for a popup/overlay. +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let width = width.min(area.width); + let height = height.min(area.height); + Rect { + x: area.x + (area.width.saturating_sub(width)) / 2, + y: area.y + (area.height.saturating_sub(height)) / 2, + width, + height, + } +} + +fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) { + let mut lines: Vec = context_keybindings(app) + .into_iter() + .map(|(key, desc)| { + Line::from(vec![ + Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)), + Span::raw(desc), + ]) + }) + .collect(); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Global", + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.extend(GLOBAL_KEYS.iter().map(|(key, desc)| { + Line::from(vec![ + Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)), + Span::raw(*desc), + ]) + })); + + let popup = centered_rect(50, lines.len() as u16 + 2, area); + frame.render_widget(Clear, popup); + let paragraph = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title("Help — ? or Esc to close"), + ); + frame.render_widget(paragraph, popup); } fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) { @@ -88,18 +194,21 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { .episodes .iter() .map(|e| { - let status = if e.has_file { - "✓" + let (status, color) = if e.has_file { + ("✓", Color::Green) } else if e.monitored { - "…" + ("…", Color::Yellow) } else { - "-" + ("-", Color::DarkGray) }; let title = e.title.as_deref().unwrap_or(""); - ListItem::new(format!( - "{status} S{:02}E{:02} {title}", - e.season_number, e.episode_number - )) + ListItem::new(Line::from(vec![ + Span::styled(status, Style::default().fg(color)), + Span::raw(format!( + " S{:02}E{:02} {title}", + e.season_number, e.episode_number + )), + ])) }) .collect(); let monitor_label = if detail.monitored { @@ -116,9 +225,7 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { }; let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(format!( - "{} ({}) [{monitor_label}] — Esc: back s: search now m: monitor show \ - e: monitor episode S: monitor season x: delete show d: delete file \ - c: pick release{confirm}", + "{} ({}) [{monitor_label}]{confirm}", detail.title, detail.year.map(|y| y.to_string()).unwrap_or_default() ))) @@ -129,28 +236,64 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { } let items: Vec = app - .media_items + .library_view .iter() - .map(|m| { - let missing = if m.missing_count > 0 { + .map(|&i| { + let m = &app.media_items[i]; + let kind_tag = if m.kind == "movie" { "[Movie]" } else { "[TV]" }; + let kind_color = if m.kind == "movie" { + Color::Magenta + } else { + Color::Blue + }; + let ratio = if m.episode_count > 0 { + m.missing_count as f64 / m.episode_count as f64 + } else if m.missing_count > 0 { + 1.0 + } else { + 0.0 + }; + // Mute severity color for unmonitored items — a missing count + // on something deliberately unmonitored isn't actionable. + let missing_color = if !m.monitored || m.missing_count == 0 { + Color::DarkGray + } else if ratio <= 0.25 { + Color::Yellow + } else { + Color::Red + }; + let missing_text = if m.missing_count > 0 { format!(" — {} missing", m.missing_count) } else { String::new() }; - ListItem::new(format!( - "{} ({}){}", - m.title, - m.year.map(|y| y.to_string()).unwrap_or_default(), - missing - )) + let title_style = if m.monitored { + Style::default() + } else { + Style::default().fg(Color::DarkGray) + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("{kind_tag} "), Style::default().fg(kind_color)), + Span::styled( + format!( + "{} ({})", + m.title, + m.year.map(|y| y.to_string()).unwrap_or_default() + ), + title_style, + ), + Span::styled(missing_text, Style::default().fg(missing_color)), + ])) }) .collect(); let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title("Monitored Shows — Enter for detail"), - ) + .block(Block::default().borders(Borders::ALL).title(format!( + "Monitored Shows ({}/{}) — filter: {} sort: {}", + app.library_view.len(), + app.media_items.len(), + app.library_filter.label(), + app.library_sort.label() + ))) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); let mut state = app.media_state.clone(); frame.render_stateful_widget(list, area, &mut state); @@ -233,12 +376,21 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) { .review_items .iter() .map(|r| { - ListItem::new(format!( - "({:.0}%) {} -> {}", - r.confidence * 100.0, - r.raw_release_title, - r.candidate_media_title.as_deref().unwrap_or("?") - )) + let color = if r.confidence < 0.70 { + Color::Red + } else if r.confidence < 0.85 { + Color::Yellow + } else { + Color::Green + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("({:.0}%)", r.confidence * 100.0), Style::default().fg(color)), + Span::raw(format!( + " {} -> {}", + r.raw_release_title, + r.candidate_media_title.as_deref().unwrap_or("?") + )), + ])) }) .collect(); let list = List::new(items) @@ -256,6 +408,11 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) { /// than expected, how deep the review queue has backed up, and search /// targets that have been failing every attempt long enough for their /// backoff to hit its ceiling. Read-only report, no selection/navigation. +// Mirrors breadarrd/src/api/routes/stuck.rs::MAXED_SEARCH_COUNT. Duplicated +// because the daemon doesn't expose it via the API; if it drifts this is +// cosmetic only (wrong shade), not a behavior bug. +const MAXED_SEARCH_COUNT: i64 = 6; + fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) { let Some(report) = &app.stuck else { let placeholder = Paragraph::new("loading...").block( @@ -272,42 +429,71 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) { .constraints([Constraint::Min(3), Constraint::Min(3)]) .split(area); + let stalled_border = if matches!(app.stuck_focus, StuckSection::Stalled) { + Color::Cyan + } else { + Color::Reset + }; let stalled_items: Vec = report .stalled_grabs .iter() .map(|g| { - ListItem::new(format!( - "{} — {} (grabbed {})", - g.media_title, g.raw_title, g.grabbed_at - )) + ListItem::new(Line::from(Span::styled( + format!("{} — {} (grabbed {})", g.media_title, g.raw_title, g.grabbed_at), + Style::default().fg(Color::Yellow), + ))) }) .collect(); - let stalled_list = - List::new(stalled_items).block(Block::default().borders(Borders::ALL).title(format!( - "Stalled grabs ({}) — review queue: {} pending", - report.stalled_grabs.len(), - report.review_queue_depth - ))); - frame.render_widget(stalled_list, chunks[0]); + let stalled_list = List::new(stalled_items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(stalled_border)) + .title(format!( + "Stalled grabs ({}) — review queue: {} pending", + report.stalled_grabs.len(), + report.review_queue_depth + )), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut stalled_state = app.stalled_state.clone(); + frame.render_stateful_widget(stalled_list, chunks[0], &mut stalled_state); + let maxed_border = if matches!(app.stuck_focus, StuckSection::Maxed) { + Color::Cyan + } else { + Color::Reset + }; let maxed_items: Vec = report .maxed_out_search_targets .iter() .map(|t| { - ListItem::new(format!( - "{} — {} attempts, last searched {}", - t.media_title, - t.search_count, - t.last_searched_at.as_deref().unwrap_or("never") - )) + let color = if t.search_count > MAXED_SEARCH_COUNT { + Color::Red + } else { + Color::Yellow + }; + ListItem::new(Line::from(Span::styled( + format!( + "{} — {} attempts, last searched {}", + t.media_title, + t.search_count, + t.last_searched_at.as_deref().unwrap_or("never") + ), + Style::default().fg(color), + ))) }) .collect(); - let maxed_list = List::new(maxed_items).block( - Block::default() - .borders(Borders::ALL) - .title("Search targets at max backoff"), - ); - frame.render_widget(maxed_list, chunks[1]); + let maxed_list = List::new(maxed_items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(maxed_border)) + .title("Search targets at max backoff — Enter: jump to show"), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut maxed_state = app.maxed_state.clone(); + frame.render_stateful_widget(maxed_list, chunks[1], &mut maxed_state); } fn draw_add(frame: &mut Frame, area: Rect, app: &App) { @@ -549,10 +735,15 @@ fn draw_profiles(frame: &mut Frame, area: Rect, app: &App) { } fn draw_status(frame: &mut Frame, area: Rect, app: &App) { - let text = if app.status.is_empty() { - "Tab: switch view | j/k: move | q: quit".to_string() - } else { + let text = if !app.status.is_empty() { app.status.clone() + } else { + let hints: Vec = context_keybindings(app) + .into_iter() + .take(4) + .map(|(key, desc)| format!("{key}: {desc}")) + .collect(); + format!("{} | ?: help", hints.join(" | ")) }; let status = Paragraph::new(text).block(Block::default().borders(Borders::ALL)); frame.render_widget(status, area); diff --git a/breadarrd/src/api/routes/stuck.rs b/breadarrd/src/api/routes/stuck.rs index c4d6888..5cae652 100644 --- a/breadarrd/src/api/routes/stuck.rs +++ b/breadarrd/src/api/routes/stuck.rs @@ -23,7 +23,7 @@ pub async fn stuck( let mut stmt = conn .prepare( - "SELECT r.id, m.title, r.raw_title, r.grabbed_at + "SELECT r.id, r.media_item_id, m.title, r.raw_title, r.grabbed_at FROM release r JOIN media_item m ON m.id = r.media_item_id WHERE r.status = 'grabbed' AND (julianday('now') - julianday(r.grabbed_at)) * 24.0 > ?1 @@ -34,9 +34,10 @@ pub async fn stuck( .query_map([STALLED_GRAB_HOURS], |row| { Ok(StalledGrab { release_id: row.get(0)?, - media_title: row.get(1)?, - raw_title: row.get(2)?, - grabbed_at: row.get(3)?, + media_item_id: row.get(1)?, + media_title: row.get(2)?, + raw_title: row.get(3)?, + grabbed_at: row.get(4)?, }) }) .map_err(internal)?