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.
1474 lines
53 KiB
Rust
1474 lines
53 KiB
Rust
use anyhow::Result;
|
|
use breadarr_shared::dto::{
|
|
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
|
|
/// added with the same title must not collide on disk, and Jellyfin's
|
|
/// per-category libraries only see files under their own category root.
|
|
pub struct LibraryRoots {
|
|
pub series: String,
|
|
pub movies: String,
|
|
}
|
|
|
|
/// Builds the per-item folder a freshly added series/movie lands in —
|
|
/// `{root}/{Title} ({Year})`, matching the convention the importer already
|
|
/// assumes (`import_one` places a movie's file directly inside its
|
|
/// `media_item.root_folder`, with no further subfolder of its own, and
|
|
/// `season_dir` does the same for a show's `Season NN` folders). Passing the
|
|
/// bare category root straight through as `root_folder` — the bug this
|
|
/// replaces — landed every new grab directly in that shared root instead of
|
|
/// its own show/movie folder.
|
|
fn item_root_folder(root: &str, title: &str, year: Option<i64>) -> String {
|
|
let sanitized: String = title
|
|
.chars()
|
|
.map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c })
|
|
.collect();
|
|
// `.` / `..` survive the character class above and would make
|
|
// `root.join(...)` walk out of the library root.
|
|
let sanitized = if sanitized == "." || sanitized == ".." {
|
|
"_".to_string()
|
|
} else {
|
|
sanitized
|
|
};
|
|
let folder_name = match year {
|
|
Some(y) => format!("{sanitized} ({y})"),
|
|
None => sanitized,
|
|
};
|
|
Path::new(root)
|
|
.join(folder_name)
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
#[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",
|
|
Tab::Add => "Add",
|
|
Tab::Stuck => "Stuck",
|
|
Tab::Calendar => "Calendar",
|
|
Tab::LibraryHealth => "Health",
|
|
Tab::Profiles => "Profiles",
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
/// 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",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Client-side filter over the already-fetched `media_items` — the server
|
|
/// has no filter/sort query params, and the library is small enough that
|
|
/// refiltering the in-memory `Vec` on every keypress is free.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LibraryFilter {
|
|
All,
|
|
Series,
|
|
Movies,
|
|
Missing,
|
|
Unmonitored,
|
|
}
|
|
|
|
impl LibraryFilter {
|
|
pub const ALL: [LibraryFilter; 5] = [
|
|
LibraryFilter::All,
|
|
LibraryFilter::Series,
|
|
LibraryFilter::Movies,
|
|
LibraryFilter::Missing,
|
|
LibraryFilter::Unmonitored,
|
|
];
|
|
|
|
pub fn next(self) -> Self {
|
|
let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
|
|
Self::ALL[(idx + 1) % Self::ALL.len()]
|
|
}
|
|
|
|
pub fn label(&self) -> &'static str {
|
|
match self {
|
|
LibraryFilter::All => "all",
|
|
LibraryFilter::Series => "series",
|
|
LibraryFilter::Movies => "movies",
|
|
LibraryFilter::Missing => "missing",
|
|
LibraryFilter::Unmonitored => "unmonitored",
|
|
}
|
|
}
|
|
|
|
fn matches(&self, m: &MediaItemSummary) -> bool {
|
|
match self {
|
|
LibraryFilter::All => true,
|
|
LibraryFilter::Series => m.kind == "series",
|
|
LibraryFilter::Movies => m.kind == "movie",
|
|
LibraryFilter::Missing => m.missing_count > 0,
|
|
LibraryFilter::Unmonitored => !m.monitored,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LibrarySort {
|
|
TitleAsc,
|
|
MissingDesc,
|
|
Kind,
|
|
}
|
|
|
|
impl LibrarySort {
|
|
pub const ALL: [LibrarySort; 3] = [
|
|
LibrarySort::TitleAsc,
|
|
LibrarySort::MissingDesc,
|
|
LibrarySort::Kind,
|
|
];
|
|
|
|
pub fn next(self) -> Self {
|
|
let idx = Self::ALL.iter().position(|s| *s == self).unwrap_or(0);
|
|
Self::ALL[(idx + 1) % Self::ALL.len()]
|
|
}
|
|
|
|
pub fn label(&self) -> &'static str {
|
|
match self {
|
|
LibrarySort::TitleAsc => "title",
|
|
LibrarySort::MissingDesc => "missing",
|
|
LibrarySort::Kind => "kind",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Which half of the Stuck tab has selection/arrow-key focus.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum StuckSection {
|
|
Stalled,
|
|
Maxed,
|
|
}
|
|
|
|
/// A weight axis's display name plus a getter/setter pair, so
|
|
/// `WEIGHT_FIELDS` can enumerate `WeightsDto`'s fields by position instead
|
|
/// of every caller matching on an index.
|
|
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,
|
|
}
|
|
|
|
/// Result of a long `DaemonClient` call spawned off the draw loop so
|
|
/// `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<SearchNowResult>),
|
|
Candidates {
|
|
episode_id: Option<i64>,
|
|
result: Result<Vec<ReleaseCandidate>>,
|
|
},
|
|
AddSearch {
|
|
results: Vec<AddResult>,
|
|
errors: Vec<String>,
|
|
},
|
|
}
|
|
|
|
/// 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<usize> {
|
|
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 {
|
|
pub client: DaemonClient,
|
|
pub daemon_up: bool,
|
|
pub health: Option<HealthDetail>,
|
|
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<Instant>,
|
|
pub should_quit: bool,
|
|
/// Toggled by `?`; intercepted at the top of `main.rs`'s key handler
|
|
/// like `Focus::AddSearchInput`/`Focus::WeightInput`, but kept as its
|
|
/// own bool (not folded into `Focus`) since it needs to open from any
|
|
/// tab rather than being scoped to one.
|
|
pub help_visible: bool,
|
|
/// Set by the `R` key; consumed by `main.rs`'s refresh loop to bypass
|
|
/// the normal 3s throttle for one immediate refresh.
|
|
pub force_refresh: bool,
|
|
|
|
pub media_items: Vec<MediaItemSummary>,
|
|
pub media_state: ListState,
|
|
/// Filtered+sorted indices into `media_items`, recomputed by
|
|
/// `recompute_library_view` — the top-level Library list and
|
|
/// `media_state` always operate over this, never over `media_items`
|
|
/// directly, so filter/sort never has to touch the raw fetched data.
|
|
pub library_view: Vec<usize>,
|
|
pub library_filter: LibraryFilter,
|
|
pub library_sort: LibrarySort,
|
|
/// Incremental title filter from `/` — applied on top of
|
|
/// `library_filter`/`library_sort` inside `recompute_library_view`.
|
|
pub library_query: String,
|
|
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,
|
|
/// 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<AddResult>,
|
|
pub add_results_state: ListState,
|
|
|
|
pub stuck: Option<StuckReport>,
|
|
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<CalendarEntry>,
|
|
pub calendar_state: ListState,
|
|
pub library_health: Option<LibraryHealthReport>,
|
|
pub health_state: ListState,
|
|
|
|
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,
|
|
|
|
/// True while a long search-now / candidate-fetch task is in flight.
|
|
pub busy: bool,
|
|
background: Option<tokio::task::JoinHandle<BackgroundOutcome>>,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(client: DaemonClient) -> Self {
|
|
Self {
|
|
client,
|
|
daemon_up: false,
|
|
health: None,
|
|
tab: Tab::Library,
|
|
focus: Focus::List,
|
|
status: String::new(),
|
|
status_set_at: None,
|
|
should_quit: false,
|
|
help_visible: false,
|
|
force_refresh: false,
|
|
media_items: Vec::new(),
|
|
media_state: ListState::default(),
|
|
library_view: Vec::new(),
|
|
library_filter: LibraryFilter::All,
|
|
library_sort: LibrarySort::TitleAsc,
|
|
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(),
|
|
stuck: None,
|
|
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,
|
|
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(),
|
|
busy: false,
|
|
background: None,
|
|
}
|
|
}
|
|
|
|
pub fn set_status(&mut self, msg: impl Into<String>) {
|
|
self.status = msg.into();
|
|
self.status_set_at = if self.status.is_empty() {
|
|
None
|
|
} else {
|
|
Some(Instant::now())
|
|
};
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
if let Some(at) = self.status_set_at {
|
|
if at.elapsed() >= Duration::from_secs(5) {
|
|
self.status.clear();
|
|
self.status_set_at = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
Tab::Library if self.detail.is_none() => {
|
|
(&mut self.media_state, self.library_view.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::Stuck => {
|
|
let report_len = self.stuck.as_ref().map_or((0, 0), |r| {
|
|
(r.stalled_grabs.len(), r.maxed_out_search_targets.len())
|
|
});
|
|
match self.stuck_focus {
|
|
StuckSection::Stalled => (&mut self.stalled_state, report_len.0),
|
|
StuckSection::Maxed => (&mut self.maxed_state, report_len.1),
|
|
}
|
|
}
|
|
Tab::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()),
|
|
}
|
|
}
|
|
|
|
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<i64> {
|
|
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<usize> = 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;
|
|
}
|
|
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(item) = self.selected_media_item() 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.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
|
|
/// 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.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.set_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, 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.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.set_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.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) {
|
|
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.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
|
|
/// 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() || self.busy {
|
|
return;
|
|
}
|
|
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());
|
|
BackgroundOutcome::AddSearch { results, errors }
|
|
}));
|
|
}
|
|
|
|
pub async fn add_selected_search_result(&mut self, roots: &LibraryRoots) {
|
|
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: item_root_folder(&roots.series, &result.title, result.year),
|
|
};
|
|
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: item_root_folder(&roots.movies, &result.title, result.year),
|
|
};
|
|
self.client.add_movie(&req).await.map(|r| r.media_item_id)
|
|
}
|
|
};
|
|
match outcome {
|
|
Ok(media_item_id) => {
|
|
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.set_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 this is spawned off the draw loop and the status line
|
|
/// shows that work is in flight. A second press while busy is ignored.
|
|
pub async fn search_now_selected(&mut self) {
|
|
let Some(id) = self.detail.as_ref().map(|d| d.id) else {
|
|
return;
|
|
};
|
|
if self.busy {
|
|
return;
|
|
}
|
|
self.set_status("searching now...".to_string());
|
|
self.busy = true;
|
|
let client = self.client.clone();
|
|
self.background = Some(tokio::spawn(async move {
|
|
BackgroundOutcome::SearchNow(client.search_now(id).await)
|
|
}));
|
|
}
|
|
|
|
/// 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;
|
|
if self.busy {
|
|
return;
|
|
}
|
|
|
|
self.set_status("fetching candidates...".to_string());
|
|
self.busy = true;
|
|
let client = self.client.clone();
|
|
self.background = Some(tokio::spawn(async move {
|
|
let result = match episode_id {
|
|
Some(id) => client.episode_candidates(id).await,
|
|
None => client.movie_candidates(media_item_id).await,
|
|
};
|
|
BackgroundOutcome::Candidates { episode_id, result }
|
|
}));
|
|
}
|
|
|
|
/// 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.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.set_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.set_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.set_status(format!("{name} updated to {value}"));
|
|
self.weight_input_buffer.clear();
|
|
self.focus = Focus::List;
|
|
}
|
|
Err(e) => self.set_status(format!("weight update failed: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Toggles monitored for whatever's selected in the top-level Library
|
|
/// list, without needing to open its detail view first.
|
|
pub async fn toggle_monitor_list_selected(&mut self) {
|
|
let Some(item) = self.selected_media_item() else {
|
|
return;
|
|
};
|
|
let (id, monitored) = (item.id, item.monitored);
|
|
let result = if monitored {
|
|
self.client.unmonitor(id).await
|
|
} else {
|
|
self.client.monitor(id).await
|
|
};
|
|
match result {
|
|
Ok(()) => {
|
|
self.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.set_status(format!("monitor toggle 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.set_status("monitor state updated".to_string());
|
|
if let Ok(fresh) = self.client.media_detail(id).await {
|
|
self.detail = Some(fresh);
|
|
}
|
|
}
|
|
Err(e) => self.set_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.set_status("press x again to confirm delete".to_string());
|
|
return;
|
|
}
|
|
self.confirm_delete = false;
|
|
match self.client.delete_media(id).await {
|
|
Ok(()) => {
|
|
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.set_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.set_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.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.set_status(format!("file delete failed: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Jumps from whichever Stuck-tab row is selected straight to that
|
|
/// show's Library detail view.
|
|
pub async fn jump_to_stuck_target(&mut self) {
|
|
let Some(report) = &self.stuck else {
|
|
return;
|
|
};
|
|
let media_item_id = match self.stuck_focus {
|
|
StuckSection::Maxed => self
|
|
.maxed_state
|
|
.selected()
|
|
.and_then(|i| report.maxed_out_search_targets.get(i))
|
|
.map(|t| t.media_item_id),
|
|
StuckSection::Stalled => self
|
|
.stalled_state
|
|
.selected()
|
|
.and_then(|i| report.stalled_grabs.get(i))
|
|
.map(|g| g.media_item_id),
|
|
};
|
|
let Some(id) = media_item_id else {
|
|
return;
|
|
};
|
|
match self.client.media_detail(id).await {
|
|
Ok(detail) => {
|
|
self.tab = Tab::Library;
|
|
self.episode_state.select(if detail.episodes.is_empty() {
|
|
None
|
|
} else {
|
|
Some(0)
|
|
});
|
|
self.detail = Some(detail);
|
|
self.focus = Focus::List;
|
|
}
|
|
Err(e) => self.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::{
|
|
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() {
|
|
assert_eq!(item_root_folder("/lib", "..", None), "/lib/_");
|
|
assert_eq!(item_root_folder("/lib", ".", None), "/lib/_");
|
|
assert_eq!(item_root_folder("/lib", "..", Some(2020)), "/lib/_ (2020)");
|
|
}
|
|
|
|
#[test]
|
|
fn item_root_folder_sanitizes_hostile_characters() {
|
|
assert_eq!(
|
|
item_root_folder("/lib", "Foo: Bar/Baz", Some(2020)),
|
|
"/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);
|
|
}
|
|
}
|