Merge feature/tui-ux into main
TUI navigation and readability improvements on top of the TPB supplement sources.
This commit is contained in:
commit
5a98860185
4 changed files with 891 additions and 312 deletions
12
README.md
12
README.md
|
|
@ -54,13 +54,13 @@ Install via bakery (`bakery install breadarr`) on a homelab host, or build from
|
||||||
|
|
||||||
## Using the TUI
|
## Using the TUI
|
||||||
|
|
||||||
`Tab` cycles Library / History / Review Queue / Add Show / Stuck / Calendar / Health / Profiles. `j`/`k` or arrow keys navigate, `Enter` opens detail or runs a search, `Esc` backs out.
|
`Tab` / `Shift+Tab` cycle Library / History / Review / Add / Stuck / Calendar / Health / Profiles; `1`–`8` jump straight to a tab. `j`/`k` or arrows move, `g`/`G` jump to first/last, `PgUp`/`PgDn` page, `Enter` opens detail or runs a search, `Esc` backs out. `?` is the full key list.
|
||||||
|
|
||||||
- **Review Queue** — `a` approves and `r` rejects a pending low-confidence title match. Check this periodically, especially early on.
|
- **Review** — `a` approves and `r` rejects a pending low-confidence title match. The tab title badges the pending count. Check this periodically, especially early on.
|
||||||
- **Library** (with an item's detail open) — `s` triggers an immediate search-now pass for that item's backlog; `m`/`e`/`S` toggle monitored on the show/episode/season respectively; `x` (confirm with a second `x`) removes the item from tracking without touching files on disk; `d` (confirm with a second `d`) deletes a bad imported file from disk and clears its tracking, freeing the episode/movie to be re-grabbed on the next cycle — the redownload path for a file that turned out to be wrong or broken; `c` fetches the manual release picker for the selected episode/movie, `Enter` grabs the highlighted candidate, `Esc` cancels.
|
- **Library** — `/` incrementally filters the list by title; `f`/`o` cycle kind-filter and sort. With an item's detail open: `s` triggers an immediate search-now pass for that item's backlog; `n` jumps to the next missing monitored episode; `m`/`e`/`S` toggle monitored on the show/episode/season respectively; `x` (confirm with a second `x`) removes the item from tracking without touching files on disk; `d` (confirm with a second `d`) deletes a bad imported file from disk and clears its tracking, freeing the episode/movie to be re-grabbed on the next cycle — the redownload path for a file that turned out to be wrong or broken; `c` fetches the manual release picker for the selected episode/movie, `Enter` grabs the highlighted candidate, `Esc` cancels. Movies (no episode list) show a summary pane with the same keys.
|
||||||
- **Stuck** — surfaces grabs that look stalled (no download progress advancing, or missing from qBittorrent) before the daemon's own auto-fail timers would catch them.
|
- **Stuck** — surfaces grabs that look stalled (no download progress advancing, or missing from qBittorrent) before the daemon's own auto-fail timers would catch them. `Enter` jumps to that show.
|
||||||
- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window.
|
- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window. `Enter` opens the matching episode.
|
||||||
- **Health** — the library-health report (see below) rendered as a tab instead of curled by hand.
|
- **Health** — daemon cycle outcomes plus the library-health report (see below), scrollable.
|
||||||
- **Profiles** — quality-profile weight axes; open a profile and edit a weight in place.
|
- **Profiles** — quality-profile weight axes; open a profile and edit a weight in place.
|
||||||
|
|
||||||
## Operational notes
|
## Operational notes
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use breadarr_shared::{Config, DaemonClient};
|
use breadarr_shared::{Config, DaemonClient};
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
|
|
@ -51,6 +51,7 @@ async fn run(
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
app.poll_background().await;
|
app.poll_background().await;
|
||||||
|
app.expire_status();
|
||||||
|
|
||||||
if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh {
|
if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh {
|
||||||
app.refresh_active_tab().await;
|
app.refresh_active_tab().await;
|
||||||
|
|
@ -65,7 +66,7 @@ async fn run(
|
||||||
if key.kind != KeyEventKind::Press {
|
if key.kind != KeyEventKind::Press {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
handle_key(app, key.code, roots).await;
|
handle_key(app, key, roots).await;
|
||||||
if app.should_quit {
|
if app.should_quit {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +75,9 @@ async fn run(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) {
|
async fn handle_key(app: &mut App, key: KeyEvent, roots: &LibraryRoots) {
|
||||||
|
let code = key.code;
|
||||||
|
|
||||||
// Typing into the add-show search box takes priority over global keys.
|
// Typing into the add-show search box takes priority over global keys.
|
||||||
if matches!(app.tab, Tab::Add) && matches!(app.focus, Focus::AddSearchInput) {
|
if matches!(app.tab, Tab::Add) && matches!(app.focus, Focus::AddSearchInput) {
|
||||||
match code {
|
match code {
|
||||||
|
|
@ -86,7 +89,28 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
app.add_query.clear();
|
app.add_query.clear();
|
||||||
}
|
}
|
||||||
KeyCode::Tab => cycle_tab(app),
|
KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)),
|
||||||
|
KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incremental Library title filter — same input-mode isolation as Add.
|
||||||
|
if matches!(app.tab, Tab::Library) && matches!(app.focus, Focus::LibraryFilterInput) {
|
||||||
|
match code {
|
||||||
|
KeyCode::Enter => app.confirm_library_filter(),
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.library_query.push(c);
|
||||||
|
app.recompute_library_view();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.library_query.pop();
|
||||||
|
app.recompute_library_view();
|
||||||
|
}
|
||||||
|
KeyCode::Esc => app.clear_library_filter(),
|
||||||
|
KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)),
|
||||||
|
KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -131,12 +155,25 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) {
|
||||||
|
|
||||||
match code {
|
match code {
|
||||||
KeyCode::Char('q') => app.should_quit = true,
|
KeyCode::Char('q') => app.should_quit = true,
|
||||||
KeyCode::Tab => cycle_tab(app),
|
KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)),
|
||||||
|
KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)),
|
||||||
|
KeyCode::Char(c) if c.is_ascii_digit() => {
|
||||||
|
if let Some(n) = c.to_digit(10) {
|
||||||
|
if (1..=Tab::ALL.len() as u32).contains(&n) {
|
||||||
|
switch_tab(app, Tab::ALL[(n as usize) - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Char('j') | KeyCode::Down => app.move_selection(1),
|
KeyCode::Char('j') | KeyCode::Down => app.move_selection(1),
|
||||||
KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1),
|
KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1),
|
||||||
|
KeyCode::Char('g') => app.select_edge(false),
|
||||||
|
KeyCode::Char('G') => app.select_edge(true),
|
||||||
|
KeyCode::PageDown => app.move_selection(10),
|
||||||
|
KeyCode::PageUp => app.move_selection(-10),
|
||||||
KeyCode::Esc => match app.tab {
|
KeyCode::Esc => match app.tab {
|
||||||
Tab::Library if matches!(app.focus, Focus::Candidates) => app.close_candidates(),
|
Tab::Library if matches!(app.focus, Focus::Candidates) => app.close_candidates(),
|
||||||
Tab::Library if app.detail.is_some() => app.close_detail(),
|
Tab::Library if app.detail.is_some() => app.close_detail(),
|
||||||
|
Tab::Library if !app.library_query.is_empty() => app.clear_library_filter(),
|
||||||
Tab::Add => app.focus = Focus::AddSearchInput,
|
Tab::Add => app.focus = Focus::AddSearchInput,
|
||||||
Tab::Profiles if app.profile_detail.is_some() => app.close_profile_detail(),
|
Tab::Profiles if app.profile_detail.is_some() => app.close_profile_detail(),
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -155,6 +192,7 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) {
|
||||||
}
|
}
|
||||||
Tab::Profiles => app.open_profile_detail(),
|
Tab::Profiles => app.open_profile_detail(),
|
||||||
Tab::Stuck => app.jump_to_stuck_target().await,
|
Tab::Stuck => app.jump_to_stuck_target().await,
|
||||||
|
Tab::Calendar => app.jump_to_calendar_entry().await,
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
KeyCode::Left | KeyCode::Right if matches!(app.tab, Tab::Stuck) => {
|
KeyCode::Left | KeyCode::Right if matches!(app.tab, Tab::Stuck) => {
|
||||||
|
|
@ -182,6 +220,16 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) {
|
||||||
KeyCode::Char('r') if matches!(app.tab, Tab::Review) => {
|
KeyCode::Char('r') if matches!(app.tab, Tab::Review) => {
|
||||||
app.reject_selected_review().await;
|
app.reject_selected_review().await;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('/') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
|
||||||
|
app.start_library_filter();
|
||||||
|
}
|
||||||
|
KeyCode::Char('n')
|
||||||
|
if matches!(app.tab, Tab::Library)
|
||||||
|
&& app.detail.is_some()
|
||||||
|
&& !matches!(app.focus, Focus::Candidates) =>
|
||||||
|
{
|
||||||
|
app.select_next_missing_episode();
|
||||||
|
}
|
||||||
KeyCode::Char('s') if matches!(app.tab, Tab::Library) && app.detail.is_some() => {
|
KeyCode::Char('s') if matches!(app.tab, Tab::Library) && app.detail.is_some() => {
|
||||||
app.search_now_selected().await;
|
app.search_now_selected().await;
|
||||||
}
|
}
|
||||||
|
|
@ -211,9 +259,20 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cycle_tab(app: &mut App) {
|
fn tab_offset(current: Tab, delta: i32) -> Tab {
|
||||||
let idx = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0);
|
let idx = Tab::ALL.iter().position(|t| *t == current).unwrap_or(0) as i32;
|
||||||
app.tab = Tab::ALL[(idx + 1) % Tab::ALL.len()];
|
let len = Tab::ALL.len() as i32;
|
||||||
|
Tab::ALL[(idx + delta).rem_euclid(len) as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch_tab(app: &mut App, tab: Tab) {
|
||||||
|
if app.tab == tab {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if matches!(app.focus, Focus::Candidates) {
|
||||||
|
app.close_candidates();
|
||||||
|
}
|
||||||
|
app.tab = tab;
|
||||||
app.detail = None;
|
app.detail = None;
|
||||||
app.profile_detail = None;
|
app.profile_detail = None;
|
||||||
app.profile_weight_state.select(None);
|
app.profile_weight_state.select(None);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, Focus, StuckSection, Tab};
|
use crate::app::{App, Focus, StuckSection, Tab};
|
||||||
|
use breadarr_shared::dto::CycleInfo;
|
||||||
|
|
||||||
// Shared color palette — kept to these meanings so a color never has to be
|
// Shared color palette — kept to these meanings so a color never has to be
|
||||||
// second-guessed at a glance:
|
// second-guessed at a glance:
|
||||||
|
|
@ -58,6 +59,7 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> {
|
||||||
Tab::Library if app.detail.is_some() => vec![
|
Tab::Library if app.detail.is_some() => vec![
|
||||||
("Esc", "back"),
|
("Esc", "back"),
|
||||||
("s", "search now"),
|
("s", "search now"),
|
||||||
|
("n", "next missing"),
|
||||||
("m", "monitor show"),
|
("m", "monitor show"),
|
||||||
("e", "monitor episode"),
|
("e", "monitor episode"),
|
||||||
("S", "monitor season"),
|
("S", "monitor season"),
|
||||||
|
|
@ -65,8 +67,12 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> {
|
||||||
("d", "delete file"),
|
("d", "delete file"),
|
||||||
("c", "pick release"),
|
("c", "pick release"),
|
||||||
],
|
],
|
||||||
|
Tab::Library if matches!(app.focus, Focus::LibraryFilterInput) => {
|
||||||
|
vec![("Enter", "keep filter"), ("Esc", "clear filter")]
|
||||||
|
}
|
||||||
Tab::Library => vec![
|
Tab::Library => vec![
|
||||||
("Enter", "open detail"),
|
("Enter", "open detail"),
|
||||||
|
("/", "filter title"),
|
||||||
("f", "cycle filter"),
|
("f", "cycle filter"),
|
||||||
("o", "cycle sort"),
|
("o", "cycle sort"),
|
||||||
("m", "monitor toggle"),
|
("m", "monitor toggle"),
|
||||||
|
|
@ -81,13 +87,17 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> {
|
||||||
}
|
}
|
||||||
Tab::Profiles => vec![("Enter", "open profile")],
|
Tab::Profiles => vec![("Enter", "open profile")],
|
||||||
Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")],
|
Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")],
|
||||||
|
Tab::Calendar => vec![("Enter", "jump to show")],
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const GLOBAL_KEYS: &[(&str, &str)] = &[
|
const GLOBAL_KEYS: &[(&str, &str)] = &[
|
||||||
("Tab", "switch tab"),
|
("Tab/S-Tab", "switch tab"),
|
||||||
|
("1-8", "jump tab"),
|
||||||
("j/k", "move"),
|
("j/k", "move"),
|
||||||
|
("g/G", "first/last"),
|
||||||
|
("PgUp/PgDn", "page"),
|
||||||
("?", "help"),
|
("?", "help"),
|
||||||
("R", "refresh now"),
|
("R", "refresh now"),
|
||||||
("q", "quit"),
|
("q", "quit"),
|
||||||
|
|
@ -128,7 +138,7 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
])
|
])
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let popup = centered_rect(50, lines.len() as u16 + 2, area);
|
let popup = centered_rect(52, lines.len() as u16 + 2, area);
|
||||||
frame.render_widget(Clear, popup);
|
frame.render_widget(Clear, popup);
|
||||||
let paragraph = Paragraph::new(lines).block(
|
let paragraph = Paragraph::new(lines).block(
|
||||||
Block::default()
|
Block::default()
|
||||||
|
|
@ -138,8 +148,47 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
frame.render_widget(paragraph, popup);
|
frame.render_widget(paragraph, popup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tab_label(tab: Tab, app: &App) -> String {
|
||||||
|
match tab {
|
||||||
|
Tab::Library => {
|
||||||
|
let missing: i64 = app.media_items.iter().map(|m| m.missing_count).sum();
|
||||||
|
if missing > 0 {
|
||||||
|
format!("Library ({missing})")
|
||||||
|
} else {
|
||||||
|
tab.title().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tab::Review => {
|
||||||
|
let n = if matches!(app.tab, Tab::Review) {
|
||||||
|
app.review_items.len()
|
||||||
|
} else {
|
||||||
|
app.review_count
|
||||||
|
};
|
||||||
|
if n > 0 {
|
||||||
|
format!("Review ({n})")
|
||||||
|
} else {
|
||||||
|
tab.title().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tab::Stuck => {
|
||||||
|
let n = app.stuck.as_ref().map_or(app.stuck_count, |r| {
|
||||||
|
r.stalled_grabs.len() + r.maxed_out_search_targets.len()
|
||||||
|
});
|
||||||
|
if n > 0 {
|
||||||
|
format!("Stuck ({n})")
|
||||||
|
} else {
|
||||||
|
tab.title().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => tab.title().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.title())).collect();
|
let titles: Vec<Line> = Tab::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|t| Line::from(tab_label(*t, app)))
|
||||||
|
.collect();
|
||||||
let selected = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0);
|
let selected = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0);
|
||||||
|
|
||||||
let (daemon_label, daemon_color) = match (&app.daemon_up, &app.health) {
|
let (daemon_label, daemon_color) = match (&app.daemon_up, &app.health) {
|
||||||
|
|
@ -190,6 +239,11 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if detail.kind == "movie" || detail.episodes.is_empty() {
|
||||||
|
draw_movie_detail(frame, area, app, detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let items: Vec<ListItem> = detail
|
let items: Vec<ListItem> = detail
|
||||||
.episodes
|
.episodes
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -286,9 +340,19 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let search = if app.library_query.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" search: {}", app.library_query)
|
||||||
|
};
|
||||||
|
let filter_caret = if matches!(app.focus, Focus::LibraryFilterInput) {
|
||||||
|
"▋"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(Block::default().borders(Borders::ALL).title(format!(
|
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||||
"Monitored Shows ({}/{}) — filter: {} sort: {}",
|
"Library ({}/{}) — filter: {} sort: {}{search}{filter_caret}",
|
||||||
app.library_view.len(),
|
app.library_view.len(),
|
||||||
app.media_items.len(),
|
app.media_items.len(),
|
||||||
app.library_filter.label(),
|
app.library_filter.label(),
|
||||||
|
|
@ -299,6 +363,61 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
frame.render_stateful_widget(list, area, &mut state);
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_movie_detail(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
app: &App,
|
||||||
|
detail: &breadarr_shared::dto::MediaItemDetail,
|
||||||
|
) {
|
||||||
|
let confirm = if app.confirm_delete {
|
||||||
|
" — x AGAIN TO DELETE"
|
||||||
|
} else if app.confirm_delete_file {
|
||||||
|
" — d AGAIN TO DELETE FILE"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let monitor_label = if detail.monitored {
|
||||||
|
"monitored"
|
||||||
|
} else {
|
||||||
|
"unmonitored"
|
||||||
|
};
|
||||||
|
let have = app
|
||||||
|
.media_items
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id == detail.id)
|
||||||
|
.map(|m| m.missing_count == 0);
|
||||||
|
let file_line = match have {
|
||||||
|
Some(true) => ("file on disk", Color::Green),
|
||||||
|
Some(false) => ("missing", Color::Yellow),
|
||||||
|
None => ("file status unknown", Color::DarkGray),
|
||||||
|
};
|
||||||
|
let kind = if detail.kind == "movie" {
|
||||||
|
"Movie"
|
||||||
|
} else {
|
||||||
|
"Series"
|
||||||
|
};
|
||||||
|
let year = detail
|
||||||
|
.year
|
||||||
|
.map(|y| y.to_string())
|
||||||
|
.unwrap_or_else(|| "—".into());
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!("{} ({year})", detail.title),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(format!("{kind} · {monitor_label}")),
|
||||||
|
Line::from(Span::styled(file_line.0, Style::default().fg(file_line.1))),
|
||||||
|
Line::from(format!("root: {}", detail.root_folder)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from("s search now c pick release m monitor d delete file x remove"),
|
||||||
|
];
|
||||||
|
let paragraph = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(
|
||||||
|
format!("{} ({year}) [{monitor_label}]{confirm}", detail.title),
|
||||||
|
));
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
/// Manual release picker — candidates for whatever episode/movie was
|
/// Manual release picker — candidates for whatever episode/movie was
|
||||||
/// selected when `c` was pressed, scored (or gate-rejected with a reason)
|
/// selected when `c` was pressed, scored (or gate-rejected with a reason)
|
||||||
/// exactly like the automatic search pipeline would see them.
|
/// exactly like the automatic search pipeline would see them.
|
||||||
|
|
@ -330,15 +449,19 @@ fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str)
|
||||||
if c.is_season_pack { " [PACK]" } else { "" },
|
if c.is_season_pack { " [PACK]" } else { "" },
|
||||||
if c.is_repack { " [REPACK]" } else { "" },
|
if c.is_repack { " [REPACK]" } else { "" },
|
||||||
);
|
);
|
||||||
let verdict = match (c.score, &c.rejected_reason) {
|
let (verdict, color) = match (c.score, &c.rejected_reason) {
|
||||||
(Some(score), _) => format!("score {score:.1}"),
|
(Some(score), _) if score >= 8.0 => (format!("score {score:.1}"), Color::Green),
|
||||||
(None, Some(reason)) => format!("REJECTED: {reason}"),
|
(Some(score), _) => (format!("score {score:.1}"), Color::Yellow),
|
||||||
(None, None) => "unscored".to_string(),
|
(None, Some(reason)) => (format!("REJECTED: {reason}"), Color::Red),
|
||||||
|
(None, None) => ("unscored".to_string(), Color::DarkGray),
|
||||||
};
|
};
|
||||||
ListItem::new(format!(
|
ListItem::new(Line::from(vec![
|
||||||
"[{}] {} — {seeders} seeders, {size}{flags} — {verdict}",
|
Span::raw(format!(
|
||||||
|
"[{}] {} — {seeders} seeders, {size}{flags} — ",
|
||||||
c.source_name, c.raw_title
|
c.source_name, c.raw_title
|
||||||
))
|
)),
|
||||||
|
Span::styled(verdict, Style::default().fg(color)),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
|
|
@ -350,18 +473,32 @@ fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str)
|
||||||
frame.render_stateful_widget(list, area, &mut state);
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn history_status_color(status: &str) -> Color {
|
||||||
|
match status {
|
||||||
|
"imported" => Color::Green,
|
||||||
|
"grabbed" => Color::Yellow,
|
||||||
|
"failed" => Color::Red,
|
||||||
|
_ => Color::DarkGray,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_history(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_history(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.releases
|
.releases
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
ListItem::new(format!(
|
ListItem::new(Line::from(vec![
|
||||||
"[{}] {} — {} (score {:.1})",
|
Span::styled(
|
||||||
r.status,
|
format!("[{}]", r.status),
|
||||||
|
Style::default().fg(history_status_color(&r.status)),
|
||||||
|
),
|
||||||
|
Span::raw(format!(
|
||||||
|
" {} — {} (score {:.1})",
|
||||||
r.media_title,
|
r.media_title,
|
||||||
r.raw_title,
|
r.raw_title,
|
||||||
r.score.unwrap_or(0.0)
|
r.score.unwrap_or(0.0)
|
||||||
))
|
)),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
|
|
@ -384,7 +521,10 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
Color::Green
|
Color::Green
|
||||||
};
|
};
|
||||||
ListItem::new(Line::from(vec![
|
ListItem::new(Line::from(vec![
|
||||||
Span::styled(format!("({:.0}%)", r.confidence * 100.0), Style::default().fg(color)),
|
Span::styled(
|
||||||
|
format!("({:.0}%)", r.confidence * 100.0),
|
||||||
|
Style::default().fg(color),
|
||||||
|
),
|
||||||
Span::raw(format!(
|
Span::raw(format!(
|
||||||
" {} -> {}",
|
" {} -> {}",
|
||||||
r.raw_release_title,
|
r.raw_release_title,
|
||||||
|
|
@ -439,7 +579,10 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
.iter()
|
.iter()
|
||||||
.map(|g| {
|
.map(|g| {
|
||||||
ListItem::new(Line::from(Span::styled(
|
ListItem::new(Line::from(Span::styled(
|
||||||
format!("{} — {} (grabbed {})", g.media_title, g.raw_title, g.grabbed_at),
|
format!(
|
||||||
|
"{} — {} (grabbed {})",
|
||||||
|
g.media_title, g.raw_title, g.grabbed_at
|
||||||
|
),
|
||||||
Style::default().fg(Color::Yellow),
|
Style::default().fg(Color::Yellow),
|
||||||
)))
|
)))
|
||||||
})
|
})
|
||||||
|
|
@ -519,12 +662,21 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
.add_results
|
.add_results
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
ListItem::new(format!(
|
let kind_color = match r.kind {
|
||||||
"[{}] {} ({})",
|
crate::app::AddKind::Movie => Color::Magenta,
|
||||||
r.kind.label(),
|
crate::app::AddKind::Series => Color::Blue,
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("[{}] ", r.kind.label()),
|
||||||
|
Style::default().fg(kind_color),
|
||||||
|
),
|
||||||
|
Span::raw(format!(
|
||||||
|
"{} ({})",
|
||||||
r.result.title,
|
r.result.title,
|
||||||
r.result.year.map(|y| y.to_string()).unwrap_or_default()
|
r.result.year.map(|y| y.to_string()).unwrap_or_default()
|
||||||
))
|
)),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
|
|
@ -539,36 +691,43 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What's aired recently or airs soon (a week back, three weeks forward —
|
/// What's aired recently or airs soon (a week back, three weeks forward —
|
||||||
/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Read-only, no
|
/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Selectable —
|
||||||
/// selection — a lookahead view, not something acted on directly here.
|
/// Enter jumps to that episode in Library.
|
||||||
fn draw_calendar(frame: &mut Frame, area: Rect, app: &App) {
|
fn draw_calendar(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let today = chrono::Local::now().date_naive().to_string();
|
let today = chrono::Local::now().date_naive().to_string();
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.calendar
|
.calendar
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
let status = if e.has_file {
|
let (status, color) = if e.has_file {
|
||||||
"✓"
|
("✓", Color::Green)
|
||||||
} else if !e.monitored {
|
} else if !e.monitored {
|
||||||
"-"
|
("-", Color::DarkGray)
|
||||||
} else if e.air_date.as_str() > today.as_str() {
|
} else if e.air_date.as_str() > today.as_str() {
|
||||||
"…"
|
("…", Color::Yellow)
|
||||||
} else {
|
} else {
|
||||||
"!" // aired, monitored, still missing
|
("!", Color::Red)
|
||||||
};
|
};
|
||||||
|
let today_mark = if e.air_date == today { " today" } else { "" };
|
||||||
let title = e.title.as_deref().unwrap_or("");
|
let title = e.title.as_deref().unwrap_or("");
|
||||||
ListItem::new(format!(
|
ListItem::new(Line::from(vec![
|
||||||
"{status} {} {} S{:02}E{:02} {title}",
|
Span::styled(status, Style::default().fg(color)),
|
||||||
|
Span::raw(format!(
|
||||||
|
" {} {} S{:02}E{:02} {title}{today_mark}",
|
||||||
e.air_date, e.media_title, e.season_number, e.episode_number
|
e.air_date, e.media_title, e.season_number, e.episode_number
|
||||||
))
|
)),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let list = List::new(items).block(
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title("Calendar — ✓ have it … upcoming ! aired but missing"),
|
.title("Calendar — Enter: jump ✓ have it … upcoming ! aired but missing"),
|
||||||
);
|
)
|
||||||
frame.render_widget(list, area);
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
let mut state = app.calendar_state.clone();
|
||||||
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only report on `media_file_probe` state: corruption, under-quality,
|
/// Read-only report on `media_file_probe` state: corruption, under-quality,
|
||||||
|
|
@ -588,7 +747,7 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(5), Constraint::Min(3)])
|
.constraints([Constraint::Length(6), Constraint::Min(3)])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let s = &report.summary;
|
let s = &report.summary;
|
||||||
|
|
@ -598,8 +757,13 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
.map(|c| format!("{}={}", c.codec, c.count))
|
.map(|c| format!("{}={}", c.codec, c.count))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
let cycles = app
|
||||||
|
.health
|
||||||
|
.as_ref()
|
||||||
|
.map(cycle_summary_line)
|
||||||
|
.unwrap_or_default();
|
||||||
let summary_text = format!(
|
let summary_text = format!(
|
||||||
"{} files, {:.1} GB total, {} probed — resolution: SD={} 720p={} 1080p={} 4K={} \
|
"{cycles}\n{} files, {:.1} GB total, {} probed — resolution: SD={} 720p={} 1080p={} 4K={} \
|
||||||
— subtitles: {:.0}% — codecs: {codec_summary}",
|
— subtitles: {:.0}% — codecs: {codec_summary}",
|
||||||
s.total_files,
|
s.total_files,
|
||||||
s.total_size_bytes as f64 / 1_073_741_824.0,
|
s.total_size_bytes as f64 / 1_073_741_824.0,
|
||||||
|
|
@ -668,12 +832,38 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
items.push(ListItem::new("Nothing flagged — library looks clean."));
|
items.push(ListItem::new("Nothing flagged — library looks clean."));
|
||||||
}
|
}
|
||||||
|
|
||||||
let list = List::new(items).block(
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title("Flagged files"),
|
.title("Flagged files"),
|
||||||
);
|
)
|
||||||
frame.render_widget(list, chunks[1]);
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
let mut state = app.health_state.clone();
|
||||||
|
frame.render_stateful_widget(list, chunks[1], &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cycle_bit(name: &str, info: &Option<CycleInfo>) -> String {
|
||||||
|
match info {
|
||||||
|
Some(c) if c.ok => format!("{name}: ok"),
|
||||||
|
Some(_) => format!("{name}: FAIL"),
|
||||||
|
None => format!("{name}: —"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cycle_summary_line(h: &breadarr_shared::dto::HealthDetail) -> String {
|
||||||
|
format!(
|
||||||
|
"{} | {} | {} | {}{}",
|
||||||
|
cycle_bit("grab", &h.last_grab_cycle),
|
||||||
|
cycle_bit("import", &h.last_import_cycle),
|
||||||
|
cycle_bit("search", &h.last_search_cycle),
|
||||||
|
cycle_bit("upgrade", &h.last_upgrade_cycle),
|
||||||
|
if h.search_halted {
|
||||||
|
" | search HALTED"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quality-profile weight editing — list of profiles, then (once one is
|
/// Quality-profile weight editing — list of profiles, then (once one is
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue