can't be bothered writing a commit message
This commit is contained in:
commit
697b009627
55 changed files with 21320 additions and 0 deletions
14
breadarr-tui/Cargo.toml
Normal file
14
breadarr-tui/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "breadarr-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
breadarr-shared.workspace = true
|
||||
tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
ratatui.workspace = true
|
||||
crossterm.workspace = true
|
||||
chrono.workspace = true
|
||||
792
breadarr-tui/src/app.rs
Normal file
792
breadarr-tui/src/app.rs
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
use anyhow::Result;
|
||||
use breadarr_shared::dto::{
|
||||
CalendarEntry, HealthDetail, LibraryHealthReport, MediaItemDetail, MediaItemSummary,
|
||||
QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchResult,
|
||||
StuckReport, WeightsDto,
|
||||
};
|
||||
use breadarr_shared::DaemonClient;
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tab {
|
||||
Library,
|
||||
History,
|
||||
Review,
|
||||
Add,
|
||||
Stuck,
|
||||
Calendar,
|
||||
LibraryHealth,
|
||||
Profiles,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub const ALL: [Tab; 8] = [
|
||||
Tab::Library,
|
||||
Tab::History,
|
||||
Tab::Review,
|
||||
Tab::Add,
|
||||
Tab::Stuck,
|
||||
Tab::Calendar,
|
||||
Tab::LibraryHealth,
|
||||
Tab::Profiles,
|
||||
];
|
||||
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Tab::Library => "Library",
|
||||
Tab::History => "History",
|
||||
Tab::Review => "Review Queue",
|
||||
Tab::Add => "Add Show",
|
||||
Tab::Stuck => "Stuck",
|
||||
Tab::Calendar => "Calendar",
|
||||
Tab::LibraryHealth => "Health",
|
||||
Tab::Profiles => "Profiles",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Focus {
|
||||
List,
|
||||
AddSearchInput,
|
||||
AddResults,
|
||||
/// 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
|
||||
/// submitted against the right target.
|
||||
Candidates,
|
||||
/// Text-entry mode for one weight axis on the Profiles tab's detail
|
||||
/// view — `App::weight_input_buffer` holds the in-progress digits.
|
||||
WeightInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AddKind {
|
||||
Series,
|
||||
Movie,
|
||||
}
|
||||
|
||||
impl AddKind {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
AddKind::Series => "TV",
|
||||
AddKind::Movie => "Movie",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
type WeightField = (
|
||||
&'static str,
|
||||
fn(&WeightsDto) -> f32,
|
||||
fn(&mut WeightsDto, f32),
|
||||
);
|
||||
|
||||
/// One row per `WeightsDto` axis, in the fixed order shown/edited on the
|
||||
/// Profiles tab.
|
||||
pub const WEIGHT_FIELDS: [WeightField; 9] = [
|
||||
("seeder", |w| w.seeder, |w, v| w.seeder = v),
|
||||
(
|
||||
"resolution_tier",
|
||||
|w| w.resolution_tier,
|
||||
|w, v| w.resolution_tier = v,
|
||||
),
|
||||
("source_tier", |w| w.source_tier, |w, v| w.source_tier = v),
|
||||
("codec_tier", |w| w.codec_tier, |w, v| w.codec_tier = v),
|
||||
("bit_depth", |w| w.bit_depth, |w, v| w.bit_depth = v),
|
||||
("container", |w| w.container, |w, v| w.container = v),
|
||||
(
|
||||
"group_allowlist",
|
||||
|w| w.group_allowlist,
|
||||
|w, v| w.group_allowlist = v,
|
||||
),
|
||||
("repack", |w| w.repack, |w, v| w.repack = v),
|
||||
("hdr", |w| w.hdr, |w, v| w.hdr = v),
|
||||
];
|
||||
|
||||
/// A search hit tagged with which endpoint it came from — `run_add_search`
|
||||
/// queries series and movies together (see its doc comment for why), so
|
||||
/// each result needs to remember its own kind rather than the app tracking
|
||||
/// one global mode.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AddResult {
|
||||
pub kind: AddKind,
|
||||
pub result: SearchResult,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub client: DaemonClient,
|
||||
pub daemon_up: bool,
|
||||
pub health: Option<HealthDetail>,
|
||||
pub tab: Tab,
|
||||
pub focus: Focus,
|
||||
pub status: String,
|
||||
pub should_quit: bool,
|
||||
|
||||
pub media_items: Vec<MediaItemSummary>,
|
||||
pub media_state: ListState,
|
||||
pub detail: Option<MediaItemDetail>,
|
||||
/// Selection within `detail.episodes` — separate from `media_state`
|
||||
/// since they're two different lists sharing the same tab.
|
||||
pub episode_state: ListState,
|
||||
|
||||
pub releases: Vec<ReleaseSummary>,
|
||||
pub releases_state: ListState,
|
||||
|
||||
pub review_items: Vec<ReviewQueueEntry>,
|
||||
pub review_state: ListState,
|
||||
|
||||
pub add_query: String,
|
||||
pub add_results: Vec<AddResult>,
|
||||
pub add_results_state: ListState,
|
||||
|
||||
pub stuck: Option<StuckReport>,
|
||||
pub calendar: Vec<CalendarEntry>,
|
||||
pub library_health: Option<LibraryHealthReport>,
|
||||
|
||||
pub candidates: Vec<ReleaseCandidate>,
|
||||
pub candidates_state: ListState,
|
||||
/// Which episode the current `candidates` list was fetched for —
|
||||
/// `None` means it was fetched for the open movie itself.
|
||||
pub candidates_episode_id: Option<i64>,
|
||||
|
||||
/// Set by a first `x` press in the Library detail view; a second press
|
||||
/// while this is `true` actually deletes. Any other key clears it. A
|
||||
/// lightweight guard against an accidental single keystroke deleting a
|
||||
/// tracked show/movie.
|
||||
pub confirm_delete: bool,
|
||||
|
||||
/// Same two-press guard as `confirm_delete`, but for deleting an
|
||||
/// imported *file* (not the whole tracked show/movie) — a separate
|
||||
/// flag since the two actions have different confirmation text and
|
||||
/// shouldn't arm each other.
|
||||
pub confirm_delete_file: bool,
|
||||
|
||||
pub quality_profiles: Vec<QualityProfileSummary>,
|
||||
pub profiles_state: ListState,
|
||||
/// The profile currently open for editing — separate from
|
||||
/// `quality_profiles` (like `detail` vs. `media_items`) so the two
|
||||
/// list levels (profiles, then that profile's weight axes) don't
|
||||
/// share a selection.
|
||||
pub profile_detail: Option<QualityProfileSummary>,
|
||||
pub profile_weight_state: ListState,
|
||||
/// In-progress digits while `Focus::WeightInput` is active.
|
||||
pub weight_input_buffer: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(client: DaemonClient) -> Self {
|
||||
Self {
|
||||
client,
|
||||
daemon_up: false,
|
||||
health: None,
|
||||
tab: Tab::Library,
|
||||
focus: Focus::List,
|
||||
status: String::new(),
|
||||
should_quit: false,
|
||||
media_items: Vec::new(),
|
||||
media_state: ListState::default(),
|
||||
detail: None,
|
||||
episode_state: ListState::default(),
|
||||
releases: Vec::new(),
|
||||
releases_state: ListState::default(),
|
||||
review_items: Vec::new(),
|
||||
review_state: ListState::default(),
|
||||
add_query: String::new(),
|
||||
add_results: Vec::new(),
|
||||
add_results_state: ListState::default(),
|
||||
stuck: None,
|
||||
calendar: Vec::new(),
|
||||
library_health: None,
|
||||
candidates: Vec::new(),
|
||||
candidates_state: ListState::default(),
|
||||
candidates_episode_id: None,
|
||||
confirm_delete: false,
|
||||
confirm_delete_file: false,
|
||||
quality_profiles: Vec::new(),
|
||||
profiles_state: ListState::default(),
|
||||
profile_detail: None,
|
||||
profile_weight_state: ListState::default(),
|
||||
weight_input_buffer: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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?;
|
||||
if self.media_state.selected().is_none() && !self.media_items.is_empty() {
|
||||
self.media_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 => {
|
||||
self.stuck = Some(self.client.stuck().await?);
|
||||
}
|
||||
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<i64> {
|
||||
self.detail.as_ref().map(|d| d.id)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
Tab::Library => (
|
||||
&mut self.episode_state,
|
||||
self.detail.as_ref().map_or(0, |d| d.episodes.len()),
|
||||
),
|
||||
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::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,
|
||||
};
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let current = state.selected().unwrap_or(0) as i32;
|
||||
let next = (current + delta).clamp(0, len as i32 - 1);
|
||||
state.select(Some(next as usize));
|
||||
}
|
||||
|
||||
pub async fn open_detail(&mut self) {
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
match self.client.media_detail(item.id).await {
|
||||
Ok(detail) => {
|
||||
self.episode_state.select(if detail.episodes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
});
|
||||
self.detail = Some(detail);
|
||||
}
|
||||
Err(e) => self.status = format!("error loading detail: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_detail(&mut self) {
|
||||
self.detail = None;
|
||||
self.episode_state.select(None);
|
||||
}
|
||||
|
||||
/// Toggles monitored on whichever episode is currently selected in the
|
||||
/// open detail view.
|
||||
pub async fn toggle_monitor_selected_episode(&mut self) {
|
||||
let Some(detail) = &self.detail else {
|
||||
return;
|
||||
};
|
||||
let Some(idx) = self.episode_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(episode) = detail.episodes.get(idx) else {
|
||||
return;
|
||||
};
|
||||
let episode_id = episode.id;
|
||||
let result = if episode.monitored {
|
||||
self.client.unmonitor_episode(episode_id).await
|
||||
} else {
|
||||
self.client.monitor_episode(episode_id).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles monitored on every episode in the currently-selected
|
||||
/// episode's season at once.
|
||||
pub async fn toggle_monitor_selected_season(&mut self) {
|
||||
let Some(detail) = &self.detail else {
|
||||
return;
|
||||
};
|
||||
let Some(idx) = self.episode_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(episode) = detail.episodes.get(idx) else {
|
||||
return;
|
||||
};
|
||||
let (media_item_id, season_number, monitored) =
|
||||
(detail.id, episode.season_number as i64, episode.monitored);
|
||||
let result = if monitored {
|
||||
self.client
|
||||
.unmonitor_season(media_item_id, season_number)
|
||||
.await
|
||||
} else {
|
||||
self.client
|
||||
.monitor_season(media_item_id, season_number)
|
||||
.await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.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}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn approve_selected_review(&mut self) {
|
||||
let Some(idx) = self.review_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = self.review_items.get(idx).cloned() else {
|
||||
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}"),
|
||||
}
|
||||
self.review_items = self.client.review_queue().await.unwrap_or_default();
|
||||
}
|
||||
|
||||
pub async fn reject_selected_review(&mut self) {
|
||||
let Some(idx) = self.review_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = self.review_items.get(idx).cloned() else {
|
||||
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}"),
|
||||
}
|
||||
self.review_items = self.client.review_queue().await.unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Searches series and movies together instead of requiring the user to
|
||||
/// pick a mode first — a prior single-mode-plus-toggle design left the
|
||||
/// toggle bound to F2, which some terminals/window managers swallow
|
||||
/// before it ever reaches the TUI, silently stranding the search in the
|
||||
/// wrong mode with no visible error (a real, confusing dead end hit live:
|
||||
/// 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() {
|
||||
return;
|
||||
}
|
||||
let (series_result, movie_result) = tokio::join!(
|
||||
self.client.search_series(&self.add_query),
|
||||
self.client.search_movies(&self.add_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;
|
||||
}
|
||||
|
||||
pub async fn add_selected_search_result(&mut self, root_folder: &str) {
|
||||
let Some(idx) = self.add_results_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(AddResult { kind, result }) = self.add_results.get(idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
let outcome = match kind {
|
||||
AddKind::Series => {
|
||||
let req = breadarr_shared::dto::AddSeriesRequest {
|
||||
tvdb_id: result.external_id.clone(),
|
||||
title: result.title.clone(),
|
||||
year: result.year,
|
||||
aliases: Vec::new(),
|
||||
root_folder: root_folder.to_string(),
|
||||
};
|
||||
self.client.add_series(&req).await.map(|r| r.media_item_id)
|
||||
}
|
||||
AddKind::Movie => {
|
||||
let req = breadarr_shared::dto::AddMovieRequest {
|
||||
tmdb_id: result.external_id.clone(),
|
||||
title: result.title.clone(),
|
||||
year: result.year,
|
||||
root_folder: root_folder.to_string(),
|
||||
};
|
||||
self.client.add_movie(&req).await.map(|r| r.media_item_id)
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
Ok(media_item_id) => {
|
||||
self.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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual "search now" for the show/movie currently open in the Library
|
||||
/// detail view — can take a while (jittered, one request per missing
|
||||
/// item), so the status line makes that explicit rather than looking
|
||||
/// like the UI hung.
|
||||
pub async fn search_now_selected(&mut self) {
|
||||
let Some(id) = self.detail.as_ref().map(|d| d.id) else {
|
||||
return;
|
||||
};
|
||||
self.status = "searching now (this can take a while)...".to_string();
|
||||
match self.client.search_now(id).await {
|
||||
Ok(stats) => {
|
||||
self.status = format!(
|
||||
"search complete: {} target(s), {} grabbed, {} error(s)",
|
||||
stats.targets, stats.grabbed, stats.errors
|
||||
);
|
||||
}
|
||||
Err(e) => self.status = format!("search failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches candidates for whatever's selected in the open detail view —
|
||||
/// the currently-highlighted episode, or the movie itself if there's no
|
||||
/// episode list. Can take a while, same as `search_now_selected`, since
|
||||
/// it runs a real search under the hood.
|
||||
pub async fn fetch_candidates_for_selected(&mut self) {
|
||||
let Some(detail) = &self.detail else {
|
||||
return;
|
||||
};
|
||||
let episode_id = if detail.kind == "movie" {
|
||||
None
|
||||
} else {
|
||||
let Some(idx) = self.episode_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(episode) = detail.episodes.get(idx) else {
|
||||
return;
|
||||
};
|
||||
Some(episode.id)
|
||||
};
|
||||
let media_item_id = detail.id;
|
||||
|
||||
self.status = "fetching candidates (this can take a while)...".to_string();
|
||||
let result = match episode_id {
|
||||
Some(id) => self.client.episode_candidates(id).await,
|
||||
None => self.client.movie_candidates(media_item_id).await,
|
||||
};
|
||||
match result {
|
||||
Ok(candidates) => {
|
||||
self.status = format!("{} candidate(s) found", candidates.len());
|
||||
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;
|
||||
}
|
||||
Err(e) => self.status = format!("candidate fetch failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Grabs whichever candidate is currently selected in the picker
|
||||
/// overlay, then returns to the normal detail view and refreshes it.
|
||||
pub async fn grab_selected_candidate(&mut self) {
|
||||
let Some(idx) = self.candidates_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(candidate) = self.candidates.get(idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(media_item_id) = self.detail.as_ref().map(|d| d.id) else {
|
||||
return;
|
||||
};
|
||||
let result = match self.candidates_episode_id {
|
||||
Some(episode_id) => {
|
||||
self.client
|
||||
.grab_episode_candidate(episode_id, &candidate)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.client
|
||||
.grab_movie_candidate(media_item_id, &candidate)
|
||||
.await
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.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}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_candidates(&mut self) {
|
||||
self.candidates.clear();
|
||||
self.candidates_state.select(None);
|
||||
self.candidates_episode_id = None;
|
||||
self.focus = Focus::List;
|
||||
}
|
||||
|
||||
pub fn open_profile_detail(&mut self) {
|
||||
if !matches!(self.tab, Tab::Profiles) || self.profile_detail.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(idx) = self.profiles_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(profile) = self.quality_profiles.get(idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
self.profile_detail = Some(profile);
|
||||
self.profile_weight_state.select(Some(0));
|
||||
}
|
||||
|
||||
pub fn close_profile_detail(&mut self) {
|
||||
self.profile_detail = None;
|
||||
self.profile_weight_state.select(None);
|
||||
self.weight_input_buffer.clear();
|
||||
self.focus = Focus::List;
|
||||
}
|
||||
|
||||
/// Seeds the edit buffer from the currently-selected weight axis's
|
||||
/// value and switches focus into text-entry mode — mirrors the Add
|
||||
/// tab's search-input pattern (`Focus::AddSearchInput`), just for a
|
||||
/// numeric field instead of a search query.
|
||||
pub fn start_editing_selected_weight(&mut self) {
|
||||
let Some(profile) = &self.profile_detail else {
|
||||
return;
|
||||
};
|
||||
let Some(idx) = self.profile_weight_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some((_, get, _)) = WEIGHT_FIELDS.get(idx) else {
|
||||
return;
|
||||
};
|
||||
self.weight_input_buffer = format!("{}", get(&profile.weights));
|
||||
self.focus = Focus::WeightInput;
|
||||
}
|
||||
|
||||
pub fn cancel_weight_edit(&mut self) {
|
||||
self.weight_input_buffer.clear();
|
||||
self.focus = Focus::List;
|
||||
}
|
||||
|
||||
/// Parses the edit buffer, applies it to the in-memory profile, and
|
||||
/// submits the *complete* resolved weights set to the daemon — the API
|
||||
/// always overwrites the full stored JSON (see `update_weights`'s doc
|
||||
/// comment server-side), so every other axis has to be sent along
|
||||
/// unchanged, not just the one being edited.
|
||||
pub async fn commit_weight_edit(&mut self) {
|
||||
let Some(idx) = self.profile_weight_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some((name, _, set)) = WEIGHT_FIELDS.get(idx) else {
|
||||
return;
|
||||
};
|
||||
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);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(profile) = &mut self.profile_detail else {
|
||||
return;
|
||||
};
|
||||
set(&mut profile.weights, value);
|
||||
let profile_id = profile.id;
|
||||
let weights = profile.weights;
|
||||
|
||||
match self
|
||||
.client
|
||||
.update_quality_profile_weights(profile_id, weights)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.status = format!("{name} updated to {value}");
|
||||
self.weight_input_buffer.clear();
|
||||
self.focus = Focus::List;
|
||||
}
|
||||
Err(e) => self.status = format!("weight update failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn toggle_monitor_selected(&mut self) {
|
||||
let Some(detail) = &self.detail else {
|
||||
return;
|
||||
};
|
||||
let id = detail.id;
|
||||
let result = if detail.monitored {
|
||||
self.client.unmonitor(id).await
|
||||
} else {
|
||||
self.client.monitor(id).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// First call arms the confirmation; a second call while armed actually
|
||||
/// deletes. Any other keypress (see `main.rs`) clears the pending state.
|
||||
pub async fn delete_selected(&mut self) {
|
||||
let Some(id) = self.detail.as_ref().map(|d| d.id) else {
|
||||
return;
|
||||
};
|
||||
if !self.confirm_delete {
|
||||
self.confirm_delete = true;
|
||||
self.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.detail = None;
|
||||
self.media_items = self.client.list_media().await.unwrap_or_default();
|
||||
}
|
||||
Err(e) => self.status = format!("delete failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the imported file for whatever's selected — the movie
|
||||
/// itself if the open detail is a movie, or the currently-selected
|
||||
/// episode otherwise — freeing it to be grabbed again. Same
|
||||
/// arm-then-confirm pattern as `delete_selected`, via
|
||||
/// `confirm_delete_file` instead so the two don't cross-arm.
|
||||
pub async fn delete_selected_file(&mut self) {
|
||||
let Some(detail) = &self.detail else {
|
||||
return;
|
||||
};
|
||||
if !self.confirm_delete_file {
|
||||
self.confirm_delete_file = true;
|
||||
self.status = "press d again to confirm deleting this file".to_string();
|
||||
return;
|
||||
}
|
||||
self.confirm_delete_file = false;
|
||||
|
||||
let result = if detail.kind == "movie" {
|
||||
self.client.delete_movie_file(detail.id).await
|
||||
} else {
|
||||
let Some(idx) = self.episode_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(episode) = detail.episodes.get(idx) else {
|
||||
return;
|
||||
};
|
||||
self.client.delete_episode_file(episode.id).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
187
breadarr-tui/src/main.rs
Normal file
187
breadarr-tui/src/main.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
mod app;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use breadarr_shared::{Config, DaemonClient};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use app::{App, Focus, Tab};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
let base_url = format!("http://{}", config.daemon.listen_addr);
|
||||
let client = DaemonClient::new(base_url, &config.daemon.api_token);
|
||||
let root_folder = config.default_root_folder().to_string_lossy().to_string();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new(client);
|
||||
let result = run(&mut terminal, &mut app, &root_folder).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn run(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
root_folder: &str,
|
||||
) -> Result<()> {
|
||||
let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10);
|
||||
|
||||
loop {
|
||||
if last_refresh.elapsed() >= Duration::from_secs(3) {
|
||||
app.refresh_active_tab().await;
|
||||
last_refresh = tokio::time::Instant::now();
|
||||
}
|
||||
|
||||
terminal.draw(|frame| ui::draw(frame, app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(200))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
handle_key(app, key.code, root_folder).await;
|
||||
if app.should_quit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
|
||||
// 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 {
|
||||
KeyCode::Enter => app.run_add_search().await,
|
||||
KeyCode::Char(c) => app.add_query.push(c),
|
||||
KeyCode::Backspace => {
|
||||
app.add_query.pop();
|
||||
}
|
||||
KeyCode::Esc => app.should_quit = true,
|
||||
KeyCode::Tab => cycle_tab(app),
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Typing a replacement value for the selected weight axis, same
|
||||
// priority-over-global-keys reasoning as the add-show search box above.
|
||||
if matches!(app.tab, Tab::Profiles) && matches!(app.focus, Focus::WeightInput) {
|
||||
match code {
|
||||
KeyCode::Enter => app.commit_weight_edit().await,
|
||||
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' || c == '-' => {
|
||||
app.weight_input_buffer.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.weight_input_buffer.pop();
|
||||
}
|
||||
KeyCode::Esc => app.cancel_weight_edit(),
|
||||
_ => {}
|
||||
}
|
||||
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."
|
||||
if app.confirm_delete && code != KeyCode::Char('x') {
|
||||
app.confirm_delete = false;
|
||||
}
|
||||
if app.confirm_delete_file && code != KeyCode::Char('d') {
|
||||
app.confirm_delete_file = false;
|
||||
}
|
||||
|
||||
match code {
|
||||
KeyCode::Char('q') => app.should_quit = true,
|
||||
KeyCode::Tab => cycle_tab(app),
|
||||
KeyCode::Char('j') | KeyCode::Down => app.move_selection(1),
|
||||
KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1),
|
||||
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::Add => app.focus = Focus::AddSearchInput,
|
||||
Tab::Profiles if app.profile_detail.is_some() => app.close_profile_detail(),
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Enter => match app.tab {
|
||||
Tab::Library if matches!(app.focus, Focus::Candidates) => {
|
||||
app.grab_selected_candidate().await;
|
||||
}
|
||||
Tab::Library => app.open_detail().await,
|
||||
Tab::Add => match app.focus {
|
||||
Focus::AddResults => app.add_selected_search_result(root_folder).await,
|
||||
_ => app.focus = Focus::AddSearchInput,
|
||||
},
|
||||
Tab::Profiles if app.profile_detail.is_some() => {
|
||||
app.start_editing_selected_weight();
|
||||
}
|
||||
Tab::Profiles => app.open_profile_detail(),
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Char('a') if matches!(app.tab, Tab::Review) => {
|
||||
app.approve_selected_review().await;
|
||||
}
|
||||
KeyCode::Char('r') if matches!(app.tab, Tab::Review) => {
|
||||
app.reject_selected_review().await;
|
||||
}
|
||||
KeyCode::Char('s') if matches!(app.tab, Tab::Library) && app.detail.is_some() => {
|
||||
app.search_now_selected().await;
|
||||
}
|
||||
KeyCode::Char('m') if matches!(app.tab, Tab::Library) && app.detail.is_some() => {
|
||||
app.toggle_monitor_selected().await;
|
||||
}
|
||||
KeyCode::Char('e') if matches!(app.tab, Tab::Library) && app.detail.is_some() => {
|
||||
app.toggle_monitor_selected_episode().await;
|
||||
}
|
||||
KeyCode::Char('S') if matches!(app.tab, Tab::Library) && app.detail.is_some() => {
|
||||
app.toggle_monitor_selected_season().await;
|
||||
}
|
||||
KeyCode::Char('x') if matches!(app.tab, Tab::Library) && app.detail.is_some() => {
|
||||
app.delete_selected().await;
|
||||
}
|
||||
KeyCode::Char('d') if matches!(app.tab, Tab::Library) && app.detail.is_some() => {
|
||||
app.delete_selected_file().await;
|
||||
}
|
||||
KeyCode::Char('c')
|
||||
if matches!(app.tab, Tab::Library)
|
||||
&& app.detail.is_some()
|
||||
&& !matches!(app.focus, Focus::Candidates) =>
|
||||
{
|
||||
app.fetch_candidates_for_selected().await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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()];
|
||||
app.detail = None;
|
||||
app.profile_detail = None;
|
||||
app.profile_weight_state.select(None);
|
||||
app.focus = if matches!(app.tab, Tab::Add) {
|
||||
Focus::AddSearchInput
|
||||
} else {
|
||||
Focus::List
|
||||
};
|
||||
}
|
||||
559
breadarr-tui/src/ui.rs
Normal file
559
breadarr-tui/src/ui.rs
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
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::Frame;
|
||||
|
||||
use crate::app::{App, Focus, Tab};
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
draw_tabs(frame, chunks[0], app);
|
||||
|
||||
match app.tab {
|
||||
Tab::Library => draw_library(frame, chunks[1], app),
|
||||
Tab::History => draw_history(frame, chunks[1], app),
|
||||
Tab::Review => draw_review(frame, chunks[1], app),
|
||||
Tab::Add => draw_add(frame, chunks[1], app),
|
||||
Tab::Stuck => draw_stuck(frame, chunks[1], app),
|
||||
Tab::Calendar => draw_calendar(frame, chunks[1], app),
|
||||
Tab::LibraryHealth => draw_library_health(frame, chunks[1], app),
|
||||
Tab::Profiles => draw_profiles(frame, chunks[1], app),
|
||||
}
|
||||
|
||||
draw_status(frame, chunks[2], 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 selected = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0);
|
||||
|
||||
let (daemon_label, daemon_color) = match (&app.daemon_up, &app.health) {
|
||||
(true, Some(h)) if h.search_halted => {
|
||||
("daemon: UP — ⚠ search halted".to_string(), Color::Yellow)
|
||||
}
|
||||
(true, Some(h)) => {
|
||||
let any_failed = [
|
||||
&h.last_grab_cycle,
|
||||
&h.last_import_cycle,
|
||||
&h.last_search_cycle,
|
||||
&h.last_upgrade_cycle,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|c| !c.ok);
|
||||
if any_failed {
|
||||
(
|
||||
"daemon: UP — last cycle had errors".to_string(),
|
||||
Color::Yellow,
|
||||
)
|
||||
} else {
|
||||
("daemon: UP".to_string(), Color::Green)
|
||||
}
|
||||
}
|
||||
(true, None) => ("daemon: UP".to_string(), Color::Green),
|
||||
(false, _) => ("daemon: DOWN".to_string(), Color::Red),
|
||||
};
|
||||
|
||||
let tabs = Tabs::new(titles)
|
||||
.block(Block::default().borders(Borders::ALL).title(Span::styled(
|
||||
daemon_label,
|
||||
Style::default().fg(daemon_color),
|
||||
)))
|
||||
.select(selected)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.fg(Color::Cyan),
|
||||
);
|
||||
frame.render_widget(tabs, area);
|
||||
}
|
||||
|
||||
fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
||||
if let Some(detail) = &app.detail {
|
||||
if matches!(app.focus, Focus::Candidates) {
|
||||
draw_candidates(frame, area, app, &detail.title);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = detail
|
||||
.episodes
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let status = if e.has_file {
|
||||
"✓"
|
||||
} else if e.monitored {
|
||||
"…"
|
||||
} else {
|
||||
"-"
|
||||
};
|
||||
let title = e.title.as_deref().unwrap_or("");
|
||||
ListItem::new(format!(
|
||||
"{status} S{:02}E{:02} {title}",
|
||||
e.season_number, e.episode_number
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let monitor_label = if detail.monitored {
|
||||
"monitored"
|
||||
} else {
|
||||
"unmonitored"
|
||||
};
|
||||
let confirm = if app.confirm_delete {
|
||||
" — x AGAIN TO DELETE SHOW"
|
||||
} else if app.confirm_delete_file {
|
||||
" — d AGAIN TO DELETE FILE"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
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}",
|
||||
detail.title,
|
||||
detail.year.map(|y| y.to_string()).unwrap_or_default()
|
||||
)))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut state = app.episode_state.clone();
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.media_items
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let missing = 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
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Monitored Shows — Enter for detail"),
|
||||
)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut state = app.media_state.clone();
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str) {
|
||||
if app.candidates.is_empty() {
|
||||
let placeholder = Paragraph::new("loading candidates...").block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!("{media_title} — candidates — Esc: cancel")),
|
||||
);
|
||||
frame.render_widget(placeholder, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let size = c
|
||||
.size_bytes
|
||||
.map(|b| format!("{:.2} GB", b as f64 / 1_073_741_824.0))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
let seeders = c
|
||||
.seeders
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
let flags = format!(
|
||||
"{}{}",
|
||||
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(),
|
||||
};
|
||||
ListItem::new(format!(
|
||||
"[{}] {} — {seeders} seeders, {size}{flags} — {verdict}",
|
||||
c.source_name, c.raw_title
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||
"{media_title} — candidates — Enter: grab Esc: cancel"
|
||||
)))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut state = app.candidates_state.clone();
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn draw_history(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let items: Vec<ListItem> = app
|
||||
.releases
|
||||
.iter()
|
||||
.map(|r| {
|
||||
ListItem::new(format!(
|
||||
"[{}] {} — {} (score {:.1})",
|
||||
r.status,
|
||||
r.media_title,
|
||||
r.raw_title,
|
||||
r.score.unwrap_or(0.0)
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title("Grab History"))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut state = app.releases_state.clone();
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let items: Vec<ListItem> = 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("?")
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Review Queue — a: approve, r: reject"),
|
||||
)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut state = app.review_state.clone();
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
/// "Why don't I have this yet" — grabs sitting without importing longer
|
||||
/// 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.
|
||||
fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let Some(report) = &app.stuck else {
|
||||
let placeholder = Paragraph::new("loading...").block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Stuck — why don't I have this yet"),
|
||||
);
|
||||
frame.render_widget(placeholder, area);
|
||||
return;
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(3), Constraint::Min(3)])
|
||||
.split(area);
|
||||
|
||||
let stalled_items: Vec<ListItem> = report
|
||||
.stalled_grabs
|
||||
.iter()
|
||||
.map(|g| {
|
||||
ListItem::new(format!(
|
||||
"{} — {} (grabbed {})",
|
||||
g.media_title, g.raw_title, g.grabbed_at
|
||||
))
|
||||
})
|
||||
.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 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")
|
||||
))
|
||||
})
|
||||
.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]);
|
||||
}
|
||||
|
||||
fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(3)])
|
||||
.split(area);
|
||||
|
||||
let input_style = match app.focus {
|
||||
Focus::AddSearchInput => Style::default().fg(Color::Cyan),
|
||||
_ => Style::default(),
|
||||
};
|
||||
let input = Paragraph::new(app.add_query.as_str())
|
||||
.style(input_style)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Search — Enter to search movies and TV together"),
|
||||
);
|
||||
frame.render_widget(input, chunks[0]);
|
||||
|
||||
let items: Vec<ListItem> = 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()
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Results — Enter to add"),
|
||||
)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut state = app.add_results_state.clone();
|
||||
frame.render_stateful_widget(list, chunks[1], &mut state);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn draw_calendar(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let today = chrono::Local::now().date_naive().to_string();
|
||||
let items: Vec<ListItem> = app
|
||||
.calendar
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let status = if e.has_file {
|
||||
"✓"
|
||||
} else if !e.monitored {
|
||||
"-"
|
||||
} else if e.air_date.as_str() > today.as_str() {
|
||||
"…"
|
||||
} else {
|
||||
"!" // aired, monitored, still missing
|
||||
};
|
||||
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
|
||||
))
|
||||
})
|
||||
.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);
|
||||
}
|
||||
|
||||
/// Read-only report on `media_file_probe` state: corruption, under-quality,
|
||||
/// missing-subtitle/English-audio, non-English-default-audio, and duplicate
|
||||
/// files, plus a library-wide summary — no selection/navigation, same shape
|
||||
/// as `draw_stuck`.
|
||||
fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let Some(report) = &app.library_health else {
|
||||
let placeholder = Paragraph::new("loading...").block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Library Health"),
|
||||
);
|
||||
frame.render_widget(placeholder, area);
|
||||
return;
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(5), Constraint::Min(3)])
|
||||
.split(area);
|
||||
|
||||
let s = &report.summary;
|
||||
let codec_summary = s
|
||||
.by_video_codec
|
||||
.iter()
|
||||
.map(|c| format!("{}={}", c.codec, c.count))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let summary_text = format!(
|
||||
"{} 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,
|
||||
s.probed_files,
|
||||
s.sd_count,
|
||||
s.hd_720p_count,
|
||||
s.full_hd_1080p_count,
|
||||
s.uhd_4k_count,
|
||||
s.pct_with_subtitles,
|
||||
);
|
||||
let summary = Paragraph::new(summary_text)
|
||||
.wrap(ratatui::widgets::Wrap { trim: true })
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Library summary"),
|
||||
);
|
||||
frame.render_widget(summary, chunks[0]);
|
||||
|
||||
let mut items: Vec<ListItem> = Vec::new();
|
||||
let mut section = |label: &str, files: &[breadarr_shared::dto::FlaggedFile]| {
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
items.push(ListItem::new(Span::styled(
|
||||
format!("── {label} ({}) ──", files.len()),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
for f in files {
|
||||
let label = f.episode_label.as_deref().unwrap_or("");
|
||||
items.push(ListItem::new(format!(
|
||||
" {} {label} — {}",
|
||||
f.media_title, f.path
|
||||
)));
|
||||
}
|
||||
};
|
||||
section("Corrupt / unreadable", &report.corrupt_files);
|
||||
section("Under 1080p", &report.under_quality_files);
|
||||
section("No English audio", &report.no_english_audio_files);
|
||||
section(
|
||||
"Non-English default audio",
|
||||
&report.non_english_default_audio_files,
|
||||
);
|
||||
section("No subtitles", &report.no_subtitle_files);
|
||||
|
||||
if !report.duplicate_groups.is_empty() {
|
||||
items.push(ListItem::new(Span::styled(
|
||||
format!("── Duplicate files ({}) ──", report.duplicate_groups.len()),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
for g in &report.duplicate_groups {
|
||||
let label = g.episode_label.as_deref().unwrap_or("");
|
||||
items.push(ListItem::new(format!(
|
||||
" {} {label} — {} copies",
|
||||
g.media_title,
|
||||
g.paths.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
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]);
|
||||
}
|
||||
|
||||
/// Quality-profile weight editing — list of profiles, then (once one is
|
||||
/// opened) a flat list of its 9 scoring axes with an inline text-input
|
||||
/// overlay while `Focus::WeightInput` is active.
|
||||
fn draw_profiles(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let Some(profile) = &app.profile_detail else {
|
||||
let items: Vec<ListItem> = app
|
||||
.quality_profiles
|
||||
.iter()
|
||||
.map(|p| ListItem::new(format!("{} ({})", p.name, p.kind)))
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Quality Profiles — Enter: edit weights"),
|
||||
)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut state = app.profiles_state.clone();
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
return;
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(3)])
|
||||
.split(area);
|
||||
|
||||
let editing_label = app
|
||||
.profile_weight_state
|
||||
.selected()
|
||||
.and_then(|idx| crate::app::WEIGHT_FIELDS.get(idx))
|
||||
.map(|(name, ..)| *name)
|
||||
.unwrap_or("");
|
||||
let input_style = match app.focus {
|
||||
Focus::WeightInput => Style::default().fg(Color::Cyan),
|
||||
_ => Style::default(),
|
||||
};
|
||||
let input = Paragraph::new(app.weight_input_buffer.as_str())
|
||||
.style(input_style)
|
||||
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||
"Editing {editing_label} — Enter: save Esc: cancel"
|
||||
)));
|
||||
frame.render_widget(input, chunks[0]);
|
||||
|
||||
let items: Vec<ListItem> = crate::app::WEIGHT_FIELDS
|
||||
.iter()
|
||||
.map(|(name, get, _)| ListItem::new(format!("{name}: {}", get(&profile.weights))))
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||
"{} — Enter: edit selected Esc: back",
|
||||
profile.name
|
||||
)))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut state = app.profile_weight_state.clone();
|
||||
frame.render_stateful_widget(list, chunks[1], &mut state);
|
||||
}
|
||||
|
||||
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 {
|
||||
app.status.clone()
|
||||
};
|
||||
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