diff --git a/README.md b/README.md index 8ef047b..4e7f5e2 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,10 @@ Sonarr + Radarr + Prowlarr is three separate services, three databases, three we - **nyaa.si** (RSS) — anime TV, polled continuously. Full-auto, no request budget concerns (it's a plain RSS feed). - **apibay.org** (a community JSON API mirror of The Pirate Bay's search) — the primary search-driven source for general TV and movies. Unlike 1337x this is a genuine machine-readable API, needs no HTML scraping, and its search actually ranks by relevance rather than pure seeder count, which matters a lot for titles made of common words. -- **torrents-csv** (JSON DHT-dump search) — first general-content fallback when TPB fails or returns nothing. Same hash-to-magnet grab as TPB; covers movies and TV. -- **YTS** (JSON `list_movies` API via `yts.lt` — `yts.mx` no longer resolves) — movie-only fallback after TPB. Each hit expands into one candidate per quality so the scorer sees 720p/1080p/2160p separately. -- **1337x** (scraped HTML, via community mirrors) — last-resort general-content fallback after the JSON sources. 1337x's main domain is Cloudflare-protected and has a ban history, so requests are round-robined across mirrors with automatic cooldown/backoff on failures, jittered between searches, and rate-limit responses are honored explicitly. -- **nyaa.si search mode** — anime movies specifically route here instead of the general-content chain, since nyaa is the safe, official-RSS-interface target and gives materially better results for anime content. +- **1337x** (scraped HTML, via community mirrors) — secondary search-driven source, tried after TPB. 1337x's main domain is Cloudflare-protected and has a ban history, so requests are round-robined across mirrors with automatic cooldown/backoff on failures, jittered between searches, and rate-limit responses are honored explicitly. +- **nyaa.si search mode** — anime movies specifically route here instead of TPB/1337x, since nyaa is the safe, official-RSS-interface target and gives materially better results for anime content. -Search-driven sources share one per-cycle request budget (default: 5 searches per 30-minute cycle). General content tries TPB, then YTS (movies) / torrents-csv, then 1337x; anime movies go to nyaa search. Kept conservative since 1337x has a ban history and the goal is steady backlog clearing, not maximum throughput. A whole cycle failing outright backs off the *next cycle's* interval (1h → 2h → 4h, capped), on top of each source's own per-mirror cooldowns. +All three search-driven sources share one per-cycle request budget (default: 5 searches per 30-minute cycle, TPB tried first, then 1337x, then nyaa search) — kept conservative since 1337x has a ban history and the goal is steady backlog clearing, not maximum throughput. A whole cycle failing outright backs off the *next cycle's* interval (1h → 2h → 4h, capped), on top of each source's own per-mirror cooldowns. ## Setup @@ -54,13 +52,13 @@ Install via bakery (`bakery install breadarr`) on a homelab host, or build from ## Using the TUI -`Tab` / `Shift+Tab` cycle Library / History / Review / Add / Stuck / Calendar / Health / Profiles; `1`–`8` jump straight to a tab. `j`/`k` or arrows move, `g`/`G` jump to first/last, `PgUp`/`PgDn` page, `Enter` opens detail or runs a search, `Esc` backs out. `?` is the full key list. +`Tab` cycles Library / History / Review Queue / Add Show / Stuck / Calendar / Health / Profiles. `j`/`k` or arrow keys navigate, `Enter` opens detail or runs a search, `Esc` backs out. -- **Review** — `a` approves and `r` rejects a pending low-confidence title match. The tab title badges the pending count. Check this periodically, especially early on. -- **Library** — `/` incrementally filters the list by title; `f`/`o` cycle kind-filter and sort. With an item's detail open: `s` triggers an immediate search-now pass for that item's backlog; `n` jumps to the next missing monitored episode; `m`/`e`/`S` toggle monitored on the show/episode/season respectively; `x` (confirm with a second `x`) removes the item from tracking without touching files on disk; `d` (confirm with a second `d`) deletes a bad imported file from disk and clears its tracking, freeing the episode/movie to be re-grabbed on the next cycle — the redownload path for a file that turned out to be wrong or broken; `c` fetches the manual release picker for the selected episode/movie, `Enter` grabs the highlighted candidate, `Esc` cancels. Movies (no episode list) show a summary pane with the same keys. -- **Stuck** — surfaces grabs that look stalled (no download progress advancing, or missing from qBittorrent) before the daemon's own auto-fail timers would catch them. `Enter` jumps to that show. -- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window. `Enter` opens the matching episode. -- **Health** — daemon cycle outcomes plus the library-health report (see below), scrollable. +- **Review Queue** — `a` approves and `r` rejects a pending low-confidence title match. Check this periodically, especially early on. +- **Library** (with an item's detail open) — `s` triggers an immediate search-now pass for that item's backlog; `m`/`e`/`S` toggle monitored on the show/episode/season respectively; `x` (confirm with a second `x`) removes the item from tracking without touching files on disk; `d` (confirm with a second `d`) deletes a bad imported file from disk and clears its tracking, freeing the episode/movie to be re-grabbed on the next cycle — the redownload path for a file that turned out to be wrong or broken; `c` fetches the manual release picker for the selected episode/movie, `Enter` grabs the highlighted candidate, `Esc` cancels. +- **Stuck** — surfaces grabs that look stalled (no download progress advancing, or missing from qBittorrent) before the daemon's own auto-fail timers would catch them. +- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window. +- **Health** — the library-health report (see below) rendered as a tab instead of curled by hand. - **Profiles** — quality-profile weight axes; open a profile and edit a weight in place. ## Operational notes diff --git a/breadarr-shared/src/config.rs b/breadarr-shared/src/config.rs index b60c84a..77cb044 100644 --- a/breadarr-shared/src/config.rs +++ b/breadarr-shared/src/config.rs @@ -97,16 +97,6 @@ pub struct SourcesConfig { /// titles made of common words. #[serde(default = "default_tpb_api_url")] pub tpb_api_url: String, - /// torrents.csv DHT-dump search — first fallback when TPB fails or - /// returns nothing. Same hash-to-magnet grab shape as TPB, covers - /// movies and TV. - #[serde(default = "default_torrents_csv_url")] - pub torrents_csv_url: String, - /// YTS v2 list_movies JSON — movie-only fallback after TPB / torrents-csv. - /// `yts.mx` does not resolve; this default is a working host as of - /// 2026-08-16. - #[serde(default = "default_yts_api_url")] - pub yts_api_url: String, /// Human kill switch for the upgrade-search loop, same reasoning as /// `search_enabled` — off by default would mean nothing ever improves, /// but a user who's happy with their current files (or wants to save @@ -144,8 +134,6 @@ impl Default for SourcesConfig { search_budget_per_cycle: default_search_budget_per_cycle(), search_enabled: default_search_enabled(), tpb_api_url: default_tpb_api_url(), - torrents_csv_url: default_torrents_csv_url(), - yts_api_url: default_yts_api_url(), upgrade_enabled: default_upgrade_enabled(), upgrade_poll_interval_secs: default_upgrade_poll_interval_secs(), upgrade_budget_per_cycle: default_upgrade_budget_per_cycle(), @@ -158,14 +146,6 @@ fn default_tpb_api_url() -> String { "https://apibay.org/q.php".to_string() } -fn default_torrents_csv_url() -> String { - "https://torrents-csv.com/service/search".to_string() -} - -fn default_yts_api_url() -> String { - "https://yts.lt/api/v2/list_movies.json".to_string() -} - fn default_upgrade_enabled() -> bool { true } @@ -212,7 +192,6 @@ fn default_import_poll_interval_secs() -> u64 { fn default_1337x_mirrors() -> Vec { [ - "https://www.1337xx.to", "https://13377x.info", "https://13377x.email", "https://1337xto.info", diff --git a/breadarr-tui/src/app.rs b/breadarr-tui/src/app.rs index b991885..45b213e 100644 --- a/breadarr-tui/src/app.rs +++ b/breadarr-tui/src/app.rs @@ -1,13 +1,12 @@ use anyhow::Result; use breadarr_shared::dto::{ - CalendarEntry, EpisodeSummary, FlaggedFile, HealthDetail, LibraryHealthReport, MediaItemDetail, - MediaItemSummary, QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, - SearchNowResult, SearchResult, StuckReport, WeightsDto, + CalendarEntry, 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 @@ -76,8 +75,8 @@ impl Tab { match self { Tab::Library => "Library", Tab::History => "History", - Tab::Review => "Review", - Tab::Add => "Add", + Tab::Review => "Review Queue", + Tab::Add => "Add Show", Tab::Stuck => "Stuck", Tab::Calendar => "Calendar", Tab::LibraryHealth => "Health", @@ -90,10 +89,6 @@ 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 @@ -243,70 +238,13 @@ pub struct AddResult { } /// 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. +/// `search_now` / candidate fetch (up to 600s) cannot freeze key handling. enum BackgroundOutcome { SearchNow(Result), Candidates { episode_id: Option, result: Result>, }, - AddSearch { - results: Vec, - errors: Vec, - }, -} - -/// Case-insensitive substring match used by the Library `/` filter. -fn title_matches_query(title: &str, query: &str) -> bool { - query.is_empty() || title.to_lowercase().contains(&query.to_lowercase()) -} - -/// Next monitored-and-missing episode, wrapping from `start` so `n` can -/// be mashed through a season without first jumping to the top. -fn next_missing_index(episodes: &[EpisodeSummary], start: usize) -> Option { - if episodes.is_empty() { - return None; - } - let start = start.min(episodes.len()); - episodes - .iter() - .enumerate() - .skip(start) - .chain(episodes.iter().enumerate().take(start)) - .find(|(_, e)| e.monitored && !e.has_file) - .map(|(i, _)| i) -} - -fn clamp_list_state(state: &mut ListState, len: usize) { - match (state.selected(), len) { - (_, 0) => state.select(None), - (None, _) => state.select(Some(0)), - (Some(i), _) if i >= len => state.select(Some(len - 1)), - _ => {} - } -} - -/// Row count of the Health tab's flagged-files list, including section -/// headers — must stay in lockstep with `draw_library_health` so j/k -/// doesn't walk off the rendered items. -pub fn health_row_count(report: &LibraryHealthReport) -> usize { - let mut n = 0; - let mut add_files = |files: &[FlaggedFile]| { - if !files.is_empty() { - n += 1 + files.len(); - } - }; - add_files(&report.corrupt_files); - add_files(&report.under_quality_files); - add_files(&report.no_english_audio_files); - add_files(&report.non_english_default_audio_files); - add_files(&report.no_subtitle_files); - if !report.duplicate_groups.is_empty() { - n += 1 + report.duplicate_groups.len(); - } - n.max(1) } pub struct App { @@ -316,10 +254,6 @@ pub struct App { pub tab: Tab, pub focus: Focus, pub status: String, - /// When `status` was last written — the draw loop clears it after a - /// few seconds so action feedback doesn't permanently hide the - /// keybinding hints in the status bar. - status_set_at: Option, pub should_quit: bool, /// Toggled by `?`; intercepted at the top of `main.rs`'s key handler /// like `Focus::AddSearchInput`/`Focus::WeightInput`, but kept as its @@ -339,9 +273,6 @@ pub struct App { pub library_view: Vec, pub library_filter: LibraryFilter, pub library_sort: LibrarySort, - /// Incremental title filter from `/` — applied on top of - /// `library_filter`/`library_sort` inside `recompute_library_view`. - pub library_query: String, pub detail: Option, /// Selection within `detail.episodes` — separate from `media_state` /// since they're two different lists sharing the same tab. @@ -352,9 +283,6 @@ pub struct App { pub review_items: Vec, pub review_state: ListState, - /// Last-seen review-queue depth, kept even when the Review tab isn't - /// the one being refreshed so the tab strip can badge it. - pub review_count: usize, pub add_query: String, pub add_results: Vec, @@ -364,12 +292,8 @@ pub struct App { pub stuck_focus: StuckSection, pub stalled_state: ListState, pub maxed_state: ListState, - /// Last-seen stalled+maxed count for the Stuck tab badge. - pub stuck_count: usize, pub calendar: Vec, - pub calendar_state: ListState, pub library_health: Option, - pub health_state: ListState, pub candidates: Vec, pub candidates_state: ListState, @@ -414,7 +338,6 @@ impl App { tab: Tab::Library, focus: Focus::List, status: String::new(), - status_set_at: None, should_quit: false, help_visible: false, force_refresh: false, @@ -423,14 +346,12 @@ impl App { library_view: Vec::new(), library_filter: LibraryFilter::All, library_sort: LibrarySort::TitleAsc, - library_query: String::new(), detail: None, episode_state: ListState::default(), releases: Vec::new(), releases_state: ListState::default(), review_items: Vec::new(), review_state: ListState::default(), - review_count: 0, add_query: String::new(), add_results: Vec::new(), add_results_state: ListState::default(), @@ -438,11 +359,8 @@ impl App { stuck_focus: StuckSection::Stalled, stalled_state: ListState::default(), maxed_state: ListState::default(), - stuck_count: 0, calendar: Vec::new(), - calendar_state: ListState::default(), library_health: None, - health_state: ListState::default(), candidates: Vec::new(), candidates_state: ListState::default(), candidates_episode_id: None, @@ -458,32 +376,190 @@ impl App { } } - pub fn set_status(&mut self, msg: impl Into) { - self.status = msg.into(); - self.status_set_at = if self.status.is_empty() { - None - } else { - Some(Instant::now()) + /// 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; }; - } - - /// 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 { + if !handle.is_finished() { return; } - if let Some(at) = self.status_set_at { - if at.elapsed() >= Duration::from_secs(5) { - self.status.clear(); - self.status_set_at = None; + let handle = self.background.take().expect("just checked is_finished"); + self.busy = false; + match handle.await { + Ok(BackgroundOutcome::SearchNow(Ok(stats))) => { + self.status = format!( + "search complete: {} target(s), {} grabbed, {} error(s)", + stats.targets, stats.grabbed, stats.errors + ); } + Ok(BackgroundOutcome::SearchNow(Err(e))) => { + self.status = format!("search failed: {e}"); + } + Ok(BackgroundOutcome::Candidates { + episode_id, + result: Ok(candidates), + }) => { + self.status = format!("{} candidate(s) found", candidates.len()); + if matches!(self.tab, Tab::Library) && self.detail.is_some() { + self.candidates_state.select(if candidates.is_empty() { + None + } else { + Some(0) + }); + self.candidates = candidates; + self.candidates_episode_id = episode_id; + self.focus = Focus::Candidates; + } + } + Ok(BackgroundOutcome::Candidates { result: Err(e), .. }) => { + self.status = format!("candidate fetch failed: {e}"); + } + Err(e) => self.status = format!("background task failed: {e}"), } } - /// The list currently driven by j/k / g/G / PageUp/PageDown. - fn active_list(&mut self) -> (&mut ListState, usize) { - match self.tab { + pub async fn refresh_active_tab(&mut self) { + match self.client.health_detail().await { + Ok(detail) => { + self.daemon_up = true; + self.health = Some(detail); + } + Err(_) => { + self.daemon_up = false; + self.health = None; + } + } + if !self.daemon_up { + self.status = "daemon unreachable".to_string(); + return; + } + let result: Result<()> = async { + match self.tab { + Tab::Library => { + if self.detail.is_some() { + if let Some(id) = self.selected_media_id() { + self.detail = Some(self.client.media_detail(id).await?); + } + } else { + self.media_items = self.client.list_media().await?; + self.recompute_library_view(); + } + } + Tab::History => { + self.releases = self.client.releases().await?; + if self.releases_state.selected().is_none() && !self.releases.is_empty() { + self.releases_state.select(Some(0)); + } + } + Tab::Review => { + self.review_items = self.client.review_queue().await?; + if self.review_state.selected().is_none() && !self.review_items.is_empty() { + self.review_state.select(Some(0)); + } + } + Tab::Add => {} + Tab::Stuck => { + let report = self.client.stuck().await?; + if self.stalled_state.selected().is_none() && !report.stalled_grabs.is_empty() + { + self.stalled_state.select(Some(0)); + } + if self.maxed_state.selected().is_none() + && !report.maxed_out_search_targets.is_empty() + { + self.maxed_state.select(Some(0)); + } + self.stuck = Some(report); + } + Tab::Calendar => { + self.calendar = self.client.calendar().await?; + } + Tab::LibraryHealth => { + self.library_health = Some(self.client.library_health().await?); + } + Tab::Profiles => { + if self.profile_detail.is_none() { + self.quality_profiles = self.client.quality_profiles().await?; + if self.profiles_state.selected().is_none() + && !self.quality_profiles.is_empty() + { + self.profiles_state.select(Some(0)); + } + } + } + } + Ok(()) + } + .await; + + if let Err(e) = result { + self.status = format!("error: {e}"); + } + } + + fn selected_media_id(&self) -> Option { + self.detail.as_ref().map(|d| d.id) + } + + /// Rebuilds `library_view` from `media_items` under the current + /// filter/sort, then re-selects whichever item was selected before (by + /// id, not raw index) if it's still in view — falls back to the first + /// item, or no selection if the view is now empty. Called after + /// `media_items` changes, and after `library_filter`/`library_sort` + /// change. + pub fn recompute_library_view(&mut self) { + let previously_selected_id = self.selected_media_item().map(|m| m.id); + + let mut indices: Vec = self + .media_items + .iter() + .enumerate() + .filter(|(_, m)| self.library_filter.matches(m)) + .map(|(i, _)| i) + .collect(); + + match self.library_sort { + LibrarySort::TitleAsc => indices.sort_by(|&a, &b| { + self.media_items[a] + .title + .to_lowercase() + .cmp(&self.media_items[b].title.to_lowercase()) + }), + LibrarySort::MissingDesc => indices.sort_by(|&a, &b| { + self.media_items[b] + .missing_count + .cmp(&self.media_items[a].missing_count) + }), + LibrarySort::Kind => indices.sort_by(|&a, &b| { + self.media_items[a].kind.cmp(&self.media_items[b].kind).then_with(|| { + self.media_items[a] + .title + .to_lowercase() + .cmp(&self.media_items[b].title.to_lowercase()) + }) + }), + } + self.library_view = indices; + + match previously_selected_id + .and_then(|id| self.library_view.iter().position(|&i| self.media_items[i].id == id)) + { + Some(pos) => self.media_state.select(Some(pos)), + None if !self.library_view.is_empty() => self.media_state.select(Some(0)), + None => self.media_state.select(None), + } + } + + pub fn selected_media_item(&self) -> Option<&MediaItemSummary> { + let idx = self.media_state.selected()?; + let real_idx = *self.library_view.get(idx)?; + self.media_items.get(real_idx) + } + + pub fn move_selection(&mut self, delta: i32) { + let (state, len) = match self.tab { Tab::Library if matches!(self.focus, Focus::Candidates) => { (&mut self.candidates_state, self.candidates.len()) } @@ -506,247 +582,12 @@ impl App { StuckSection::Maxed => (&mut self.maxed_state, report_len.1), } } - Tab::Calendar => (&mut self.calendar_state, self.calendar.len()), - Tab::LibraryHealth => { - let n = self - .library_health - .as_ref() - .map(health_row_count) - .unwrap_or(0); - (&mut self.health_state, n) - } Tab::Profiles if self.profile_detail.is_some() => { (&mut self.profile_weight_state, WEIGHT_FIELDS.len()) } Tab::Profiles => (&mut self.profiles_state, self.quality_profiles.len()), - } - } - - 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; + _ => return, }; - if !handle.is_finished() { - return; - } - let handle = self.background.take().expect("just checked is_finished"); - self.busy = false; - match handle.await { - Ok(BackgroundOutcome::SearchNow(Ok(stats))) => { - self.set_status(format!( - "search complete: {} target(s), {} grabbed, {} error(s)", - stats.targets, stats.grabbed, stats.errors - )); - } - Ok(BackgroundOutcome::SearchNow(Err(e))) => { - self.set_status(format!("search failed: {e}")); - } - Ok(BackgroundOutcome::Candidates { - episode_id, - result: Ok(candidates), - }) => { - self.set_status(format!("{} candidate(s) found", candidates.len())); - if matches!(self.tab, Tab::Library) && self.detail.is_some() { - self.candidates_state.select(if candidates.is_empty() { - None - } else { - Some(0) - }); - self.candidates = candidates; - self.candidates_episode_id = episode_id; - self.focus = Focus::Candidates; - } - } - Ok(BackgroundOutcome::Candidates { result: Err(e), .. }) => { - self.set_status(format!("candidate fetch failed: {e}")); - } - Ok(BackgroundOutcome::AddSearch { results, errors }) => { - self.add_results_state - .select(if results.is_empty() { None } else { Some(0) }); - self.add_results = results; - self.set_status(if errors.is_empty() { - String::new() - } else { - errors.join("; ") - }); - if matches!(self.tab, Tab::Add) { - self.focus = Focus::AddResults; - } - } - Err(e) => self.set_status(format!("background task failed: {e}")), - } - } - - pub async fn refresh_active_tab(&mut self) { - match self.client.health_detail().await { - Ok(detail) => { - self.daemon_up = true; - self.health = Some(detail); - } - Err(_) => { - self.daemon_up = false; - self.health = None; - } - } - if !self.daemon_up { - self.set_status("daemon unreachable"); - return; - } - let result: Result<()> = async { - match self.tab { - Tab::Library => { - if self.detail.is_some() { - if let Some(id) = self.selected_media_id() { - self.detail = Some(self.client.media_detail(id).await?); - } - } else { - self.media_items = self.client.list_media().await?; - self.recompute_library_view(); - } - } - Tab::History => { - self.releases = self.client.releases().await?; - clamp_list_state(&mut self.releases_state, self.releases.len()); - } - Tab::Review => { - self.review_items = self.client.review_queue().await?; - self.review_count = self.review_items.len(); - clamp_list_state(&mut self.review_state, self.review_items.len()); - } - Tab::Add => {} - Tab::Stuck => { - let report = self.client.stuck().await?; - self.stuck_count = - report.stalled_grabs.len() + report.maxed_out_search_targets.len(); - clamp_list_state(&mut self.stalled_state, report.stalled_grabs.len()); - clamp_list_state(&mut self.maxed_state, report.maxed_out_search_targets.len()); - self.stuck = Some(report); - } - Tab::Calendar => { - self.calendar = self.client.calendar().await?; - clamp_list_state(&mut self.calendar_state, self.calendar.len()); - } - Tab::LibraryHealth => { - self.library_health = Some(self.client.library_health().await?); - let n = self - .library_health - .as_ref() - .map(health_row_count) - .unwrap_or(0); - clamp_list_state(&mut self.health_state, n); - } - Tab::Profiles => { - if self.profile_detail.is_none() { - self.quality_profiles = self.client.quality_profiles().await?; - clamp_list_state(&mut self.profiles_state, self.quality_profiles.len()); - } - } - } - self.refresh_badge_counts().await; - Ok(()) - } - .await; - - if let Err(e) = result { - self.set_status(format!("error: {e}")); - } - } - - /// Cheap counts for the tab-strip badges. Skips the resource the - /// active tab already fetched so we don't double-hit the same route. - async fn refresh_badge_counts(&mut self) { - if !matches!(self.tab, Tab::Review) { - if let Ok(items) = self.client.review_queue().await { - self.review_count = items.len(); - } - } - if !matches!(self.tab, Tab::Stuck) { - if let Ok(report) = self.client.stuck().await { - self.stuck_count = - report.stalled_grabs.len() + report.maxed_out_search_targets.len(); - } - } - } - - fn selected_media_id(&self) -> Option { - self.detail.as_ref().map(|d| d.id) - } - - /// Rebuilds `library_view` from `media_items` under the current - /// filter/sort, then re-selects whichever item was selected before (by - /// id, not raw index) if it's still in view — falls back to the first - /// item, or no selection if the view is now empty. Called after - /// `media_items` changes, and after `library_filter`/`library_sort` - /// change. - pub fn recompute_library_view(&mut self) { - let previously_selected_id = self.selected_media_item().map(|m| m.id); - - let query = self.library_query.clone(); - let mut indices: Vec = self - .media_items - .iter() - .enumerate() - .filter(|(_, m)| { - self.library_filter.matches(m) && title_matches_query(&m.title, &query) - }) - .map(|(i, _)| i) - .collect(); - - match self.library_sort { - LibrarySort::TitleAsc => indices.sort_by(|&a, &b| { - self.media_items[a] - .title - .to_lowercase() - .cmp(&self.media_items[b].title.to_lowercase()) - }), - LibrarySort::MissingDesc => indices.sort_by(|&a, &b| { - self.media_items[b] - .missing_count - .cmp(&self.media_items[a].missing_count) - }), - LibrarySort::Kind => indices.sort_by(|&a, &b| { - self.media_items[a] - .kind - .cmp(&self.media_items[b].kind) - .then_with(|| { - self.media_items[a] - .title - .to_lowercase() - .cmp(&self.media_items[b].title.to_lowercase()) - }) - }), - } - self.library_view = indices; - - match previously_selected_id.and_then(|id| { - self.library_view - .iter() - .position(|&i| self.media_items[i].id == id) - }) { - Some(pos) => self.media_state.select(Some(pos)), - None if !self.library_view.is_empty() => self.media_state.select(Some(0)), - None => self.media_state.select(None), - } - } - - pub fn selected_media_item(&self) -> Option<&MediaItemSummary> { - let idx = self.media_state.selected()?; - let real_idx = *self.library_view.get(idx)?; - self.media_items.get(real_idx) - } - - pub fn move_selection(&mut self, delta: i32) { - let (state, len) = self.active_list(); if len == 0 { return; } @@ -771,47 +612,13 @@ impl App { }); self.detail = Some(detail); } - Err(e) => self.set_status(format!("error loading detail: {e}")), + Err(e) => self.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 @@ -834,14 +641,14 @@ impl App { }; match result { Ok(()) => { - self.set_status("episode monitor state updated".to_string()); + 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.set_status(format!("episode monitor toggle failed: {e}")), + Err(e) => self.status = format!("episode monitor toggle failed: {e}"), } } @@ -870,12 +677,12 @@ impl App { }; match result { Ok(()) => { - self.set_status(format!("season {season_number} monitor state updated")); + 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.set_status(format!("season monitor toggle failed: {e}")), + Err(e) => self.status = format!("season monitor toggle failed: {e}"), } } @@ -887,12 +694,10 @@ impl App { 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}")), + 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(); - 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) { @@ -903,12 +708,10 @@ impl App { 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}")), + 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(); - 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 @@ -919,36 +722,41 @@ impl App { /// a movie search returned nothing because the mode had never actually /// switched). Querying both up front removes the failure mode entirely. pub async fn run_add_search(&mut self) { - if self.add_query.trim().is_empty() || self.busy { + if self.add_query.trim().is_empty() { 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 (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()); - BackgroundOutcome::AddSearch { results, errors } - })); + 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, roots: &LibraryRoots) { @@ -981,15 +789,12 @@ impl App { }; match outcome { Ok(media_item_id) => { - self.set_status(format!( - "added {:?} (media_item_id={media_item_id})", - result.title - )); + 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.set_status(format!("add failed: {e}")), + Err(e) => self.status = format!("add failed: {e}"), } } @@ -1004,7 +809,7 @@ impl App { if self.busy { return; } - self.set_status("searching now...".to_string()); + self.status = "searching now...".to_string(); self.busy = true; let client = self.client.clone(); self.background = Some(tokio::spawn(async move { @@ -1036,7 +841,7 @@ impl App { return; } - self.set_status("fetching candidates...".to_string()); + self.status = "fetching candidates...".to_string(); self.busy = true; let client = self.client.clone(); self.background = Some(tokio::spawn(async move { @@ -1074,13 +879,13 @@ impl App { }; match result { Ok(()) => { - self.set_status(format!("grabbed: {}", candidate.raw_title)); + 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.set_status(format!("grab failed: {e}")), + Err(e) => self.status = format!("grab failed: {e}"), } } @@ -1150,10 +955,7 @@ impl App { 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 - )); + self.status = format!("'{}' is not a valid number", self.weight_input_buffer); return; } }; @@ -1170,11 +972,11 @@ impl App { .await { Ok(()) => { - self.set_status(format!("{name} updated to {value}")); + self.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}")), + Err(e) => self.status = format!("weight update failed: {e}"), } } @@ -1192,11 +994,11 @@ impl App { }; match result { Ok(()) => { - self.set_status("monitor state updated".to_string()); + self.status = "monitor state updated".to_string(); self.media_items = self.client.list_media().await.unwrap_or_default(); self.recompute_library_view(); } - Err(e) => self.set_status(format!("monitor toggle failed: {e}")), + Err(e) => self.status = format!("monitor toggle failed: {e}"), } } @@ -1212,12 +1014,12 @@ impl App { }; match result { Ok(()) => { - self.set_status("monitor state updated".to_string()); + self.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}")), + Err(e) => self.status = format!("monitor toggle failed: {e}"), } } @@ -1229,18 +1031,18 @@ impl App { }; if !self.confirm_delete { self.confirm_delete = true; - self.set_status("press x again to confirm delete".to_string()); + self.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.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}")), + Err(e) => self.status = format!("delete failed: {e}"), } } @@ -1255,7 +1057,7 @@ impl App { }; if !self.confirm_delete_file { self.confirm_delete_file = true; - self.set_status("press d again to confirm deleting this file".to_string()); + self.status = "press d again to confirm deleting this file".to_string(); return; } self.confirm_delete_file = false; @@ -1273,14 +1075,14 @@ impl App { }; match result { Ok(()) => { - self.set_status("file deleted, will be re-searched".to_string()); + 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.set_status(format!("file delete failed: {e}")), + Err(e) => self.status = format!("file delete failed: {e}"), } } @@ -1316,51 +1118,14 @@ impl App { 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}")), + Err(e) => self.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; + use super::item_root_folder; #[test] fn item_root_folder_replaces_dot_and_dotdot() { @@ -1376,99 +1141,4 @@ mod tests { "/lib/Foo_ Bar_Baz (2020)" ); } - - #[test] - fn title_matches_query_is_case_insensitive_substring() { - assert!(title_matches_query("The Matrix", "matrix")); - assert!(title_matches_query("The Matrix", "")); - assert!(!title_matches_query("The Matrix", "inception")); - } - - fn ep(season: i64, number: i64, monitored: bool, has_file: bool) -> EpisodeSummary { - EpisodeSummary { - id: season * 100 + number, - season_number: season, - episode_number: number, - title: None, - air_date: None, - monitored, - has_file, - } - } - - #[test] - fn next_missing_index_wraps_and_skips_owned_or_unmonitored() { - let episodes = vec![ - ep(1, 1, true, true), - ep(1, 2, true, false), - ep(1, 3, false, false), - ep(1, 4, true, false), - ]; - assert_eq!(next_missing_index(&episodes, 0), Some(1)); - assert_eq!(next_missing_index(&episodes, 2), Some(3)); - assert_eq!(next_missing_index(&episodes, 4), Some(1)); - assert_eq!(next_missing_index(&[], 0), None); - assert_eq!( - next_missing_index(&[ep(1, 1, true, true), ep(1, 2, false, false)], 0), - None - ); - } - - #[test] - fn clamp_list_state_pins_past_the_end_and_clears_empty() { - let mut state = ListState::default(); - state.select(Some(4)); - clamp_list_state(&mut state, 3); - assert_eq!(state.selected(), Some(2)); - clamp_list_state(&mut state, 0); - assert_eq!(state.selected(), None); - clamp_list_state(&mut state, 2); - assert_eq!(state.selected(), Some(0)); - } - - fn empty_health() -> LibraryHealthReport { - LibraryHealthReport { - corrupt_files: vec![], - under_quality_files: vec![], - no_subtitle_files: vec![], - no_english_audio_files: vec![], - non_english_default_audio_files: vec![], - duplicate_groups: vec![], - summary: LibrarySummary { - total_files: 0, - total_size_bytes: 0, - probed_files: 0, - by_video_codec: vec![], - sd_count: 0, - hd_720p_count: 0, - full_hd_1080p_count: 0, - uhd_4k_count: 0, - pct_with_subtitles: 0.0, - }, - } - } - - fn flagged(title: &str) -> FlaggedFile { - FlaggedFile { - episode_file_id: 1, - media_title: title.to_string(), - episode_label: None, - path: "/x".to_string(), - } - } - - #[test] - fn health_row_count_includes_headers_and_empty_placeholder() { - let mut report = empty_health(); - assert_eq!(health_row_count(&report), 1); - report.corrupt_files.push(flagged("A")); - report.corrupt_files.push(flagged("B")); - report.duplicate_groups.push(DuplicateGroup { - media_title: "C".into(), - episode_label: None, - paths: vec!["/a".into(), "/b".into()], - }); - // header + 2 files + header + 1 group - assert_eq!(health_row_count(&report), 5); - } } diff --git a/breadarr-tui/src/main.rs b/breadarr-tui/src/main.rs index d8af4b0..f4135e3 100644 --- a/breadarr-tui/src/main.rs +++ b/breadarr-tui/src/main.rs @@ -6,7 +6,7 @@ use std::time::Duration; use anyhow::Result; use breadarr_shared::{Config, DaemonClient}; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -51,7 +51,6 @@ async fn run( loop { app.poll_background().await; - app.expire_status(); if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh { app.refresh_active_tab().await; @@ -66,7 +65,7 @@ async fn run( if key.kind != KeyEventKind::Press { continue; } - handle_key(app, key, roots).await; + handle_key(app, key.code, roots).await; if app.should_quit { return Ok(()); } @@ -75,9 +74,7 @@ async fn run( } } -async fn handle_key(app: &mut App, key: KeyEvent, roots: &LibraryRoots) { - let code = key.code; - +async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { // 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 { @@ -89,28 +86,7 @@ async fn handle_key(app: &mut App, key: KeyEvent, roots: &LibraryRoots) { KeyCode::Esc => { app.add_query.clear(); } - KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)), - KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)), - _ => {} - } - return; - } - - // Incremental Library title filter — same input-mode isolation as Add. - if matches!(app.tab, Tab::Library) && matches!(app.focus, Focus::LibraryFilterInput) { - match code { - KeyCode::Enter => app.confirm_library_filter(), - KeyCode::Char(c) => { - app.library_query.push(c); - app.recompute_library_view(); - } - KeyCode::Backspace => { - app.library_query.pop(); - app.recompute_library_view(); - } - KeyCode::Esc => app.clear_library_filter(), - KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)), - KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)), + KeyCode::Tab => cycle_tab(app), _ => {} } return; @@ -155,25 +131,12 @@ async fn handle_key(app: &mut App, key: KeyEvent, roots: &LibraryRoots) { match code { KeyCode::Char('q') => app.should_quit = true, - KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)), - KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)), - KeyCode::Char(c) if c.is_ascii_digit() => { - if let Some(n) = c.to_digit(10) { - if (1..=Tab::ALL.len() as u32).contains(&n) { - switch_tab(app, Tab::ALL[(n as usize) - 1]); - } - } - } + KeyCode::Tab => cycle_tab(app), KeyCode::Char('j') | KeyCode::Down => app.move_selection(1), KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1), - KeyCode::Char('g') => app.select_edge(false), - KeyCode::Char('G') => app.select_edge(true), - KeyCode::PageDown => app.move_selection(10), - KeyCode::PageUp => app.move_selection(-10), KeyCode::Esc => match app.tab { Tab::Library if matches!(app.focus, Focus::Candidates) => app.close_candidates(), Tab::Library if app.detail.is_some() => app.close_detail(), - Tab::Library if !app.library_query.is_empty() => app.clear_library_filter(), Tab::Add => app.focus = Focus::AddSearchInput, Tab::Profiles if app.profile_detail.is_some() => app.close_profile_detail(), _ => {} @@ -192,7 +155,6 @@ async fn handle_key(app: &mut App, key: KeyEvent, roots: &LibraryRoots) { } Tab::Profiles => app.open_profile_detail(), Tab::Stuck => app.jump_to_stuck_target().await, - Tab::Calendar => app.jump_to_calendar_entry().await, _ => {} }, KeyCode::Left | KeyCode::Right if matches!(app.tab, Tab::Stuck) => { @@ -220,16 +182,6 @@ async fn handle_key(app: &mut App, key: KeyEvent, roots: &LibraryRoots) { KeyCode::Char('r') if matches!(app.tab, Tab::Review) => { app.reject_selected_review().await; } - KeyCode::Char('/') if matches!(app.tab, Tab::Library) && app.detail.is_none() => { - app.start_library_filter(); - } - KeyCode::Char('n') - if matches!(app.tab, Tab::Library) - && app.detail.is_some() - && !matches!(app.focus, Focus::Candidates) => - { - app.select_next_missing_episode(); - } KeyCode::Char('s') if matches!(app.tab, Tab::Library) && app.detail.is_some() => { app.search_now_selected().await; } @@ -259,20 +211,9 @@ async fn handle_key(app: &mut App, key: KeyEvent, roots: &LibraryRoots) { } } -fn tab_offset(current: Tab, delta: i32) -> Tab { - let idx = Tab::ALL.iter().position(|t| *t == current).unwrap_or(0) as i32; - let len = Tab::ALL.len() as i32; - Tab::ALL[(idx + delta).rem_euclid(len) as usize] -} - -fn switch_tab(app: &mut App, tab: Tab) { - if app.tab == tab { - return; - } - if matches!(app.focus, Focus::Candidates) { - app.close_candidates(); - } - app.tab = tab; +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); diff --git a/breadarr-tui/src/ui.rs b/breadarr-tui/src/ui.rs index 77ea537..5f7735c 100644 --- a/breadarr-tui/src/ui.rs +++ b/breadarr-tui/src/ui.rs @@ -5,7 +5,6 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs}; use ratatui::Frame; use crate::app::{App, Focus, StuckSection, Tab}; -use breadarr_shared::dto::CycleInfo; // Shared color palette — kept to these meanings so a color never has to be // second-guessed at a glance: @@ -59,7 +58,6 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { Tab::Library if app.detail.is_some() => vec![ ("Esc", "back"), ("s", "search now"), - ("n", "next missing"), ("m", "monitor show"), ("e", "monitor episode"), ("S", "monitor season"), @@ -67,12 +65,8 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { ("d", "delete file"), ("c", "pick release"), ], - Tab::Library if matches!(app.focus, Focus::LibraryFilterInput) => { - vec![("Enter", "keep filter"), ("Esc", "clear filter")] - } Tab::Library => vec![ ("Enter", "open detail"), - ("/", "filter title"), ("f", "cycle filter"), ("o", "cycle sort"), ("m", "monitor toggle"), @@ -87,17 +81,13 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { } Tab::Profiles => vec![("Enter", "open profile")], Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")], - Tab::Calendar => vec![("Enter", "jump to show")], _ => vec![], } } const GLOBAL_KEYS: &[(&str, &str)] = &[ - ("Tab/S-Tab", "switch tab"), - ("1-8", "jump tab"), + ("Tab", "switch tab"), ("j/k", "move"), - ("g/G", "first/last"), - ("PgUp/PgDn", "page"), ("?", "help"), ("R", "refresh now"), ("q", "quit"), @@ -138,7 +128,7 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) { ]) })); - let popup = centered_rect(52, lines.len() as u16 + 2, area); + let popup = centered_rect(50, lines.len() as u16 + 2, area); frame.render_widget(Clear, popup); let paragraph = Paragraph::new(lines).block( Block::default() @@ -148,47 +138,8 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) { frame.render_widget(paragraph, popup); } -fn tab_label(tab: Tab, app: &App) -> String { - match tab { - Tab::Library => { - let missing: i64 = app.media_items.iter().map(|m| m.missing_count).sum(); - if missing > 0 { - format!("Library ({missing})") - } else { - tab.title().to_string() - } - } - Tab::Review => { - let n = if matches!(app.tab, Tab::Review) { - app.review_items.len() - } else { - app.review_count - }; - if n > 0 { - format!("Review ({n})") - } else { - tab.title().to_string() - } - } - Tab::Stuck => { - let n = app.stuck.as_ref().map_or(app.stuck_count, |r| { - r.stalled_grabs.len() + r.maxed_out_search_targets.len() - }); - if n > 0 { - format!("Stuck ({n})") - } else { - tab.title().to_string() - } - } - _ => tab.title().to_string(), - } -} - fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) { - let titles: Vec = Tab::ALL - .iter() - .map(|t| Line::from(tab_label(*t, app))) - .collect(); + let titles: Vec = 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) { @@ -239,11 +190,6 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { return; } - if detail.kind == "movie" || detail.episodes.is_empty() { - draw_movie_detail(frame, area, app, detail); - return; - } - let items: Vec = detail .episodes .iter() @@ -340,19 +286,9 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { ])) }) .collect(); - let search = if app.library_query.is_empty() { - String::new() - } else { - format!(" search: {}", app.library_query) - }; - let filter_caret = if matches!(app.focus, Focus::LibraryFilterInput) { - "▋" - } else { - "" - }; let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(format!( - "Library ({}/{}) — filter: {} sort: {}{search}{filter_caret}", + "Monitored Shows ({}/{}) — filter: {} sort: {}", app.library_view.len(), app.media_items.len(), app.library_filter.label(), @@ -363,61 +299,6 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { frame.render_stateful_widget(list, area, &mut state); } -fn draw_movie_detail( - frame: &mut Frame, - area: Rect, - app: &App, - detail: &breadarr_shared::dto::MediaItemDetail, -) { - let confirm = if app.confirm_delete { - " — x AGAIN TO DELETE" - } else if app.confirm_delete_file { - " — d AGAIN TO DELETE FILE" - } else { - "" - }; - let monitor_label = if detail.monitored { - "monitored" - } else { - "unmonitored" - }; - let have = app - .media_items - .iter() - .find(|m| m.id == detail.id) - .map(|m| m.missing_count == 0); - let file_line = match have { - Some(true) => ("file on disk", Color::Green), - Some(false) => ("missing", Color::Yellow), - None => ("file status unknown", Color::DarkGray), - }; - let kind = if detail.kind == "movie" { - "Movie" - } else { - "Series" - }; - let year = detail - .year - .map(|y| y.to_string()) - .unwrap_or_else(|| "—".into()); - let lines = vec![ - Line::from(Span::styled( - format!("{} ({year})", detail.title), - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(format!("{kind} · {monitor_label}")), - Line::from(Span::styled(file_line.0, Style::default().fg(file_line.1))), - Line::from(format!("root: {}", detail.root_folder)), - Line::from(""), - Line::from("s search now c pick release m monitor d delete file x remove"), - ]; - let paragraph = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title( - format!("{} ({year}) [{monitor_label}]{confirm}", detail.title), - )); - frame.render_widget(paragraph, area); -} - /// Manual release picker — candidates for whatever episode/movie was /// selected when `c` was pressed, scored (or gate-rejected with a reason) /// exactly like the automatic search pipeline would see them. @@ -449,19 +330,15 @@ fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str) if c.is_season_pack { " [PACK]" } else { "" }, if c.is_repack { " [REPACK]" } else { "" }, ); - let (verdict, color) = match (c.score, &c.rejected_reason) { - (Some(score), _) if score >= 8.0 => (format!("score {score:.1}"), Color::Green), - (Some(score), _) => (format!("score {score:.1}"), Color::Yellow), - (None, Some(reason)) => (format!("REJECTED: {reason}"), Color::Red), - (None, None) => ("unscored".to_string(), Color::DarkGray), + 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(Line::from(vec![ - Span::raw(format!( - "[{}] {} — {seeders} seeders, {size}{flags} — ", - c.source_name, c.raw_title - )), - Span::styled(verdict, Style::default().fg(color)), - ])) + ListItem::new(format!( + "[{}] {} — {seeders} seeders, {size}{flags} — {verdict}", + c.source_name, c.raw_title + )) }) .collect(); let list = List::new(items) @@ -473,32 +350,18 @@ fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str) frame.render_stateful_widget(list, area, &mut state); } -fn history_status_color(status: &str) -> Color { - match status { - "imported" => Color::Green, - "grabbed" => Color::Yellow, - "failed" => Color::Red, - _ => Color::DarkGray, - } -} - fn draw_history(frame: &mut Frame, area: Rect, app: &App) { let items: Vec = app .releases .iter() .map(|r| { - ListItem::new(Line::from(vec![ - Span::styled( - format!("[{}]", r.status), - Style::default().fg(history_status_color(&r.status)), - ), - Span::raw(format!( - " {} — {} (score {:.1})", - r.media_title, - r.raw_title, - r.score.unwrap_or(0.0) - )), - ])) + 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) @@ -521,10 +384,7 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) { Color::Green }; ListItem::new(Line::from(vec![ - Span::styled( - format!("({:.0}%)", r.confidence * 100.0), - Style::default().fg(color), - ), + Span::styled(format!("({:.0}%)", r.confidence * 100.0), Style::default().fg(color)), Span::raw(format!( " {} -> {}", r.raw_release_title, @@ -579,10 +439,7 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) { .iter() .map(|g| { ListItem::new(Line::from(Span::styled( - format!( - "{} — {} (grabbed {})", - g.media_title, g.raw_title, g.grabbed_at - ), + format!("{} — {} (grabbed {})", g.media_title, g.raw_title, g.grabbed_at), Style::default().fg(Color::Yellow), ))) }) @@ -662,21 +519,12 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) { .add_results .iter() .map(|r| { - let kind_color = match r.kind { - crate::app::AddKind::Movie => Color::Magenta, - crate::app::AddKind::Series => Color::Blue, - }; - ListItem::new(Line::from(vec![ - Span::styled( - format!("[{}] ", r.kind.label()), - Style::default().fg(kind_color), - ), - Span::raw(format!( - "{} ({})", - r.result.title, - r.result.year.map(|y| y.to_string()).unwrap_or_default() - )), - ])) + 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) @@ -691,43 +539,36 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) { } /// What's aired recently or airs soon (a week back, three weeks forward — -/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Selectable — -/// Enter jumps to that episode in Library. +/// 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 = app .calendar .iter() .map(|e| { - let (status, color) = if e.has_file { - ("✓", Color::Green) + let status = if e.has_file { + "✓" } else if !e.monitored { - ("-", Color::DarkGray) + "-" } else if e.air_date.as_str() > today.as_str() { - ("…", Color::Yellow) + "…" } else { - ("!", Color::Red) + "!" // aired, monitored, still missing }; - let today_mark = if e.air_date == today { " today" } else { "" }; let title = e.title.as_deref().unwrap_or(""); - ListItem::new(Line::from(vec![ - Span::styled(status, Style::default().fg(color)), - Span::raw(format!( - " {} {} S{:02}E{:02} {title}{today_mark}", - e.air_date, e.media_title, e.season_number, e.episode_number - )), - ])) + 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 — Enter: jump ✓ have it … upcoming ! aired but missing"), - ) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - let mut state = app.calendar_state.clone(); - frame.render_stateful_widget(list, area, &mut state); + 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, @@ -747,7 +588,7 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(6), Constraint::Min(3)]) + .constraints([Constraint::Length(5), Constraint::Min(3)]) .split(area); let s = &report.summary; @@ -757,13 +598,8 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) { .map(|c| format!("{}={}", c.codec, c.count)) .collect::>() .join(", "); - let cycles = app - .health - .as_ref() - .map(cycle_summary_line) - .unwrap_or_default(); let summary_text = format!( - "{cycles}\n{} files, {:.1} GB total, {} probed — resolution: SD={} 720p={} 1080p={} 4K={} \ + "{} 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, @@ -832,38 +668,12 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) { items.push(ListItem::new("Nothing flagged — library looks clean.")); } - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title("Flagged files"), - ) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - let mut state = app.health_state.clone(); - frame.render_stateful_widget(list, chunks[1], &mut state); -} - -fn cycle_bit(name: &str, info: &Option) -> String { - match info { - Some(c) if c.ok => format!("{name}: ok"), - Some(_) => format!("{name}: FAIL"), - None => format!("{name}: —"), - } -} - -fn cycle_summary_line(h: &breadarr_shared::dto::HealthDetail) -> String { - format!( - "{} | {} | {} | {}{}", - cycle_bit("grab", &h.last_grab_cycle), - cycle_bit("import", &h.last_import_cycle), - cycle_bit("search", &h.last_search_cycle), - cycle_bit("upgrade", &h.last_upgrade_cycle), - if h.search_halted { - " | search HALTED" - } else { - "" - } - ) + 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 diff --git a/breadarrd/src/main.rs b/breadarrd/src/main.rs index 2154730..a34cb89 100644 --- a/breadarrd/src/main.rs +++ b/breadarrd/src/main.rs @@ -355,26 +355,6 @@ async fn background_loop( ) { error!(error = %e, "failed to register tpb source row"); } - if let Err(e) = conn.execute( - "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) - VALUES (4, 'torrents-csv', 'scrape', ?1, ?2, 1)", - rusqlite::params![ - config.sources.torrents_csv_url, - config.sources.search_poll_interval_secs - ], - ) { - error!(error = %e, "failed to register torrents-csv source row"); - } - if let Err(e) = conn.execute( - "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) - VALUES (5, 'yts', 'scrape', ?1, ?2, 1)", - rusqlite::params![ - config.sources.yts_api_url, - config.sources.search_poll_interval_secs - ], - ) { - error!(error = %e, "failed to register yts source row"); - } } // A transient failure here (network blip during the one-time model @@ -397,21 +377,6 @@ async fn background_loop( let scrape_source = sources::scrape::ScrapeSource::new(config.sources.torrent_1337x_mirrors.clone()); let tpb_source = sources::tpb::TpbSource::new(config.sources.tpb_api_url.clone()); - let torrents_csv_source = - sources::torrents_csv::TorrentsCsvSource::new(config.sources.torrents_csv_url.clone()); - let yts_source = sources::yts::YtsSource::new(config.sources.yts_api_url.clone()); - let search_sources = scheduler::SearchSources { - tpb: &tpb_source, - tpb_id: 3, - torrents_csv: &torrents_csv_source, - torrents_csv_id: 4, - yts: &yts_source, - yts_id: 5, - scrape: &scrape_source, - scrape_id: 2, - nyaa_search: &nyaa_source, - nyaa_id: 1, - }; let mut grab_ticker = tokio::time::interval(std::time::Duration::from_secs( config.sources.grab_poll_interval_secs, @@ -536,7 +501,9 @@ async fn background_loop( let conn = conn.lock().await; scheduler::run_search_cycle( &conn, - &search_sources, + &tpb_source, 3, + &scrape_source, 2, + &nyaa_source, 1, &mut title_matcher, &qbit, &config.qbit.category, config.sources.search_budget_per_cycle, @@ -595,7 +562,9 @@ async fn background_loop( let conn = conn.lock().await; scheduler::run_upgrade_cycle( &conn, - &search_sources, + &tpb_source, 3, + &scrape_source, 2, + &nyaa_source, 1, &mut title_matcher, &qbit, &config.qbit.category, config.sources.upgrade_budget_per_cycle, @@ -710,7 +679,9 @@ async fn background_loop( Ok(targets) => scheduler::execute_search_targets( &conn, &targets, - &search_sources, + &tpb_source, 3, + &scrape_source, 2, + &nyaa_source, 1, &mut title_matcher, &qbit, &config.qbit.category, ).await, @@ -734,7 +705,9 @@ async fn background_loop( &conn, media_item_id, episode_id, - &search_sources, + &tpb_source, 3, + &scrape_source, 2, + &nyaa_source, 1, ).await }; let _ = reply.send(result); @@ -1160,22 +1133,6 @@ async fn debug_search_show(config: &Config, title: &str) -> Result<()> { config.sources.search_poll_interval_secs ], )?; - conn.execute( - "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) - VALUES (4, 'torrents-csv', 'scrape', ?1, ?2, 1)", - rusqlite::params![ - config.sources.torrents_csv_url, - config.sources.search_poll_interval_secs - ], - )?; - conn.execute( - "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) - VALUES (5, 'yts', 'scrape', ?1, ?2, 1)", - rusqlite::params![ - config.sources.yts_api_url, - config.sources.search_poll_interval_secs - ], - )?; let qbit = QbitClient::new(config.qbit.base_url.clone())?; if !config.qbit.username.is_empty() { @@ -1187,21 +1144,6 @@ async fn debug_search_show(config: &Config, title: &str) -> Result<()> { let scrape_source = sources::scrape::ScrapeSource::new(config.sources.torrent_1337x_mirrors.clone()); let nyaa_source = sources::rss::RssSource::new(config.sources.nyaa_rss_url.clone()); - let torrents_csv_source = - sources::torrents_csv::TorrentsCsvSource::new(config.sources.torrents_csv_url.clone()); - let yts_source = sources::yts::YtsSource::new(config.sources.yts_api_url.clone()); - let search_sources = scheduler::SearchSources { - tpb: &tpb_source, - tpb_id: 3, - torrents_csv: &torrents_csv_source, - torrents_csv_id: 4, - yts: &yts_source, - yts_id: 5, - scrape: &scrape_source, - scrape_id: 2, - nyaa_search: &nyaa_source, - nyaa_id: 1, - }; let targets = scheduler::enumerate_search_targets_for_media_item(&conn, media_item_id)?; println!("{} missing episode(s)/movie for {title:?}", targets.len()); @@ -1209,7 +1151,12 @@ async fn debug_search_show(config: &Config, title: &str) -> Result<()> { let stats = scheduler::execute_search_targets( &conn, &targets, - &search_sources, + &tpb_source, + 3, + &scrape_source, + 2, + &nyaa_source, + 1, &mut title_matcher, &qbit, &config.qbit.category, diff --git a/breadarrd/src/scheduler.rs b/breadarrd/src/scheduler.rs index 90b7d96..9ec61d7 100644 --- a/breadarrd/src/scheduler.rs +++ b/breadarrd/src/scheduler.rs @@ -652,8 +652,7 @@ async fn process_item( return Ok(ProcessOutcome::QueuedForReview); } - let release_score = - scoring::score(&parsed, item.seeders.unwrap_or(0), parsed.has_hdr, &profile); + let release_score = scoring::score(&parsed, item.seeders.unwrap_or(0), parsed.has_hdr, &profile); let existing_best = match (episode_id, season_pack_number) { (Some(eid), _) => best_existing_score(conn, eid)?, (None, Some(season)) => best_existing_season_pack_score(conn, media_item.id, season)?, @@ -912,8 +911,8 @@ pub async fn run_grab_cycle( Ok(stats) } -// --- Search-driven acquisition (TPB + YTS/csv/1337x fallbacks for -// general TV/movies, nyaa search for anime movies) --- +// --- Search-driven acquisition (1337x for general TV/movies, nyaa search +// for anime movies) --- // // Unlike the feed-based path above, there's no natural stream of "new" // items to dedup against — the recurring cost here is the *search itself*, @@ -922,12 +921,18 @@ pub async fn run_grab_cycle( // single cycle; cadence backs off exponentially (6h, 12h, 24h, 48h, 96h, // capped at a week) the more times it's been searched without success. -/// General-content fallbacks live-tested 2026-08-16 (TPB/apibay was -/// timing out; every configured 1337x mirror returned Cloudflare 521): -/// - **torrents-csv** and **YTS** (`yts.lt` API — `yts.mx` still does not -/// resolve) are JSON hash-to-magnet sources, same grab shape as TPB. -/// - **EZTV**'s JSON API is up but IMDb-id only; name search is a -/// Cloudflare challenge. Not wired — breadarr has TMDB/TVDB, not IMDb. +/// Other general-content sources considered and rejected (live-tested +/// 2026-07-12, not just assumed) before landing on TPB as primary: +/// - **YTS** (`yts.mx`): DNS doesn't resolve at all. Every known mirror +/// (`yts.am`, `yts.ag`, `yts.lt`, `yts.pe`) either 301s in a loop or drops +/// the query and lands on a bare homepage. The whole mirror network looks +/// dead, not just one domain — re-check before assuming a fix is quick. +/// - **EZTV** (`eztv.re`): redirects to `eztvx.to`, which fails to connect +/// outright (TLS/connection error, not a slow response). Also +/// Cloudflare-fronted, so even if connectivity is restored it carries the +/// same risk profile 1337x does. +/// If revisiting either, re-verify connectivity first — this isn't a +/// permanent architectural decision, just what was true when checked. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum SearchRoute { /// Primary general-content (movies + non-anime TV) route — a JSON API, @@ -942,22 +947,6 @@ enum SearchRoute { NyaaSearch, } -/// The search-driven sources plus their `source` table ids — one bundle -/// so `execute_search_targets` / `fetch_candidates` / the cycle runners -/// don't each grow another four arguments every time a fallback is added. -pub struct SearchSources<'a> { - pub tpb: &'a sources::tpb::TpbSource, - pub tpb_id: i64, - pub torrents_csv: &'a sources::torrents_csv::TorrentsCsvSource, - pub torrents_csv_id: i64, - pub yts: &'a sources::yts::YtsSource, - pub yts_id: i64, - pub scrape: &'a sources::scrape::ScrapeSource, - pub scrape_id: i64, - pub nyaa_search: &'a sources::rss::RssSource, - pub nyaa_id: i64, -} - #[derive(Debug, Clone, PartialEq)] pub struct SearchTarget { media_item_id: i64, @@ -1576,16 +1565,35 @@ pub struct SearchCycleStats { /// single broad query can dump into the review queue. const MAX_RESULTS_PER_SEARCH: usize = 15; +#[allow(clippy::too_many_arguments)] pub async fn run_search_cycle( conn: &Connection, - sources: &SearchSources<'_>, + tpb: &sources::tpb::TpbSource, + tpb_source_id: i64, + scrape: &sources::scrape::ScrapeSource, + scrape_source_id: i64, + nyaa_search: &sources::rss::RssSource, + nyaa_source_id: i64, matcher: &mut TitleMatcher, qbit: &QbitClient, qbit_category: &str, budget: usize, ) -> Result { let targets = enumerate_search_targets(conn, budget)?; - execute_search_targets(conn, &targets, sources, matcher, qbit, qbit_category).await + execute_search_targets( + conn, + &targets, + tpb, + tpb_source_id, + scrape, + scrape_source_id, + nyaa_search, + nyaa_source_id, + matcher, + qbit, + qbit_category, + ) + .await } /// Upgrade-search counterpart to `run_search_cycle`: same execution engine @@ -1593,9 +1601,15 @@ pub async fn run_search_cycle( /// ones. `min_gain` is threaded onto every target via /// `enumerate_upgrade_targets`, which is what routes `process_item` into /// its upgrade-eligibility path instead of the normal missing-content one. +#[allow(clippy::too_many_arguments)] pub async fn run_upgrade_cycle( conn: &Connection, - sources: &SearchSources<'_>, + tpb: &sources::tpb::TpbSource, + tpb_source_id: i64, + scrape: &sources::scrape::ScrapeSource, + scrape_source_id: i64, + nyaa_search: &sources::rss::RssSource, + nyaa_source_id: i64, matcher: &mut TitleMatcher, qbit: &QbitClient, qbit_category: &str, @@ -1603,7 +1617,20 @@ pub async fn run_upgrade_cycle( min_gain: f32, ) -> Result { let targets = enumerate_upgrade_targets(conn, budget, min_gain)?; - execute_search_targets(conn, &targets, sources, matcher, qbit, qbit_category).await + execute_search_targets( + conn, + &targets, + tpb, + tpb_source_id, + scrape, + scrape_source_id, + nyaa_search, + nyaa_source_id, + matcher, + qbit, + qbit_category, + ) + .await } /// Every currently-missing episode/movie for one specific `media_item`, @@ -1701,10 +1728,16 @@ pub fn find_media_item_id_by_title(conn: &Connection, title: &str) -> Result, + tpb: &sources::tpb::TpbSource, + tpb_source_id: i64, + scrape: &sources::scrape::ScrapeSource, + scrape_source_id: i64, + nyaa_search: &sources::rss::RssSource, + nyaa_source_id: i64, matcher: &mut TitleMatcher, qbit: &QbitClient, qbit_category: &str, @@ -1720,8 +1753,8 @@ pub async fn execute_search_targets( // without this, a 10-episode backlog fires 10 indistinguishable // requests at a single-domain API with no mirror fallback, which is // exactly the kind of pattern that gets a source rate-limited. Caches - // which source actually answered too (the TPB → YTS/csv/1337x chain - // can mean two targets with the same query were served by two + // which source actually answered too (the TPB→1337x fallback can mean + // two different targets with the same query string were served by two // different sources), so a cache hit still attributes dedup/grab // records to the right source id. let mut query_cache: std::collections::HashMap< @@ -1730,6 +1763,12 @@ pub async fn execute_search_targets( > = std::collections::HashMap::new(); for (i, target) in targets.iter().enumerate() { + let (primary, primary_id): (&dyn ReleaseSource, i64) = match target.route { + SearchRoute::Tpb => (tpb, tpb_source_id), + SearchRoute::X1337 => (scrape, scrape_source_id), + SearchRoute::NyaaSearch => (nyaa_search, nyaa_source_id), + }; + let cache_key = (target.route, target.query.clone()); let (items, source_id) = if let Some((cached_items, cached_source_id)) = query_cache.get(&cache_key) { @@ -1740,11 +1779,34 @@ pub async fn execute_search_targets( tokio::time::sleep(std::time::Duration::from_secs(jitter_secs)).await; } - match fetch_for_target(target, sources).await { - Ok(pair) => { + let primary_result = primary.fetch(Some(&target.query)).await; + // TPB is the primary route for general content, but a fetch + // *failure* there (not just "no relevant results") falls back + // to 1337x for the same query before giving up — keeps the + // mirror-rotation/cooldown machinery built for 1337x as real + // resilience rather than dead code, just no longer the first + // choice given TPB's better precision. `result_source_id` + // tracks which source actually produced whatever we end up + // with, since dedup (`is_seen`/`mark_seen`) and grab records + // are keyed by source id — attributing a 1337x-sourced guid to + // TPB's source id would silently break dedup between the two. + let (fetch_result, result_source_id) = match primary_result { + Err(e) if matches!(target.route, SearchRoute::Tpb) => { + tracing::warn!( + query = %target.query, + error = %e, + "TPB search failed, falling back to 1337x" + ); + (scrape.fetch(Some(&target.query)).await, scrape_source_id) + } + other => (other, primary_id), + }; + + match fetch_result { + Ok(items) => { consecutive_fetch_errors = 0; - query_cache.insert(cache_key, pair.clone()); - pair + query_cache.insert(cache_key, (items.clone(), result_source_id)); + (items, result_source_id) } Err(e) => { consecutive_fetch_errors += 1; @@ -1875,96 +1937,14 @@ pub async fn execute_search_targets( Ok(stats) } -fn source_name_for_id(id: i64) -> &'static str { - match id { - 1 => "nyaa", - 2 => "1337x", - 3 => "tpb", - 4 => "torrents-csv", - 5 => "yts", - _ => "unknown", +fn source_route_name(route: SearchRoute) -> &'static str { + match route { + SearchRoute::Tpb => "tpb", + SearchRoute::X1337 => "1337x", + SearchRoute::NyaaSearch => "nyaa", } } -/// TPB first; on failure *or* empty results, walk the supplement chain -/// (torrents-csv for everything, YTS for movies, 1337x last). Empty is -/// treated as "try the next one" so a live-but-empty apibay doesn't hide -/// a title that YTS/csv actually has. Dedup/grabs use whichever source -/// actually answered. -async fn fetch_for_target( - target: &SearchTarget, - sources: &SearchSources<'_>, -) -> Result<(Vec, i64)> { - match target.route { - SearchRoute::NyaaSearch => Ok(( - sources.nyaa_search.fetch(Some(&target.query)).await?, - sources.nyaa_id, - )), - SearchRoute::X1337 => Ok(( - sources.scrape.fetch(Some(&target.query)).await?, - sources.scrape_id, - )), - SearchRoute::Tpb => fetch_general_content(target, sources).await, - } -} - -async fn fetch_general_content( - target: &SearchTarget, - sources: &SearchSources<'_>, -) -> Result<(Vec, i64)> { - let is_movie = target.episode_id.is_none(); - let mut attempts: Vec<(&dyn ReleaseSource, i64, &'static str)> = - vec![(sources.tpb, sources.tpb_id, "tpb")]; - if is_movie { - attempts.push((sources.yts, sources.yts_id, "yts")); - } - attempts.push(( - sources.torrents_csv, - sources.torrents_csv_id, - "torrents-csv", - )); - attempts.push((sources.scrape, sources.scrape_id, "1337x")); - - let mut last_err: Option = None; - let mut any_ok = false; - for (src, id, name) in attempts { - match src.fetch(Some(&target.query)).await { - Ok(items) if !items.is_empty() => { - if name != "tpb" { - tracing::info!( - query = %target.query, - source = name, - n = items.len(), - "search fallback produced results" - ); - } - return Ok((items, id)); - } - Ok(_) => { - any_ok = true; - tracing::debug!( - query = %target.query, - source = name, - "search source returned no results" - ); - } - Err(e) => { - tracing::warn!( - query = %target.query, - source = name, - error = %e, - "search source failed" - ); - last_err = Some(e); - } - } - } - if any_ok { - return Ok((Vec::new(), sources.tpb_id)); - } - Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all general-content sources failed"))) -} - /// Fetches and scores (or gate-rejects) candidates for one search target — /// the same evaluation `execute_search_targets` does automatically, minus /// the grab decision, surfaced instead for a human to choose from. Used by @@ -1974,18 +1954,29 @@ async fn fetch_general_content( /// episode/movie right now (already owned, unmonitored, or mid-grab) — /// same "nothing to do" cases `enumerate_search_targets_for_media_item` /// already excludes. +#[allow(clippy::too_many_arguments)] pub async fn fetch_candidates( conn: &Connection, media_item_id: i64, episode_id: Option, - sources: &SearchSources<'_>, + tpb: &sources::tpb::TpbSource, + tpb_source_id: i64, + scrape: &sources::scrape::ScrapeSource, + scrape_source_id: i64, + nyaa_search: &sources::rss::RssSource, + nyaa_source_id: i64, ) -> Result> { let targets = enumerate_search_targets_for_media_item(conn, media_item_id)?; let Some(target) = targets.into_iter().find(|t| t.episode_id == episode_id) else { return Ok(Vec::new()); }; - let (items, source_id) = fetch_for_target(&target, sources).await?; + let (source, source_id): (&dyn ReleaseSource, i64) = match target.route { + SearchRoute::Tpb => (tpb, tpb_source_id), + SearchRoute::X1337 => (scrape, scrape_source_id), + SearchRoute::NyaaSearch => (nyaa_search, nyaa_source_id), + }; + let items = source.fetch(Some(&target.query)).await?; let media_item = get_media_item(conn, media_item_id)?; let anime = match media_item.tvdb_id { @@ -2038,7 +2029,7 @@ pub async fn fetch_candidates( link: item.link.clone(), guid: item.guid.clone(), source_id, - source_name: source_name_for_id(source_id).to_string(), + source_name: source_route_name(target.route).to_string(), seeders: item.seeders, leechers: item.leechers, size_bytes: item.size_bytes, diff --git a/breadarrd/src/sources/mod.rs b/breadarrd/src/sources/mod.rs index f4a32e1..646d82f 100644 --- a/breadarrd/src/sources/mod.rs +++ b/breadarrd/src/sources/mod.rs @@ -1,8 +1,6 @@ pub mod rss; pub mod scrape; -pub mod torrents_csv; pub mod tpb; -pub mod yts; use anyhow::Result; use async_trait::async_trait; @@ -27,39 +25,6 @@ pub trait ReleaseSource { async fn fetch(&self, query: Option<&str>) -> Result>; } -/// Trackers attached to every magnet we synthesize from an info-hash -/// (TPB, torrents-csv, YTS). Same set the TPB client has used since it -/// landed — qBittorrent needs *some* announce list or the torrent sits -/// hash-only until DHT finds peers. -const MAGNET_TRACKERS: &[&str] = &[ - "udp://tracker.opentrackr.org:1337/announce", - "udp://open.stealth.si:80/announce", - "udp://tracker.torrent.eu.org:451/announce", - "udp://tracker.openbittorrent.com:6969/announce", - "udp://exodus.desync.com:6969/announce", -]; - -/// A valid BitTorrent v1 info_hash: 40 hex chars or 32 base32 chars — same -/// shape `qbit::extract_btih` accepts out of a magnet URI. Shared by every -/// hash-to-magnet source so a malformed value can't silently produce a -/// magnet the grab path then fails to parse back. -pub(crate) fn is_valid_info_hash(hash: &str) -> bool { - (hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit())) - || (hash.len() == 32 - && hash - .bytes() - .all(|b| matches!(b, b'2'..=b'7' | b'a'..=b'z' | b'A'..=b'Z'))) -} - -pub(crate) fn build_magnet(info_hash: &str, name: &str) -> String { - let mut magnet = format!("magnet:?xt=urn:btih:{info_hash}&dn={}", urlencode(name)); - for t in MAGNET_TRACKERS { - magnet.push_str("&tr="); - magnet.push_str(&urlencode(t)); - } - magnet -} - pub(crate) fn urlencode(s: &str) -> String { s.chars() .map(|c| { diff --git a/breadarrd/src/sources/torrents_csv.rs b/breadarrd/src/sources/torrents_csv.rs deleted file mode 100644 index ea9cb06..0000000 --- a/breadarrd/src/sources/torrents_csv.rs +++ /dev/null @@ -1,130 +0,0 @@ -use anyhow::{Context, Result}; -use async_trait::async_trait; -use serde::Deserialize; - -use super::{build_magnet, is_valid_info_hash, urlencode, RawReleaseItem, ReleaseSource}; - -/// Public JSON search over the torrents.csv DHT dump. Same grab shape as -/// TPB (info-hash → magnet, no HTML): used as the first general-content -/// fallback when apibay is down or returns nothing. Seeders are scrape -/// snapshots, not live tracker data, so a high number can still stall — -/// the existing seeder gate still applies. -pub struct TorrentsCsvSource { - api_url: String, - client: reqwest::Client, -} - -#[derive(Deserialize)] -struct CsvResponse { - #[serde(default)] - torrents: Vec, -} - -#[derive(Deserialize)] -struct CsvTorrent { - infohash: String, - name: String, - size_bytes: Option, - seeders: Option, - leechers: Option, -} - -impl TorrentsCsvSource { - pub fn new(api_url: impl Into) -> Self { - Self { - api_url: api_url.into(), - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .expect("reqwest client build"), - } - } -} - -fn as_u32(n: Option) -> Option { - n.and_then(|v| u32::try_from(v.max(0)).ok()) -} - -#[async_trait] -impl ReleaseSource for TorrentsCsvSource { - async fn fetch(&self, query: Option<&str>) -> Result> { - let Some(query) = query else { - anyhow::bail!( - "TorrentsCsvSource requires a search query (this is a search-driven source, not a feed)" - ); - }; - let sep = if self.api_url.contains('?') { '&' } else { '?' }; - let url = format!( - "{}{sep}q={}&size=25", - self.api_url.trim_end_matches('/'), - urlencode(query) - ); - let parsed: CsvResponse = self - .client - .get(&url) - .send() - .await - .with_context(|| format!("request to {url} failed"))? - .error_for_status() - .with_context(|| format!("{url} returned an error status"))? - .json() - .await - .context("failed to parse torrents-csv response as JSON")?; - - Ok(parsed - .torrents - .into_iter() - .filter(|t| is_valid_info_hash(&t.infohash)) - .map(|t| RawReleaseItem { - title: t.name.clone(), - link: build_magnet(&t.infohash, &t.name), - guid: t.infohash, - size_bytes: t.size_bytes, - seeders: as_u32(t.seeders), - leechers: as_u32(t.leechers), - }) - .collect()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_a_real_captured_response() { - let body = r#"{ - "torrents": [ - { - "infohash": "ed0da850c273e3e15a819bdcbbf418bc85107ec8", - "name": "Dune (2021) [1080p] [WEBRip]", - "size_bytes": 2947989023, - "seeders": 746, - "leechers": 38 - }, - { - "infohash": "not-a-hash", - "name": "garbage", - "size_bytes": 1, - "seeders": 0, - "leechers": 0 - } - ] - }"#; - let parsed: CsvResponse = serde_json::from_str(body).unwrap(); - let items: Vec<_> = parsed - .torrents - .into_iter() - .filter(|t| is_valid_info_hash(&t.infohash)) - .collect(); - assert_eq!(items.len(), 1); - assert_eq!(items[0].name, "Dune (2021) [1080p] [WEBRip]"); - assert_eq!(items[0].seeders, Some(746)); - } - - #[test] - fn empty_payload_is_no_results_not_an_error() { - let parsed: CsvResponse = serde_json::from_str(r#"{"torrents":[]}"#).unwrap(); - assert!(parsed.torrents.is_empty()); - } -} diff --git a/breadarrd/src/sources/tpb.rs b/breadarrd/src/sources/tpb.rs index 6db4f35..e72d01f 100644 --- a/breadarrd/src/sources/tpb.rs +++ b/breadarrd/src/sources/tpb.rs @@ -2,7 +2,18 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use serde::Deserialize; -use super::{build_magnet, is_valid_info_hash, urlencode, RawReleaseItem, ReleaseSource}; +use super::{urlencode, RawReleaseItem, ReleaseSource}; + +/// A valid BitTorrent v1 info_hash: 40 hex chars or 32 base32 chars — same +/// shape `qbit::extract_btih` accepts out of a magnet URI. Checked before +/// building a magnet from `info_hash` at all: apibay is normally reliable, +/// but a malformed value would otherwise silently produce a magnet +/// `extract_btih` can't parse back out, downgrading that grab to the slow +/// ~30s `torrents/info` polling path with no visible error anywhere. +fn is_valid_info_hash(hash: &str) -> bool { + (hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit())) + || (hash.len() == 32 && hash.bytes().all(|b| matches!(b, b'2'..=b'7' | b'a'..=b'z' | b'A'..=b'Z'))) +} /// A community-run JSON API mirror of The Pirate Bay's search — unlike /// 1337x, this is a genuine machine-readable API (not HTML scraping), and @@ -27,6 +38,23 @@ struct TpbResult { size: String, } +const TRACKERS: &[&str] = &[ + "udp://tracker.opentrackr.org:1337/announce", + "udp://open.stealth.si:80/announce", + "udp://tracker.torrent.eu.org:451/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://exodus.desync.com:6969/announce", +]; + +fn build_magnet(info_hash: &str, name: &str) -> String { + let mut magnet = format!("magnet:?xt=urn:btih:{info_hash}&dn={}", urlencode(name)); + for t in TRACKERS { + magnet.push_str("&tr="); + magnet.push_str(&urlencode(t)); + } + magnet +} + impl TpbSource { pub fn new(api_url: impl Into) -> Self { Self { @@ -126,9 +154,7 @@ mod tests { #[test] fn is_valid_info_hash_accepts_both_real_shapes() { - assert!(is_valid_info_hash( - "8F87C7C186172F17E35F4512BB1A3E93B614ADED" - )); // 40 hex + assert!(is_valid_info_hash("8F87C7C186172F17E35F4512BB1A3E93B614ADED")); // 40 hex assert!(is_valid_info_hash("abcdefghijklmnopqrstuvwxyz234567")); // 32 base32 } @@ -141,11 +167,7 @@ mod tests { fn is_valid_info_hash_rejects_malformed_values() { assert!(!is_valid_info_hash("")); assert!(!is_valid_info_hash("too-short")); - assert!(!is_valid_info_hash( - "not-a-hex-string-at-all-nope!!!!!!!!!!!!" - )); // 40 chars, non-hex - assert!(!is_valid_info_hash( - "8F87C7C186172F17E35F4512BB1A3E93B614ADE" - )); // 39 hex chars + assert!(!is_valid_info_hash("not-a-hex-string-at-all-nope!!!!!!!!!!!!")); // 40 chars, non-hex + assert!(!is_valid_info_hash("8F87C7C186172F17E35F4512BB1A3E93B614ADE")); // 39 hex chars } } diff --git a/breadarrd/src/sources/yts.rs b/breadarrd/src/sources/yts.rs deleted file mode 100644 index a29bf9c..0000000 --- a/breadarrd/src/sources/yts.rs +++ /dev/null @@ -1,194 +0,0 @@ -use anyhow::{Context, Result}; -use async_trait::async_trait; -use serde::Deserialize; - -use super::{build_magnet, is_valid_info_hash, urlencode, RawReleaseItem, ReleaseSource}; - -/// YTS movie API. `yts.mx` itself no longer resolves (checked 2026-07-12 -/// and again 2026-08-16); the `yts.lt` / `yts.am` hosts still serve the -/// v2 JSON API, which is why the default URL is a working mirror rather -/// than the brand domain. Movies only — each hit expands into one -/// `RawReleaseItem` per quality so the scorer sees 720p/1080p/2160p as -/// distinct candidates, same as if they were separate TPB rows. -pub struct YtsSource { - api_url: String, - client: reqwest::Client, -} - -#[derive(Deserialize)] -struct YtsResponse { - status: String, - data: Option, -} - -#[derive(Deserialize, Default)] -struct YtsData { - #[serde(default)] - movies: Vec, -} - -#[derive(Deserialize)] -struct YtsMovie { - title: String, - year: Option, - #[serde(default)] - torrents: Vec, -} - -#[derive(Deserialize)] -struct YtsTorrent { - hash: String, - quality: Option, - #[serde(rename = "type")] - source_type: Option, - video_codec: Option, - seeds: Option, - peers: Option, - size_bytes: Option, -} - -impl YtsSource { - pub fn new(api_url: impl Into) -> Self { - Self { - api_url: api_url.into(), - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .expect("reqwest client build"), - } - } -} - -fn as_u32(n: Option) -> Option { - n.and_then(|v| u32::try_from(v.max(0)).ok()) -} - -/// Builds a release title the existing parser can read quality/source/codec -/// out of — YTS stores those as structured fields, not in `title`. -fn release_title(movie: &YtsMovie, torrent: &YtsTorrent) -> String { - let mut title = movie.title.clone(); - if let Some(year) = movie.year { - title.push_str(&format!(" ({year})")); - } - for part in [ - torrent.quality.as_deref(), - torrent.source_type.as_deref(), - torrent.video_codec.as_deref(), - ] - .into_iter() - .flatten() - { - if !part.is_empty() { - title.push_str(&format!(" [{part}]")); - } - } - title -} - -#[async_trait] -impl ReleaseSource for YtsSource { - async fn fetch(&self, query: Option<&str>) -> Result> { - let Some(query) = query else { - anyhow::bail!( - "YtsSource requires a search query (this is a search-driven source, not a feed)" - ); - }; - let sep = if self.api_url.contains('?') { '&' } else { '?' }; - let url = format!( - "{}{sep}query_term={}&limit=20&sort_by=seeds", - self.api_url.trim_end_matches('/'), - urlencode(query) - ); - let parsed: YtsResponse = self - .client - .get(&url) - .send() - .await - .with_context(|| format!("request to {url} failed"))? - .error_for_status() - .with_context(|| format!("{url} returned an error status"))? - .json() - .await - .context("failed to parse YTS response as JSON")?; - anyhow::ensure!( - parsed.status == "ok", - "YTS returned status {:?}", - parsed.status - ); - - let movies = parsed.data.unwrap_or_default().movies; - let mut items = Vec::new(); - for movie in movies { - for torrent in &movie.torrents { - if !is_valid_info_hash(&torrent.hash) { - continue; - } - let title = release_title(&movie, torrent); - items.push(RawReleaseItem { - title: title.clone(), - link: build_magnet(&torrent.hash, &title), - guid: torrent.hash.clone(), - size_bytes: torrent.size_bytes, - seeders: as_u32(torrent.seeds), - leechers: as_u32(torrent.peers), - }); - } - } - Ok(items) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = r#"{ - "status": "ok", - "data": { - "movie_count": 1, - "movies": [{ - "title": "Dune: Part One", - "year": 2021, - "torrents": [ - { - "hash": "DEB6929BEEB09ADCBD14DC4D6081F7E6B297B88C", - "quality": "1080p", - "type": "web", - "video_codec": "x264", - "seeds": 12, - "peers": 3, - "size_bytes": 2147483648 - }, - { - "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "quality": "720p", - "type": "bluray", - "video_codec": "x265", - "seeds": 4, - "peers": 1, - "size_bytes": 1073741824 - } - ] - }] - } - }"#; - - #[test] - fn expands_one_movie_into_per_quality_rows() { - let parsed: YtsResponse = serde_json::from_str(SAMPLE).unwrap(); - assert_eq!(parsed.status, "ok"); - let movie = &parsed.data.unwrap().movies[0]; - assert_eq!(movie.torrents.len(), 2); - let t0 = release_title(movie, &movie.torrents[0]); - assert_eq!(t0, "Dune: Part One (2021) [1080p] [web] [x264]"); - let t1 = release_title(movie, &movie.torrents[1]); - assert_eq!(t1, "Dune: Part One (2021) [720p] [bluray] [x265]"); - } - - #[test] - fn missing_movies_array_is_empty_not_an_error() { - let parsed: YtsResponse = - serde_json::from_str(r#"{"status":"ok","data":{"movie_count":0}}"#).unwrap(); - assert!(parsed.data.unwrap_or_default().movies.is_empty()); - } -} diff --git a/config.example.toml b/config.example.toml index 0c64aaa..6643c46 100644 --- a/config.example.toml +++ b/config.example.toml @@ -72,12 +72,6 @@ import_poll_interval_secs = 60 # Community JSON API mirror of The Pirate Bay — primary general-content # search source (movies + non-anime TV). tpb_api_url = "https://apibay.org/q.php" -# torrents.csv DHT-dump search — first fallback when TPB fails or returns -# nothing. Same hash-to-magnet grab; movies and TV. -torrents_csv_url = "https://torrents-csv.com/service/search" -# YTS v2 list_movies JSON — movie-only fallback. yts.mx does not resolve; -# yts.lt is a working host as of 2026-08-16. -yts_api_url = "https://yts.lt/api/v2/list_movies.json" # Search-driven acquisition (movies + non-anime TV via 1337x, anime movies # via nyaa's search mode) — unlike the nyaa RSS feed watch above, this # actively queries a Cloudflare-fronted service with a ban history, so keep @@ -102,7 +96,6 @@ upgrade_min_score_gain = 5.0 # tried in a fixed fallback order), with a failing mirror demoted into a # cooldown rather than re-probed on the very next search. torrent_1337x_mirrors = [ - "https://www.1337xx.to", "https://13377x.info", "https://13377x.email", "https://1337xto.info",