From f5625528db9f56cf158485553dc64778bc4d76ba Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 10:20:43 +0800 Subject: [PATCH] Improve TUI navigation, search, and list readability Tab jump keys, reverse-tab, title filter, and first/last/page movement make a 160-item library usable. Review/Stuck/Library badge counts, a movie detail pane, scrollable Calendar/Health, history and candidate colors, and non-blocking add-search fix the daily friction. Status messages expire so key hints come back; review selection no longer walks off the end of the list. --- README.md | 12 +- breadarr-tui/src/app.rs | 822 +++++++++++++++++++++++++++------------ breadarr-tui/src/main.rs | 75 +++- breadarr-tui/src/ui.rs | 294 +++++++++++--- 4 files changed, 891 insertions(+), 312 deletions(-) diff --git a/README.md b/README.md index 4e7f5e2..1c3fc09 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,13 @@ Install via bakery (`bakery install breadarr`) on a homelab host, or build from ## 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. -- **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. -- **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. -- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window. -- **Health** — the library-health report (see below) rendered as a tab instead of curled by hand. +- **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** — `/` 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. `Enter` jumps to that show. +- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window. `Enter` opens the matching episode. +- **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. ## Operational notes diff --git a/breadarr-tui/src/app.rs b/breadarr-tui/src/app.rs index 45b213e..b991885 100644 --- a/breadarr-tui/src/app.rs +++ b/breadarr-tui/src/app.rs @@ -1,12 +1,13 @@ use anyhow::Result; use breadarr_shared::dto::{ - CalendarEntry, HealthDetail, LibraryHealthReport, MediaItemDetail, MediaItemSummary, - QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchNowResult, - SearchResult, StuckReport, WeightsDto, + CalendarEntry, EpisodeSummary, FlaggedFile, HealthDetail, LibraryHealthReport, MediaItemDetail, + MediaItemSummary, QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, + SearchNowResult, SearchResult, StuckReport, WeightsDto, }; use breadarr_shared::DaemonClient; use ratatui::widgets::ListState; use std::path::Path; +use std::time::{Duration, Instant}; /// Category roots the TUI's "Add" flow can place new items under — kept as /// two separate paths (not one shared default) since a series and a movie @@ -75,8 +76,8 @@ impl Tab { match self { Tab::Library => "Library", Tab::History => "History", - Tab::Review => "Review Queue", - Tab::Add => "Add Show", + Tab::Review => "Review", + Tab::Add => "Add", Tab::Stuck => "Stuck", Tab::Calendar => "Calendar", Tab::LibraryHealth => "Health", @@ -89,6 +90,10 @@ pub enum Focus { List, AddSearchInput, AddResults, + /// Incremental title filter on the Library list — `App::library_query` + /// holds the in-progress text, same priority-over-global-keys pattern + /// as `AddSearchInput`. + LibraryFilterInput, /// The manual release picker overlay — `App::candidates` holds the /// list, `App::candidates_episode_id` remembers which episode (or, if /// `None`, the open movie) it was fetched for so a grab can be @@ -238,13 +243,70 @@ pub struct AddResult { } /// Result of a long `DaemonClient` call spawned off the draw loop so -/// `search_now` / candidate fetch (up to 600s) cannot freeze key handling. +/// `search_now` / candidate fetch / add-tab search cannot freeze key +/// handling. Add-search is typically a few seconds (TVDB + TMDB together) +/// rather than minutes, but it still shouldn't stall j/k or Tab. enum BackgroundOutcome { SearchNow(Result), Candidates { episode_id: Option, result: Result>, }, + AddSearch { + results: Vec, + errors: Vec, + }, +} + +/// Case-insensitive substring match used by the Library `/` filter. +fn title_matches_query(title: &str, query: &str) -> bool { + query.is_empty() || title.to_lowercase().contains(&query.to_lowercase()) +} + +/// Next monitored-and-missing episode, wrapping from `start` so `n` can +/// be mashed through a season without first jumping to the top. +fn next_missing_index(episodes: &[EpisodeSummary], start: usize) -> Option { + if episodes.is_empty() { + return None; + } + let start = start.min(episodes.len()); + episodes + .iter() + .enumerate() + .skip(start) + .chain(episodes.iter().enumerate().take(start)) + .find(|(_, e)| e.monitored && !e.has_file) + .map(|(i, _)| i) +} + +fn clamp_list_state(state: &mut ListState, len: usize) { + match (state.selected(), len) { + (_, 0) => state.select(None), + (None, _) => state.select(Some(0)), + (Some(i), _) if i >= len => state.select(Some(len - 1)), + _ => {} + } +} + +/// Row count of the Health tab's flagged-files list, including section +/// headers — must stay in lockstep with `draw_library_health` so j/k +/// doesn't walk off the rendered items. +pub fn health_row_count(report: &LibraryHealthReport) -> usize { + let mut n = 0; + let mut add_files = |files: &[FlaggedFile]| { + if !files.is_empty() { + n += 1 + files.len(); + } + }; + add_files(&report.corrupt_files); + add_files(&report.under_quality_files); + add_files(&report.no_english_audio_files); + add_files(&report.non_english_default_audio_files); + add_files(&report.no_subtitle_files); + if !report.duplicate_groups.is_empty() { + n += 1 + report.duplicate_groups.len(); + } + n.max(1) } pub struct App { @@ -254,6 +316,10 @@ pub struct App { pub tab: Tab, pub focus: Focus, pub status: String, + /// When `status` was last written — the draw loop clears it after a + /// few seconds so action feedback doesn't permanently hide the + /// keybinding hints in the status bar. + status_set_at: Option, 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 @@ -273,6 +339,9 @@ pub struct App { pub library_view: Vec, pub library_filter: LibraryFilter, pub library_sort: LibrarySort, + /// Incremental title filter from `/` — applied on top of + /// `library_filter`/`library_sort` inside `recompute_library_view`. + pub library_query: String, pub detail: Option, /// Selection within `detail.episodes` — separate from `media_state` /// since they're two different lists sharing the same tab. @@ -283,6 +352,9 @@ pub struct App { pub review_items: Vec, pub review_state: ListState, + /// Last-seen review-queue depth, kept even when the Review tab isn't + /// the one being refreshed so the tab strip can badge it. + pub review_count: usize, pub add_query: String, pub add_results: Vec, @@ -292,8 +364,12 @@ pub struct App { pub stuck_focus: StuckSection, pub stalled_state: ListState, pub maxed_state: ListState, + /// Last-seen stalled+maxed count for the Stuck tab badge. + pub stuck_count: usize, pub calendar: Vec, + pub calendar_state: ListState, pub library_health: Option, + pub health_state: ListState, pub candidates: Vec, pub candidates_state: ListState, @@ -338,6 +414,7 @@ impl App { tab: Tab::Library, focus: Focus::List, status: String::new(), + status_set_at: None, should_quit: false, help_visible: false, force_refresh: false, @@ -346,12 +423,14 @@ impl App { library_view: Vec::new(), library_filter: LibraryFilter::All, library_sort: LibrarySort::TitleAsc, + library_query: String::new(), detail: None, episode_state: ListState::default(), releases: Vec::new(), releases_state: ListState::default(), review_items: Vec::new(), review_state: ListState::default(), + review_count: 0, add_query: String::new(), add_results: Vec::new(), add_results_state: ListState::default(), @@ -359,8 +438,11 @@ impl App { stuck_focus: StuckSection::Stalled, stalled_state: ListState::default(), maxed_state: ListState::default(), + stuck_count: 0, calendar: Vec::new(), + calendar_state: ListState::default(), library_health: None, + health_state: ListState::default(), candidates: Vec::new(), candidates_state: ListState::default(), candidates_episode_id: None, @@ -376,190 +458,32 @@ impl App { } } - /// Applies a finished background search/candidate task. Only `.await`s - /// a handle that `is_finished()`, so the draw loop stays responsive. - pub async fn poll_background(&mut self) { - let Some(handle) = &self.background else { - return; + pub fn set_status(&mut self, msg: impl Into) { + self.status = msg.into(); + self.status_set_at = if self.status.is_empty() { + None + } else { + Some(Instant::now()) }; - if !handle.is_finished() { + } + + /// Drops stale action feedback so the status bar can show keybinding + /// hints again. In-flight work (`busy`) keeps its "searching..." line. + pub fn expire_status(&mut self) { + if self.busy { return; } - let handle = self.background.take().expect("just checked is_finished"); - self.busy = false; - match handle.await { - Ok(BackgroundOutcome::SearchNow(Ok(stats))) => { - self.status = format!( - "search complete: {} target(s), {} grabbed, {} error(s)", - stats.targets, stats.grabbed, stats.errors - ); + if let Some(at) = self.status_set_at { + if at.elapsed() >= Duration::from_secs(5) { + self.status.clear(); + self.status_set_at = None; } - Ok(BackgroundOutcome::SearchNow(Err(e))) => { - self.status = format!("search failed: {e}"); - } - Ok(BackgroundOutcome::Candidates { - episode_id, - result: Ok(candidates), - }) => { - self.status = format!("{} candidate(s) found", candidates.len()); - if matches!(self.tab, Tab::Library) && self.detail.is_some() { - self.candidates_state.select(if candidates.is_empty() { - None - } else { - Some(0) - }); - self.candidates = candidates; - self.candidates_episode_id = episode_id; - self.focus = Focus::Candidates; - } - } - Ok(BackgroundOutcome::Candidates { result: Err(e), .. }) => { - self.status = format!("candidate fetch failed: {e}"); - } - Err(e) => self.status = format!("background task failed: {e}"), } } - pub async fn refresh_active_tab(&mut self) { - match self.client.health_detail().await { - Ok(detail) => { - self.daemon_up = true; - self.health = Some(detail); - } - Err(_) => { - self.daemon_up = false; - self.health = None; - } - } - if !self.daemon_up { - self.status = "daemon unreachable".to_string(); - return; - } - let result: Result<()> = async { - match self.tab { - Tab::Library => { - if self.detail.is_some() { - if let Some(id) = self.selected_media_id() { - self.detail = Some(self.client.media_detail(id).await?); - } - } else { - self.media_items = self.client.list_media().await?; - self.recompute_library_view(); - } - } - Tab::History => { - self.releases = self.client.releases().await?; - if self.releases_state.selected().is_none() && !self.releases.is_empty() { - self.releases_state.select(Some(0)); - } - } - Tab::Review => { - self.review_items = self.client.review_queue().await?; - if self.review_state.selected().is_none() && !self.review_items.is_empty() { - self.review_state.select(Some(0)); - } - } - Tab::Add => {} - Tab::Stuck => { - 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?; - } - Tab::LibraryHealth => { - self.library_health = Some(self.client.library_health().await?); - } - Tab::Profiles => { - if self.profile_detail.is_none() { - self.quality_profiles = self.client.quality_profiles().await?; - if self.profiles_state.selected().is_none() - && !self.quality_profiles.is_empty() - { - self.profiles_state.select(Some(0)); - } - } - } - } - Ok(()) - } - .await; - - if let Err(e) = result { - self.status = format!("error: {e}"); - } - } - - fn selected_media_id(&self) -> Option { - 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 { + /// The list currently driven by j/k / g/G / PageUp/PageDown. + fn active_list(&mut self) -> (&mut ListState, usize) { + match self.tab { Tab::Library if matches!(self.focus, Focus::Candidates) => { (&mut self.candidates_state, self.candidates.len()) } @@ -582,12 +506,247 @@ impl App { StuckSection::Maxed => (&mut self.maxed_state, report_len.1), } } + Tab::Calendar => (&mut self.calendar_state, self.calendar.len()), + Tab::LibraryHealth => { + let n = self + .library_health + .as_ref() + .map(health_row_count) + .unwrap_or(0); + (&mut self.health_state, n) + } Tab::Profiles if self.profile_detail.is_some() => { (&mut self.profile_weight_state, WEIGHT_FIELDS.len()) } Tab::Profiles => (&mut self.profiles_state, self.quality_profiles.len()), - _ => return, + } + } + + pub fn select_edge(&mut self, last: bool) { + let (state, len) = self.active_list(); + if len == 0 { + return; + } + state.select(Some(if last { len - 1 } else { 0 })); + } + + /// Applies a finished background search/candidate task. Only `.await`s + /// a handle that `is_finished()`, so the draw loop stays responsive. + pub async fn poll_background(&mut self) { + let Some(handle) = &self.background else { + return; }; + if !handle.is_finished() { + return; + } + let handle = self.background.take().expect("just checked is_finished"); + self.busy = false; + match handle.await { + Ok(BackgroundOutcome::SearchNow(Ok(stats))) => { + self.set_status(format!( + "search complete: {} target(s), {} grabbed, {} error(s)", + stats.targets, stats.grabbed, stats.errors + )); + } + Ok(BackgroundOutcome::SearchNow(Err(e))) => { + self.set_status(format!("search failed: {e}")); + } + Ok(BackgroundOutcome::Candidates { + episode_id, + result: Ok(candidates), + }) => { + self.set_status(format!("{} candidate(s) found", candidates.len())); + if matches!(self.tab, Tab::Library) && self.detail.is_some() { + self.candidates_state.select(if candidates.is_empty() { + None + } else { + Some(0) + }); + self.candidates = candidates; + self.candidates_episode_id = episode_id; + self.focus = Focus::Candidates; + } + } + Ok(BackgroundOutcome::Candidates { result: Err(e), .. }) => { + self.set_status(format!("candidate fetch failed: {e}")); + } + Ok(BackgroundOutcome::AddSearch { results, errors }) => { + self.add_results_state + .select(if results.is_empty() { None } else { Some(0) }); + self.add_results = results; + self.set_status(if errors.is_empty() { + String::new() + } else { + errors.join("; ") + }); + if matches!(self.tab, Tab::Add) { + self.focus = Focus::AddResults; + } + } + Err(e) => self.set_status(format!("background task failed: {e}")), + } + } + + pub async fn refresh_active_tab(&mut self) { + match self.client.health_detail().await { + Ok(detail) => { + self.daemon_up = true; + self.health = Some(detail); + } + Err(_) => { + self.daemon_up = false; + self.health = None; + } + } + if !self.daemon_up { + self.set_status("daemon unreachable"); + return; + } + let result: Result<()> = async { + match self.tab { + Tab::Library => { + if self.detail.is_some() { + if let Some(id) = self.selected_media_id() { + self.detail = Some(self.client.media_detail(id).await?); + } + } else { + self.media_items = self.client.list_media().await?; + self.recompute_library_view(); + } + } + Tab::History => { + self.releases = self.client.releases().await?; + clamp_list_state(&mut self.releases_state, self.releases.len()); + } + Tab::Review => { + self.review_items = self.client.review_queue().await?; + self.review_count = self.review_items.len(); + clamp_list_state(&mut self.review_state, self.review_items.len()); + } + Tab::Add => {} + Tab::Stuck => { + let report = self.client.stuck().await?; + self.stuck_count = + report.stalled_grabs.len() + report.maxed_out_search_targets.len(); + clamp_list_state(&mut self.stalled_state, report.stalled_grabs.len()); + clamp_list_state(&mut self.maxed_state, report.maxed_out_search_targets.len()); + self.stuck = Some(report); + } + Tab::Calendar => { + self.calendar = self.client.calendar().await?; + clamp_list_state(&mut self.calendar_state, self.calendar.len()); + } + Tab::LibraryHealth => { + self.library_health = Some(self.client.library_health().await?); + let n = self + .library_health + .as_ref() + .map(health_row_count) + .unwrap_or(0); + clamp_list_state(&mut self.health_state, n); + } + Tab::Profiles => { + if self.profile_detail.is_none() { + self.quality_profiles = self.client.quality_profiles().await?; + clamp_list_state(&mut self.profiles_state, self.quality_profiles.len()); + } + } + } + self.refresh_badge_counts().await; + Ok(()) + } + .await; + + if let Err(e) = result { + self.set_status(format!("error: {e}")); + } + } + + /// Cheap counts for the tab-strip badges. Skips the resource the + /// active tab already fetched so we don't double-hit the same route. + async fn refresh_badge_counts(&mut self) { + if !matches!(self.tab, Tab::Review) { + if let Ok(items) = self.client.review_queue().await { + self.review_count = items.len(); + } + } + if !matches!(self.tab, Tab::Stuck) { + if let Ok(report) = self.client.stuck().await { + self.stuck_count = + report.stalled_grabs.len() + report.maxed_out_search_targets.len(); + } + } + } + + fn selected_media_id(&self) -> Option { + 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 query = self.library_query.clone(); + let mut indices: Vec = self + .media_items + .iter() + .enumerate() + .filter(|(_, m)| { + self.library_filter.matches(m) && title_matches_query(&m.title, &query) + }) + .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) = self.active_list(); if len == 0 { return; } @@ -612,13 +771,47 @@ impl App { }); self.detail = Some(detail); } - Err(e) => self.status = format!("error loading detail: {e}"), + Err(e) => self.set_status(format!("error loading detail: {e}")), } } pub fn close_detail(&mut self) { self.detail = None; self.episode_state.select(None); + if matches!(self.focus, Focus::Candidates) { + self.close_candidates(); + } + } + + pub fn start_library_filter(&mut self) { + if self.detail.is_some() { + return; + } + self.focus = Focus::LibraryFilterInput; + } + + pub fn confirm_library_filter(&mut self) { + self.focus = Focus::List; + } + + pub fn clear_library_filter(&mut self) { + self.library_query.clear(); + self.focus = Focus::List; + self.recompute_library_view(); + } + + /// Advances the episode selection to the next monitored episode that + /// still has no file, wrapping so a second `n` at the end of the list + /// starts over rather than doing nothing. + pub fn select_next_missing_episode(&mut self) { + let Some(detail) = &self.detail else { + return; + }; + let start = self.episode_state.selected().map(|i| i + 1).unwrap_or(0); + match next_missing_index(&detail.episodes, start) { + Some(i) => self.episode_state.select(Some(i)), + None => self.set_status("no missing monitored episodes"), + } } /// Toggles monitored on whichever episode is currently selected in the @@ -641,14 +834,14 @@ impl App { }; match result { Ok(()) => { - self.status = "episode monitor state updated".to_string(); + self.set_status("episode monitor state updated".to_string()); if let Some(id) = self.selected_media_id() { if let Ok(fresh) = self.client.media_detail(id).await { self.detail = Some(fresh); } } } - Err(e) => self.status = format!("episode monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("episode monitor toggle failed: {e}")), } } @@ -677,12 +870,12 @@ impl App { }; match result { Ok(()) => { - self.status = format!("season {season_number} monitor state updated"); + self.set_status(format!("season {season_number} monitor state updated")); if let Ok(fresh) = self.client.media_detail(media_item_id).await { self.detail = Some(fresh); } } - Err(e) => self.status = format!("season monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("season monitor toggle failed: {e}")), } } @@ -694,10 +887,12 @@ impl App { return; }; match self.client.approve_review(item.id).await { - Ok(()) => self.status = format!("approved: {}", item.raw_release_title), - Err(e) => self.status = format!("approve failed: {e}"), + Ok(()) => self.set_status(format!("approved: {}", item.raw_release_title)), + Err(e) => self.set_status(format!("approve failed: {e}")), } self.review_items = self.client.review_queue().await.unwrap_or_default(); + self.review_count = self.review_items.len(); + clamp_list_state(&mut self.review_state, self.review_items.len()); } pub async fn reject_selected_review(&mut self) { @@ -708,10 +903,12 @@ impl App { return; }; match self.client.reject_review(item.id).await { - Ok(()) => self.status = format!("rejected: {}", item.raw_release_title), - Err(e) => self.status = format!("reject failed: {e}"), + Ok(()) => self.set_status(format!("rejected: {}", item.raw_release_title)), + Err(e) => self.set_status(format!("reject failed: {e}")), } self.review_items = self.client.review_queue().await.unwrap_or_default(); + self.review_count = self.review_items.len(); + clamp_list_state(&mut self.review_state, self.review_items.len()); } /// Searches series and movies together instead of requiring the user to @@ -722,41 +919,36 @@ impl App { /// a movie search returned nothing because the mode had never actually /// switched). Querying both up front removes the failure mode entirely. pub async fn run_add_search(&mut self) { - if self.add_query.trim().is_empty() { + if self.add_query.trim().is_empty() || self.busy { return; } - let (series_result, movie_result) = tokio::join!( - self.client.search_series(&self.add_query), - self.client.search_movies(&self.add_query) - ); + self.set_status("searching movies and TV..."); + self.busy = true; + let client = self.client.clone(); + let query = self.add_query.clone(); + self.background = Some(tokio::spawn(async move { + let (series_result, movie_result) = + tokio::join!(client.search_series(&query), client.search_movies(&query)); - let mut results = Vec::new(); - let mut errors = Vec::new(); - match series_result { - Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { - kind: AddKind::Series, - result, - })), - Err(e) => errors.push(format!("series search failed: {e}")), - } - match movie_result { - Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { - kind: AddKind::Movie, - result, - })), - Err(e) => errors.push(format!("movie search failed: {e}")), - } - results.sort_by_key(|a| a.result.title.to_lowercase()); - - self.add_results_state - .select(if results.is_empty() { None } else { Some(0) }); - self.add_results = results; - self.status = if errors.is_empty() { - String::new() - } else { - errors.join("; ") - }; - self.focus = Focus::AddResults; + let mut results = Vec::new(); + let mut errors = Vec::new(); + match series_result { + Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { + kind: AddKind::Series, + result, + })), + Err(e) => errors.push(format!("series search failed: {e}")), + } + match movie_result { + Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { + kind: AddKind::Movie, + result, + })), + Err(e) => errors.push(format!("movie search failed: {e}")), + } + results.sort_by_key(|a| a.result.title.to_lowercase()); + BackgroundOutcome::AddSearch { results, errors } + })); } pub async fn add_selected_search_result(&mut self, roots: &LibraryRoots) { @@ -789,12 +981,15 @@ impl App { }; match outcome { Ok(media_item_id) => { - self.status = format!("added {:?} (media_item_id={media_item_id})", result.title); + self.set_status(format!( + "added {:?} (media_item_id={media_item_id})", + result.title + )); self.add_results.clear(); self.add_query.clear(); self.focus = Focus::AddSearchInput; } - Err(e) => self.status = format!("add failed: {e}"), + Err(e) => self.set_status(format!("add failed: {e}")), } } @@ -809,7 +1004,7 @@ impl App { if self.busy { return; } - self.status = "searching now...".to_string(); + self.set_status("searching now...".to_string()); self.busy = true; let client = self.client.clone(); self.background = Some(tokio::spawn(async move { @@ -841,7 +1036,7 @@ impl App { return; } - self.status = "fetching candidates...".to_string(); + self.set_status("fetching candidates...".to_string()); self.busy = true; let client = self.client.clone(); self.background = Some(tokio::spawn(async move { @@ -879,13 +1074,13 @@ impl App { }; match result { Ok(()) => { - self.status = format!("grabbed: {}", candidate.raw_title); + self.set_status(format!("grabbed: {}", candidate.raw_title)); self.close_candidates(); if let Ok(fresh) = self.client.media_detail(media_item_id).await { self.detail = Some(fresh); } } - Err(e) => self.status = format!("grab failed: {e}"), + Err(e) => self.set_status(format!("grab failed: {e}")), } } @@ -955,7 +1150,10 @@ impl App { let value: f32 = match self.weight_input_buffer.trim().parse() { Ok(v) => v, Err(_) => { - self.status = format!("'{}' is not a valid number", self.weight_input_buffer); + self.set_status(format!( + "'{}' is not a valid number", + self.weight_input_buffer + )); return; } }; @@ -972,11 +1170,11 @@ impl App { .await { Ok(()) => { - self.status = format!("{name} updated to {value}"); + self.set_status(format!("{name} updated to {value}")); self.weight_input_buffer.clear(); self.focus = Focus::List; } - Err(e) => self.status = format!("weight update failed: {e}"), + Err(e) => self.set_status(format!("weight update failed: {e}")), } } @@ -994,11 +1192,11 @@ impl App { }; match result { Ok(()) => { - self.status = "monitor state updated".to_string(); + self.set_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}"), + Err(e) => self.set_status(format!("monitor toggle failed: {e}")), } } @@ -1014,12 +1212,12 @@ impl App { }; match result { Ok(()) => { - self.status = "monitor state updated".to_string(); + self.set_status("monitor state updated".to_string()); if let Ok(fresh) = self.client.media_detail(id).await { self.detail = Some(fresh); } } - Err(e) => self.status = format!("monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("monitor toggle failed: {e}")), } } @@ -1031,18 +1229,18 @@ impl App { }; if !self.confirm_delete { self.confirm_delete = true; - self.status = "press x again to confirm delete".to_string(); + self.set_status("press x again to confirm delete".to_string()); return; } self.confirm_delete = false; match self.client.delete_media(id).await { Ok(()) => { - self.status = "deleted".to_string(); + self.set_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}"), + Err(e) => self.set_status(format!("delete failed: {e}")), } } @@ -1057,7 +1255,7 @@ impl App { }; if !self.confirm_delete_file { self.confirm_delete_file = true; - self.status = "press d again to confirm deleting this file".to_string(); + self.set_status("press d again to confirm deleting this file".to_string()); return; } self.confirm_delete_file = false; @@ -1075,14 +1273,14 @@ impl App { }; match result { Ok(()) => { - self.status = "file deleted, will be re-searched".to_string(); + self.set_status("file deleted, will be re-searched".to_string()); if let Some(id) = self.selected_media_id() { if let Ok(fresh) = self.client.media_detail(id).await { self.detail = Some(fresh); } } } - Err(e) => self.status = format!("file delete failed: {e}"), + Err(e) => self.set_status(format!("file delete failed: {e}")), } } @@ -1118,14 +1316,51 @@ impl App { self.detail = Some(detail); self.focus = Focus::List; } - Err(e) => self.status = format!("error loading detail: {e}"), + Err(e) => self.set_status(format!("error loading detail: {e}")), + } + } + + /// Jumps from the selected Calendar row to that episode's Library + /// detail — same idea as `jump_to_stuck_target`, but also lands on the + /// matching SxxExx so `c`/`d` apply to the aired episode, not S01E01. + pub async fn jump_to_calendar_entry(&mut self) { + let Some(idx) = self.calendar_state.selected() else { + return; + }; + let Some(entry) = self.calendar.get(idx).cloned() else { + return; + }; + match self.client.media_detail(entry.media_item_id).await { + Ok(detail) => { + let ep = detail.episodes.iter().position(|e| { + e.season_number == entry.season_number + && e.episode_number == entry.episode_number + }); + self.tab = Tab::Library; + self.episode_state + .select(ep.or(if detail.episodes.is_empty() { + None + } else { + Some(0) + })); + self.detail = Some(detail); + self.focus = Focus::List; + } + Err(e) => self.set_status(format!("error loading detail: {e}")), } } } #[cfg(test)] mod tests { - use super::item_root_folder; + use super::{ + clamp_list_state, health_row_count, item_root_folder, next_missing_index, + title_matches_query, + }; + use breadarr_shared::dto::{ + DuplicateGroup, EpisodeSummary, FlaggedFile, LibraryHealthReport, LibrarySummary, + }; + use ratatui::widgets::ListState; #[test] fn item_root_folder_replaces_dot_and_dotdot() { @@ -1141,4 +1376,99 @@ mod tests { "/lib/Foo_ Bar_Baz (2020)" ); } + + #[test] + fn title_matches_query_is_case_insensitive_substring() { + assert!(title_matches_query("The Matrix", "matrix")); + assert!(title_matches_query("The Matrix", "")); + assert!(!title_matches_query("The Matrix", "inception")); + } + + fn ep(season: i64, number: i64, monitored: bool, has_file: bool) -> EpisodeSummary { + EpisodeSummary { + id: season * 100 + number, + season_number: season, + episode_number: number, + title: None, + air_date: None, + monitored, + has_file, + } + } + + #[test] + fn next_missing_index_wraps_and_skips_owned_or_unmonitored() { + let episodes = vec![ + ep(1, 1, true, true), + ep(1, 2, true, false), + ep(1, 3, false, false), + ep(1, 4, true, false), + ]; + assert_eq!(next_missing_index(&episodes, 0), Some(1)); + assert_eq!(next_missing_index(&episodes, 2), Some(3)); + assert_eq!(next_missing_index(&episodes, 4), Some(1)); + assert_eq!(next_missing_index(&[], 0), None); + assert_eq!( + next_missing_index(&[ep(1, 1, true, true), ep(1, 2, false, false)], 0), + None + ); + } + + #[test] + fn clamp_list_state_pins_past_the_end_and_clears_empty() { + let mut state = ListState::default(); + state.select(Some(4)); + clamp_list_state(&mut state, 3); + assert_eq!(state.selected(), Some(2)); + clamp_list_state(&mut state, 0); + assert_eq!(state.selected(), None); + clamp_list_state(&mut state, 2); + assert_eq!(state.selected(), Some(0)); + } + + fn empty_health() -> LibraryHealthReport { + LibraryHealthReport { + corrupt_files: vec![], + under_quality_files: vec![], + no_subtitle_files: vec![], + no_english_audio_files: vec![], + non_english_default_audio_files: vec![], + duplicate_groups: vec![], + summary: LibrarySummary { + total_files: 0, + total_size_bytes: 0, + probed_files: 0, + by_video_codec: vec![], + sd_count: 0, + hd_720p_count: 0, + full_hd_1080p_count: 0, + uhd_4k_count: 0, + pct_with_subtitles: 0.0, + }, + } + } + + fn flagged(title: &str) -> FlaggedFile { + FlaggedFile { + episode_file_id: 1, + media_title: title.to_string(), + episode_label: None, + path: "/x".to_string(), + } + } + + #[test] + fn health_row_count_includes_headers_and_empty_placeholder() { + let mut report = empty_health(); + assert_eq!(health_row_count(&report), 1); + report.corrupt_files.push(flagged("A")); + report.corrupt_files.push(flagged("B")); + report.duplicate_groups.push(DuplicateGroup { + media_title: "C".into(), + episode_label: None, + paths: vec!["/a".into(), "/b".into()], + }); + // header + 2 files + header + 1 group + assert_eq!(health_row_count(&report), 5); + } } diff --git a/breadarr-tui/src/main.rs b/breadarr-tui/src/main.rs index f4135e3..d8af4b0 100644 --- a/breadarr-tui/src/main.rs +++ b/breadarr-tui/src/main.rs @@ -6,7 +6,7 @@ use std::time::Duration; use anyhow::Result; 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::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -51,6 +51,7 @@ async fn run( loop { app.poll_background().await; + app.expire_status(); if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh { app.refresh_active_tab().await; @@ -65,7 +66,7 @@ async fn run( if key.kind != KeyEventKind::Press { continue; } - handle_key(app, key.code, roots).await; + handle_key(app, key, roots).await; if app.should_quit { 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. if matches!(app.tab, Tab::Add) && matches!(app.focus, Focus::AddSearchInput) { match code { @@ -86,7 +89,28 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { KeyCode::Esc => { 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; @@ -131,12 +155,25 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { match code { 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('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 { 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.library_query.is_empty() => app.clear_library_filter(), Tab::Add => app.focus = Focus::AddSearchInput, 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::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) => { @@ -182,6 +220,16 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { KeyCode::Char('r') if matches!(app.tab, Tab::Review) => { 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() => { 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) { - let idx = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0); - app.tab = Tab::ALL[(idx + 1) % Tab::ALL.len()]; +fn tab_offset(current: Tab, delta: i32) -> Tab { + let idx = Tab::ALL.iter().position(|t| *t == current).unwrap_or(0) as i32; + 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.profile_detail = None; app.profile_weight_state.select(None); diff --git a/breadarr-tui/src/ui.rs b/breadarr-tui/src/ui.rs index 5f7735c..77ea537 100644 --- a/breadarr-tui/src/ui.rs +++ b/breadarr-tui/src/ui.rs @@ -5,6 +5,7 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs}; use ratatui::Frame; 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 // 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![ ("Esc", "back"), ("s", "search now"), + ("n", "next missing"), ("m", "monitor show"), ("e", "monitor episode"), ("S", "monitor season"), @@ -65,8 +67,12 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { ("d", "delete file"), ("c", "pick release"), ], + Tab::Library if matches!(app.focus, Focus::LibraryFilterInput) => { + vec![("Enter", "keep filter"), ("Esc", "clear filter")] + } Tab::Library => vec![ ("Enter", "open detail"), + ("/", "filter title"), ("f", "cycle filter"), ("o", "cycle sort"), ("m", "monitor toggle"), @@ -81,13 +87,17 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { } Tab::Profiles => vec![("Enter", "open profile")], Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")], + Tab::Calendar => vec![("Enter", "jump to show")], _ => vec![], } } const GLOBAL_KEYS: &[(&str, &str)] = &[ - ("Tab", "switch tab"), + ("Tab/S-Tab", "switch tab"), + ("1-8", "jump tab"), ("j/k", "move"), + ("g/G", "first/last"), + ("PgUp/PgDn", "page"), ("?", "help"), ("R", "refresh now"), ("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); let paragraph = Paragraph::new(lines).block( Block::default() @@ -138,8 +148,47 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) { 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) { - let titles: Vec = Tab::ALL.iter().map(|t| Line::from(t.title())).collect(); + let titles: Vec = 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 (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; } + if detail.kind == "movie" || detail.episodes.is_empty() { + draw_movie_detail(frame, area, app, detail); + return; + } + let items: Vec = detail .episodes .iter() @@ -286,9 +340,19 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { ])) }) .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) .block(Block::default().borders(Borders::ALL).title(format!( - "Monitored Shows ({}/{}) — filter: {} sort: {}", + "Library ({}/{}) — filter: {} sort: {}{search}{filter_caret}", app.library_view.len(), app.media_items.len(), 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); } +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 /// selected when `c` was pressed, scored (or gate-rejected with a reason) /// 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_repack { " [REPACK]" } else { "" }, ); - let verdict = match (c.score, &c.rejected_reason) { - (Some(score), _) => format!("score {score:.1}"), - (None, Some(reason)) => format!("REJECTED: {reason}"), - (None, None) => "unscored".to_string(), + let (verdict, color) = match (c.score, &c.rejected_reason) { + (Some(score), _) if score >= 8.0 => (format!("score {score:.1}"), Color::Green), + (Some(score), _) => (format!("score {score:.1}"), Color::Yellow), + (None, Some(reason)) => (format!("REJECTED: {reason}"), Color::Red), + (None, None) => ("unscored".to_string(), Color::DarkGray), }; - ListItem::new(format!( - "[{}] {} — {seeders} seeders, {size}{flags} — {verdict}", - c.source_name, c.raw_title - )) + ListItem::new(Line::from(vec![ + Span::raw(format!( + "[{}] {} — {seeders} seeders, {size}{flags} — ", + c.source_name, c.raw_title + )), + Span::styled(verdict, Style::default().fg(color)), + ])) }) .collect(); 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); } +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) { let items: Vec = app .releases .iter() .map(|r| { - ListItem::new(format!( - "[{}] {} — {} (score {:.1})", - r.status, - r.media_title, - r.raw_title, - r.score.unwrap_or(0.0) - )) + ListItem::new(Line::from(vec![ + Span::styled( + format!("[{}]", r.status), + Style::default().fg(history_status_color(&r.status)), + ), + Span::raw(format!( + " {} — {} (score {:.1})", + r.media_title, + r.raw_title, + r.score.unwrap_or(0.0) + )), + ])) }) .collect(); let list = List::new(items) @@ -384,7 +521,10 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) { Color::Green }; 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!( " {} -> {}", r.raw_release_title, @@ -439,7 +579,10 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) { .iter() .map(|g| { 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), ))) }) @@ -519,12 +662,21 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) { .add_results .iter() .map(|r| { - ListItem::new(format!( - "[{}] {} ({})", - r.kind.label(), - r.result.title, - r.result.year.map(|y| y.to_string()).unwrap_or_default() - )) + let kind_color = match r.kind { + crate::app::AddKind::Movie => Color::Magenta, + 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.year.map(|y| y.to_string()).unwrap_or_default() + )), + ])) }) .collect(); 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 — -/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Read-only, no -/// selection — a lookahead view, not something acted on directly here. +/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Selectable — +/// Enter jumps to that episode in Library. fn draw_calendar(frame: &mut Frame, area: Rect, app: &App) { let today = chrono::Local::now().date_naive().to_string(); let items: Vec = app .calendar .iter() .map(|e| { - let status = if e.has_file { - "✓" + let (status, color) = if e.has_file { + ("✓", Color::Green) } else if !e.monitored { - "-" + ("-", Color::DarkGray) } else if e.air_date.as_str() > today.as_str() { - "…" + ("…", Color::Yellow) } 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(""); - ListItem::new(format!( - "{status} {} {} S{:02}E{:02} {title}", - e.air_date, e.media_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}{today_mark}", + e.air_date, e.media_title, e.season_number, e.episode_number + )), + ])) }) .collect(); - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .title("Calendar — ✓ have it … upcoming ! aired but missing"), - ); - frame.render_widget(list, area); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Calendar — Enter: jump ✓ have it … upcoming ! aired but missing"), + ) + .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, @@ -588,7 +747,7 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(5), Constraint::Min(3)]) + .constraints([Constraint::Length(6), Constraint::Min(3)]) .split(area); 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)) .collect::>() .join(", "); + let cycles = app + .health + .as_ref() + .map(cycle_summary_line) + .unwrap_or_default(); 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}", s.total_files, 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.")); } - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .title("Flagged files"), - ); - frame.render_widget(list, chunks[1]); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Flagged files"), + ) + .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) -> 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