Overhaul breadarr-tui UX: filter/sort, color, help overlay, cross-nav
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.
This commit is contained in:
parent
99604a7e55
commit
6e7be67f0b
5 changed files with 534 additions and 74 deletions
|
|
@ -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<MediaItemSummary>,
|
||||
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<usize>,
|
||||
pub library_filter: LibraryFilter,
|
||||
pub library_sort: LibrarySort,
|
||||
pub detail: Option<MediaItemDetail>,
|
||||
/// 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<StuckReport>,
|
||||
pub stuck_focus: StuckSection,
|
||||
pub stalled_state: ListState,
|
||||
pub maxed_state: ListState,
|
||||
pub calendar: Vec<CalendarEntry>,
|
||||
pub library_health: Option<LibraryHealthReport>,
|
||||
|
||||
|
|
@ -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<usize> = 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Line> = 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<ListItem> = 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<ListItem> = 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<ListItem> = 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<String> = 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue