diff --git a/README.md b/README.md index 4e7f5e2..8ef047b 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,12 @@ 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. -- **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. +- **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. -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. +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. ## Setup @@ -52,13 +54,13 @@ Install via bakery (`bakery install breadarr`) on a homelab host, or build from ## Using the TUI -`Tab` cycles Library / History / Review Queue / Add Show / Stuck / Calendar / Health / Profiles. `j`/`k` or arrow keys navigate, `Enter` opens detail or runs a search, `Esc` backs out. +`Tab` / `Shift+Tab` cycle Library / History / Review / Add / Stuck / Calendar / Health / Profiles; `1`–`8` jump straight to a tab. `j`/`k` or arrows move, `g`/`G` jump to first/last, `PgUp`/`PgDn` page, `Enter` opens detail or runs a search, `Esc` backs out. `?` is the full key list. -- **Review Queue** — `a` approves and `r` rejects a pending low-confidence title match. Check this periodically, especially early on. -- **Library** (with an item's detail open) — `s` triggers an immediate search-now pass for that item's backlog; `m`/`e`/`S` toggle monitored on the show/episode/season respectively; `x` (confirm with a second `x`) removes the item from tracking without touching files on disk; `d` (confirm with a second `d`) deletes a bad imported file from disk and clears its tracking, freeing the episode/movie to be re-grabbed on the next cycle — the redownload path for a file that turned out to be wrong or broken; `c` fetches the manual release picker for the selected episode/movie, `Enter` grabs the highlighted candidate, `Esc` cancels. -- **Stuck** — surfaces grabs that look stalled (no download progress advancing, or missing from qBittorrent) before the daemon's own auto-fail timers would catch them. -- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window. -- **Health** — the library-health report (see below) rendered as a tab instead of curled by hand. +- **Review** — `a` approves and `r` rejects a pending low-confidence title match. The tab title badges the pending count. Check this periodically, especially early on. +- **Library** — `/` incrementally filters the list by title; `f`/`o` cycle kind-filter and sort. With an item's detail open: `s` triggers an immediate search-now pass for that item's backlog; `n` jumps to the next missing monitored episode; `m`/`e`/`S` toggle monitored on the show/episode/season respectively; `x` (confirm with a second `x`) removes the item from tracking without touching files on disk; `d` (confirm with a second `d`) deletes a bad imported file from disk and clears its tracking, freeing the episode/movie to be re-grabbed on the next cycle — the redownload path for a file that turned out to be wrong or broken; `c` fetches the manual release picker for the selected episode/movie, `Enter` grabs the highlighted candidate, `Esc` cancels. Movies (no episode list) show a summary pane with the same keys. +- **Stuck** — surfaces grabs that look stalled (no download progress advancing, or missing from qBittorrent) before the daemon's own auto-fail timers would catch them. `Enter` jumps to that show. +- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window. `Enter` opens the matching episode. +- **Health** — daemon cycle outcomes plus the library-health report (see below), scrollable. - **Profiles** — quality-profile weight axes; open a profile and edit a weight in place. ## Operational notes diff --git a/breadarr-shared/src/config.rs b/breadarr-shared/src/config.rs index 77cb044..b60c84a 100644 --- a/breadarr-shared/src/config.rs +++ b/breadarr-shared/src/config.rs @@ -97,6 +97,16 @@ 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 @@ -134,6 +144,8 @@ 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(), @@ -146,6 +158,14 @@ 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 } @@ -192,6 +212,7 @@ 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 45b213e..b991885 100644 --- a/breadarr-tui/src/app.rs +++ b/breadarr-tui/src/app.rs @@ -1,12 +1,13 @@ use anyhow::Result; use breadarr_shared::dto::{ - CalendarEntry, HealthDetail, LibraryHealthReport, MediaItemDetail, MediaItemSummary, - QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchNowResult, - SearchResult, StuckReport, WeightsDto, + CalendarEntry, EpisodeSummary, FlaggedFile, HealthDetail, LibraryHealthReport, MediaItemDetail, + MediaItemSummary, QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, + SearchNowResult, SearchResult, StuckReport, WeightsDto, }; use breadarr_shared::DaemonClient; use ratatui::widgets::ListState; use std::path::Path; +use std::time::{Duration, Instant}; /// Category roots the TUI's "Add" flow can place new items under — kept as /// two separate paths (not one shared default) since a series and a movie @@ -75,8 +76,8 @@ impl Tab { match self { Tab::Library => "Library", Tab::History => "History", - Tab::Review => "Review Queue", - Tab::Add => "Add Show", + Tab::Review => "Review", + Tab::Add => "Add", Tab::Stuck => "Stuck", Tab::Calendar => "Calendar", Tab::LibraryHealth => "Health", @@ -89,6 +90,10 @@ pub enum Focus { List, AddSearchInput, AddResults, + /// Incremental title filter on the Library list — `App::library_query` + /// holds the in-progress text, same priority-over-global-keys pattern + /// as `AddSearchInput`. + LibraryFilterInput, /// The manual release picker overlay — `App::candidates` holds the /// list, `App::candidates_episode_id` remembers which episode (or, if /// `None`, the open movie) it was fetched for so a grab can be @@ -238,13 +243,70 @@ pub struct AddResult { } /// Result of a long `DaemonClient` call spawned off the draw loop so -/// `search_now` / candidate fetch (up to 600s) cannot freeze key handling. +/// `search_now` / candidate fetch / add-tab search cannot freeze key +/// handling. Add-search is typically a few seconds (TVDB + TMDB together) +/// rather than minutes, but it still shouldn't stall j/k or Tab. enum BackgroundOutcome { SearchNow(Result), Candidates { episode_id: Option, result: Result>, }, + AddSearch { + results: Vec, + errors: Vec, + }, +} + +/// Case-insensitive substring match used by the Library `/` filter. +fn title_matches_query(title: &str, query: &str) -> bool { + query.is_empty() || title.to_lowercase().contains(&query.to_lowercase()) +} + +/// Next monitored-and-missing episode, wrapping from `start` so `n` can +/// be mashed through a season without first jumping to the top. +fn next_missing_index(episodes: &[EpisodeSummary], start: usize) -> Option { + if episodes.is_empty() { + return None; + } + let start = start.min(episodes.len()); + episodes + .iter() + .enumerate() + .skip(start) + .chain(episodes.iter().enumerate().take(start)) + .find(|(_, e)| e.monitored && !e.has_file) + .map(|(i, _)| i) +} + +fn clamp_list_state(state: &mut ListState, len: usize) { + match (state.selected(), len) { + (_, 0) => state.select(None), + (None, _) => state.select(Some(0)), + (Some(i), _) if i >= len => state.select(Some(len - 1)), + _ => {} + } +} + +/// Row count of the Health tab's flagged-files list, including section +/// headers — must stay in lockstep with `draw_library_health` so j/k +/// doesn't walk off the rendered items. +pub fn health_row_count(report: &LibraryHealthReport) -> usize { + let mut n = 0; + let mut add_files = |files: &[FlaggedFile]| { + if !files.is_empty() { + n += 1 + files.len(); + } + }; + add_files(&report.corrupt_files); + add_files(&report.under_quality_files); + add_files(&report.no_english_audio_files); + add_files(&report.non_english_default_audio_files); + add_files(&report.no_subtitle_files); + if !report.duplicate_groups.is_empty() { + n += 1 + report.duplicate_groups.len(); + } + n.max(1) } pub struct App { @@ -254,6 +316,10 @@ pub struct App { pub tab: Tab, pub focus: Focus, pub status: String, + /// When `status` was last written — the draw loop clears it after a + /// few seconds so action feedback doesn't permanently hide the + /// keybinding hints in the status bar. + status_set_at: Option, pub should_quit: bool, /// Toggled by `?`; intercepted at the top of `main.rs`'s key handler /// like `Focus::AddSearchInput`/`Focus::WeightInput`, but kept as its @@ -273,6 +339,9 @@ pub struct App { pub library_view: Vec, pub library_filter: LibraryFilter, pub library_sort: LibrarySort, + /// Incremental title filter from `/` — applied on top of + /// `library_filter`/`library_sort` inside `recompute_library_view`. + pub library_query: String, pub detail: Option, /// Selection within `detail.episodes` — separate from `media_state` /// since they're two different lists sharing the same tab. @@ -283,6 +352,9 @@ pub struct App { pub review_items: Vec, pub review_state: ListState, + /// Last-seen review-queue depth, kept even when the Review tab isn't + /// the one being refreshed so the tab strip can badge it. + pub review_count: usize, pub add_query: String, pub add_results: Vec, @@ -292,8 +364,12 @@ pub struct App { pub stuck_focus: StuckSection, pub stalled_state: ListState, pub maxed_state: ListState, + /// Last-seen stalled+maxed count for the Stuck tab badge. + pub stuck_count: usize, pub calendar: Vec, + pub calendar_state: ListState, pub library_health: Option, + pub health_state: ListState, pub candidates: Vec, pub candidates_state: ListState, @@ -338,6 +414,7 @@ impl App { tab: Tab::Library, focus: Focus::List, status: String::new(), + status_set_at: None, should_quit: false, help_visible: false, force_refresh: false, @@ -346,12 +423,14 @@ impl App { library_view: Vec::new(), library_filter: LibraryFilter::All, library_sort: LibrarySort::TitleAsc, + library_query: String::new(), detail: None, episode_state: ListState::default(), releases: Vec::new(), releases_state: ListState::default(), review_items: Vec::new(), review_state: ListState::default(), + review_count: 0, add_query: String::new(), add_results: Vec::new(), add_results_state: ListState::default(), @@ -359,8 +438,11 @@ impl App { stuck_focus: StuckSection::Stalled, stalled_state: ListState::default(), maxed_state: ListState::default(), + stuck_count: 0, calendar: Vec::new(), + calendar_state: ListState::default(), library_health: None, + health_state: ListState::default(), candidates: Vec::new(), candidates_state: ListState::default(), candidates_episode_id: None, @@ -376,190 +458,32 @@ impl App { } } - /// Applies a finished background search/candidate task. Only `.await`s - /// a handle that `is_finished()`, so the draw loop stays responsive. - pub async fn poll_background(&mut self) { - let Some(handle) = &self.background else { - return; + pub fn set_status(&mut self, msg: impl Into) { + self.status = msg.into(); + self.status_set_at = if self.status.is_empty() { + None + } else { + Some(Instant::now()) }; - if !handle.is_finished() { + } + + /// Drops stale action feedback so the status bar can show keybinding + /// hints again. In-flight work (`busy`) keeps its "searching..." line. + pub fn expire_status(&mut self) { + if self.busy { return; } - let handle = self.background.take().expect("just checked is_finished"); - self.busy = false; - match handle.await { - Ok(BackgroundOutcome::SearchNow(Ok(stats))) => { - self.status = format!( - "search complete: {} target(s), {} grabbed, {} error(s)", - stats.targets, stats.grabbed, stats.errors - ); + if let Some(at) = self.status_set_at { + if at.elapsed() >= Duration::from_secs(5) { + self.status.clear(); + self.status_set_at = None; } - Ok(BackgroundOutcome::SearchNow(Err(e))) => { - self.status = format!("search failed: {e}"); - } - Ok(BackgroundOutcome::Candidates { - episode_id, - result: Ok(candidates), - }) => { - self.status = format!("{} candidate(s) found", candidates.len()); - if matches!(self.tab, Tab::Library) && self.detail.is_some() { - self.candidates_state.select(if candidates.is_empty() { - None - } else { - Some(0) - }); - self.candidates = candidates; - self.candidates_episode_id = episode_id; - self.focus = Focus::Candidates; - } - } - Ok(BackgroundOutcome::Candidates { result: Err(e), .. }) => { - self.status = format!("candidate fetch failed: {e}"); - } - Err(e) => self.status = format!("background task failed: {e}"), } } - pub async fn refresh_active_tab(&mut self) { - match self.client.health_detail().await { - Ok(detail) => { - self.daemon_up = true; - self.health = Some(detail); - } - Err(_) => { - self.daemon_up = false; - self.health = None; - } - } - if !self.daemon_up { - self.status = "daemon unreachable".to_string(); - return; - } - let result: Result<()> = async { - match self.tab { - Tab::Library => { - if self.detail.is_some() { - if let Some(id) = self.selected_media_id() { - self.detail = Some(self.client.media_detail(id).await?); - } - } else { - self.media_items = self.client.list_media().await?; - self.recompute_library_view(); - } - } - Tab::History => { - self.releases = self.client.releases().await?; - if self.releases_state.selected().is_none() && !self.releases.is_empty() { - self.releases_state.select(Some(0)); - } - } - Tab::Review => { - self.review_items = self.client.review_queue().await?; - if self.review_state.selected().is_none() && !self.review_items.is_empty() { - self.review_state.select(Some(0)); - } - } - Tab::Add => {} - Tab::Stuck => { - let report = self.client.stuck().await?; - if self.stalled_state.selected().is_none() && !report.stalled_grabs.is_empty() - { - self.stalled_state.select(Some(0)); - } - if self.maxed_state.selected().is_none() - && !report.maxed_out_search_targets.is_empty() - { - self.maxed_state.select(Some(0)); - } - self.stuck = Some(report); - } - Tab::Calendar => { - self.calendar = self.client.calendar().await?; - } - Tab::LibraryHealth => { - self.library_health = Some(self.client.library_health().await?); - } - Tab::Profiles => { - if self.profile_detail.is_none() { - self.quality_profiles = self.client.quality_profiles().await?; - if self.profiles_state.selected().is_none() - && !self.quality_profiles.is_empty() - { - self.profiles_state.select(Some(0)); - } - } - } - } - Ok(()) - } - .await; - - if let Err(e) = result { - self.status = format!("error: {e}"); - } - } - - fn selected_media_id(&self) -> Option { - self.detail.as_ref().map(|d| d.id) - } - - /// Rebuilds `library_view` from `media_items` under the current - /// filter/sort, then re-selects whichever item was selected before (by - /// id, not raw index) if it's still in view — falls back to the first - /// item, or no selection if the view is now empty. Called after - /// `media_items` changes, and after `library_filter`/`library_sort` - /// change. - pub fn recompute_library_view(&mut self) { - let previously_selected_id = self.selected_media_item().map(|m| m.id); - - let mut indices: Vec = self - .media_items - .iter() - .enumerate() - .filter(|(_, m)| self.library_filter.matches(m)) - .map(|(i, _)| i) - .collect(); - - match self.library_sort { - LibrarySort::TitleAsc => indices.sort_by(|&a, &b| { - self.media_items[a] - .title - .to_lowercase() - .cmp(&self.media_items[b].title.to_lowercase()) - }), - LibrarySort::MissingDesc => indices.sort_by(|&a, &b| { - self.media_items[b] - .missing_count - .cmp(&self.media_items[a].missing_count) - }), - LibrarySort::Kind => indices.sort_by(|&a, &b| { - self.media_items[a].kind.cmp(&self.media_items[b].kind).then_with(|| { - self.media_items[a] - .title - .to_lowercase() - .cmp(&self.media_items[b].title.to_lowercase()) - }) - }), - } - self.library_view = indices; - - match previously_selected_id - .and_then(|id| self.library_view.iter().position(|&i| self.media_items[i].id == id)) - { - Some(pos) => self.media_state.select(Some(pos)), - None if !self.library_view.is_empty() => self.media_state.select(Some(0)), - None => self.media_state.select(None), - } - } - - pub fn selected_media_item(&self) -> Option<&MediaItemSummary> { - let idx = self.media_state.selected()?; - let real_idx = *self.library_view.get(idx)?; - self.media_items.get(real_idx) - } - - pub fn move_selection(&mut self, delta: i32) { - let (state, len) = match self.tab { + /// The list currently driven by j/k / g/G / PageUp/PageDown. + fn active_list(&mut self) -> (&mut ListState, usize) { + match self.tab { Tab::Library if matches!(self.focus, Focus::Candidates) => { (&mut self.candidates_state, self.candidates.len()) } @@ -582,12 +506,247 @@ impl App { StuckSection::Maxed => (&mut self.maxed_state, report_len.1), } } + Tab::Calendar => (&mut self.calendar_state, self.calendar.len()), + Tab::LibraryHealth => { + let n = self + .library_health + .as_ref() + .map(health_row_count) + .unwrap_or(0); + (&mut self.health_state, n) + } Tab::Profiles if self.profile_detail.is_some() => { (&mut self.profile_weight_state, WEIGHT_FIELDS.len()) } Tab::Profiles => (&mut self.profiles_state, self.quality_profiles.len()), - _ => return, + } + } + + pub fn select_edge(&mut self, last: bool) { + let (state, len) = self.active_list(); + if len == 0 { + return; + } + state.select(Some(if last { len - 1 } else { 0 })); + } + + /// Applies a finished background search/candidate task. Only `.await`s + /// a handle that `is_finished()`, so the draw loop stays responsive. + pub async fn poll_background(&mut self) { + let Some(handle) = &self.background else { + return; }; + if !handle.is_finished() { + return; + } + let handle = self.background.take().expect("just checked is_finished"); + self.busy = false; + match handle.await { + Ok(BackgroundOutcome::SearchNow(Ok(stats))) => { + self.set_status(format!( + "search complete: {} target(s), {} grabbed, {} error(s)", + stats.targets, stats.grabbed, stats.errors + )); + } + Ok(BackgroundOutcome::SearchNow(Err(e))) => { + self.set_status(format!("search failed: {e}")); + } + Ok(BackgroundOutcome::Candidates { + episode_id, + result: Ok(candidates), + }) => { + self.set_status(format!("{} candidate(s) found", candidates.len())); + if matches!(self.tab, Tab::Library) && self.detail.is_some() { + self.candidates_state.select(if candidates.is_empty() { + None + } else { + Some(0) + }); + self.candidates = candidates; + self.candidates_episode_id = episode_id; + self.focus = Focus::Candidates; + } + } + Ok(BackgroundOutcome::Candidates { result: Err(e), .. }) => { + self.set_status(format!("candidate fetch failed: {e}")); + } + Ok(BackgroundOutcome::AddSearch { results, errors }) => { + self.add_results_state + .select(if results.is_empty() { None } else { Some(0) }); + self.add_results = results; + self.set_status(if errors.is_empty() { + String::new() + } else { + errors.join("; ") + }); + if matches!(self.tab, Tab::Add) { + self.focus = Focus::AddResults; + } + } + Err(e) => self.set_status(format!("background task failed: {e}")), + } + } + + pub async fn refresh_active_tab(&mut self) { + match self.client.health_detail().await { + Ok(detail) => { + self.daemon_up = true; + self.health = Some(detail); + } + Err(_) => { + self.daemon_up = false; + self.health = None; + } + } + if !self.daemon_up { + self.set_status("daemon unreachable"); + return; + } + let result: Result<()> = async { + match self.tab { + Tab::Library => { + if self.detail.is_some() { + if let Some(id) = self.selected_media_id() { + self.detail = Some(self.client.media_detail(id).await?); + } + } else { + self.media_items = self.client.list_media().await?; + self.recompute_library_view(); + } + } + Tab::History => { + self.releases = self.client.releases().await?; + clamp_list_state(&mut self.releases_state, self.releases.len()); + } + Tab::Review => { + self.review_items = self.client.review_queue().await?; + self.review_count = self.review_items.len(); + clamp_list_state(&mut self.review_state, self.review_items.len()); + } + Tab::Add => {} + Tab::Stuck => { + let report = self.client.stuck().await?; + self.stuck_count = + report.stalled_grabs.len() + report.maxed_out_search_targets.len(); + clamp_list_state(&mut self.stalled_state, report.stalled_grabs.len()); + clamp_list_state(&mut self.maxed_state, report.maxed_out_search_targets.len()); + self.stuck = Some(report); + } + Tab::Calendar => { + self.calendar = self.client.calendar().await?; + clamp_list_state(&mut self.calendar_state, self.calendar.len()); + } + Tab::LibraryHealth => { + self.library_health = Some(self.client.library_health().await?); + let n = self + .library_health + .as_ref() + .map(health_row_count) + .unwrap_or(0); + clamp_list_state(&mut self.health_state, n); + } + Tab::Profiles => { + if self.profile_detail.is_none() { + self.quality_profiles = self.client.quality_profiles().await?; + clamp_list_state(&mut self.profiles_state, self.quality_profiles.len()); + } + } + } + self.refresh_badge_counts().await; + Ok(()) + } + .await; + + if let Err(e) = result { + self.set_status(format!("error: {e}")); + } + } + + /// Cheap counts for the tab-strip badges. Skips the resource the + /// active tab already fetched so we don't double-hit the same route. + async fn refresh_badge_counts(&mut self) { + if !matches!(self.tab, Tab::Review) { + if let Ok(items) = self.client.review_queue().await { + self.review_count = items.len(); + } + } + if !matches!(self.tab, Tab::Stuck) { + if let Ok(report) = self.client.stuck().await { + self.stuck_count = + report.stalled_grabs.len() + report.maxed_out_search_targets.len(); + } + } + } + + fn selected_media_id(&self) -> Option { + self.detail.as_ref().map(|d| d.id) + } + + /// Rebuilds `library_view` from `media_items` under the current + /// filter/sort, then re-selects whichever item was selected before (by + /// id, not raw index) if it's still in view — falls back to the first + /// item, or no selection if the view is now empty. Called after + /// `media_items` changes, and after `library_filter`/`library_sort` + /// change. + pub fn recompute_library_view(&mut self) { + let previously_selected_id = self.selected_media_item().map(|m| m.id); + + let query = self.library_query.clone(); + let mut indices: Vec = self + .media_items + .iter() + .enumerate() + .filter(|(_, m)| { + self.library_filter.matches(m) && title_matches_query(&m.title, &query) + }) + .map(|(i, _)| i) + .collect(); + + match self.library_sort { + LibrarySort::TitleAsc => indices.sort_by(|&a, &b| { + self.media_items[a] + .title + .to_lowercase() + .cmp(&self.media_items[b].title.to_lowercase()) + }), + LibrarySort::MissingDesc => indices.sort_by(|&a, &b| { + self.media_items[b] + .missing_count + .cmp(&self.media_items[a].missing_count) + }), + LibrarySort::Kind => indices.sort_by(|&a, &b| { + self.media_items[a] + .kind + .cmp(&self.media_items[b].kind) + .then_with(|| { + self.media_items[a] + .title + .to_lowercase() + .cmp(&self.media_items[b].title.to_lowercase()) + }) + }), + } + self.library_view = indices; + + match previously_selected_id.and_then(|id| { + self.library_view + .iter() + .position(|&i| self.media_items[i].id == id) + }) { + Some(pos) => self.media_state.select(Some(pos)), + None if !self.library_view.is_empty() => self.media_state.select(Some(0)), + None => self.media_state.select(None), + } + } + + pub fn selected_media_item(&self) -> Option<&MediaItemSummary> { + let idx = self.media_state.selected()?; + let real_idx = *self.library_view.get(idx)?; + self.media_items.get(real_idx) + } + + pub fn move_selection(&mut self, delta: i32) { + let (state, len) = self.active_list(); if len == 0 { return; } @@ -612,13 +771,47 @@ impl App { }); self.detail = Some(detail); } - Err(e) => self.status = format!("error loading detail: {e}"), + Err(e) => self.set_status(format!("error loading detail: {e}")), } } pub fn close_detail(&mut self) { self.detail = None; self.episode_state.select(None); + if matches!(self.focus, Focus::Candidates) { + self.close_candidates(); + } + } + + pub fn start_library_filter(&mut self) { + if self.detail.is_some() { + return; + } + self.focus = Focus::LibraryFilterInput; + } + + pub fn confirm_library_filter(&mut self) { + self.focus = Focus::List; + } + + pub fn clear_library_filter(&mut self) { + self.library_query.clear(); + self.focus = Focus::List; + self.recompute_library_view(); + } + + /// Advances the episode selection to the next monitored episode that + /// still has no file, wrapping so a second `n` at the end of the list + /// starts over rather than doing nothing. + pub fn select_next_missing_episode(&mut self) { + let Some(detail) = &self.detail else { + return; + }; + let start = self.episode_state.selected().map(|i| i + 1).unwrap_or(0); + match next_missing_index(&detail.episodes, start) { + Some(i) => self.episode_state.select(Some(i)), + None => self.set_status("no missing monitored episodes"), + } } /// Toggles monitored on whichever episode is currently selected in the @@ -641,14 +834,14 @@ impl App { }; match result { Ok(()) => { - self.status = "episode monitor state updated".to_string(); + self.set_status("episode monitor state updated".to_string()); if let Some(id) = self.selected_media_id() { if let Ok(fresh) = self.client.media_detail(id).await { self.detail = Some(fresh); } } } - Err(e) => self.status = format!("episode monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("episode monitor toggle failed: {e}")), } } @@ -677,12 +870,12 @@ impl App { }; match result { Ok(()) => { - self.status = format!("season {season_number} monitor state updated"); + self.set_status(format!("season {season_number} monitor state updated")); if let Ok(fresh) = self.client.media_detail(media_item_id).await { self.detail = Some(fresh); } } - Err(e) => self.status = format!("season monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("season monitor toggle failed: {e}")), } } @@ -694,10 +887,12 @@ impl App { return; }; match self.client.approve_review(item.id).await { - Ok(()) => self.status = format!("approved: {}", item.raw_release_title), - Err(e) => self.status = format!("approve failed: {e}"), + Ok(()) => self.set_status(format!("approved: {}", item.raw_release_title)), + Err(e) => self.set_status(format!("approve failed: {e}")), } self.review_items = self.client.review_queue().await.unwrap_or_default(); + self.review_count = self.review_items.len(); + clamp_list_state(&mut self.review_state, self.review_items.len()); } pub async fn reject_selected_review(&mut self) { @@ -708,10 +903,12 @@ impl App { return; }; match self.client.reject_review(item.id).await { - Ok(()) => self.status = format!("rejected: {}", item.raw_release_title), - Err(e) => self.status = format!("reject failed: {e}"), + Ok(()) => self.set_status(format!("rejected: {}", item.raw_release_title)), + Err(e) => self.set_status(format!("reject failed: {e}")), } self.review_items = self.client.review_queue().await.unwrap_or_default(); + self.review_count = self.review_items.len(); + clamp_list_state(&mut self.review_state, self.review_items.len()); } /// Searches series and movies together instead of requiring the user to @@ -722,41 +919,36 @@ impl App { /// a movie search returned nothing because the mode had never actually /// switched). Querying both up front removes the failure mode entirely. pub async fn run_add_search(&mut self) { - if self.add_query.trim().is_empty() { + if self.add_query.trim().is_empty() || self.busy { return; } - let (series_result, movie_result) = tokio::join!( - self.client.search_series(&self.add_query), - self.client.search_movies(&self.add_query) - ); + self.set_status("searching movies and TV..."); + self.busy = true; + let client = self.client.clone(); + let query = self.add_query.clone(); + self.background = Some(tokio::spawn(async move { + let (series_result, movie_result) = + tokio::join!(client.search_series(&query), client.search_movies(&query)); - let mut results = Vec::new(); - let mut errors = Vec::new(); - match series_result { - Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { - kind: AddKind::Series, - result, - })), - Err(e) => errors.push(format!("series search failed: {e}")), - } - match movie_result { - Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { - kind: AddKind::Movie, - result, - })), - Err(e) => errors.push(format!("movie search failed: {e}")), - } - results.sort_by_key(|a| a.result.title.to_lowercase()); - - self.add_results_state - .select(if results.is_empty() { None } else { Some(0) }); - self.add_results = results; - self.status = if errors.is_empty() { - String::new() - } else { - errors.join("; ") - }; - self.focus = Focus::AddResults; + let mut results = Vec::new(); + let mut errors = Vec::new(); + match series_result { + Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { + kind: AddKind::Series, + result, + })), + Err(e) => errors.push(format!("series search failed: {e}")), + } + match movie_result { + Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { + kind: AddKind::Movie, + result, + })), + Err(e) => errors.push(format!("movie search failed: {e}")), + } + results.sort_by_key(|a| a.result.title.to_lowercase()); + BackgroundOutcome::AddSearch { results, errors } + })); } pub async fn add_selected_search_result(&mut self, roots: &LibraryRoots) { @@ -789,12 +981,15 @@ impl App { }; match outcome { Ok(media_item_id) => { - self.status = format!("added {:?} (media_item_id={media_item_id})", result.title); + self.set_status(format!( + "added {:?} (media_item_id={media_item_id})", + result.title + )); self.add_results.clear(); self.add_query.clear(); self.focus = Focus::AddSearchInput; } - Err(e) => self.status = format!("add failed: {e}"), + Err(e) => self.set_status(format!("add failed: {e}")), } } @@ -809,7 +1004,7 @@ impl App { if self.busy { return; } - self.status = "searching now...".to_string(); + self.set_status("searching now...".to_string()); self.busy = true; let client = self.client.clone(); self.background = Some(tokio::spawn(async move { @@ -841,7 +1036,7 @@ impl App { return; } - self.status = "fetching candidates...".to_string(); + self.set_status("fetching candidates...".to_string()); self.busy = true; let client = self.client.clone(); self.background = Some(tokio::spawn(async move { @@ -879,13 +1074,13 @@ impl App { }; match result { Ok(()) => { - self.status = format!("grabbed: {}", candidate.raw_title); + self.set_status(format!("grabbed: {}", candidate.raw_title)); self.close_candidates(); if let Ok(fresh) = self.client.media_detail(media_item_id).await { self.detail = Some(fresh); } } - Err(e) => self.status = format!("grab failed: {e}"), + Err(e) => self.set_status(format!("grab failed: {e}")), } } @@ -955,7 +1150,10 @@ impl App { let value: f32 = match self.weight_input_buffer.trim().parse() { Ok(v) => v, Err(_) => { - self.status = format!("'{}' is not a valid number", self.weight_input_buffer); + self.set_status(format!( + "'{}' is not a valid number", + self.weight_input_buffer + )); return; } }; @@ -972,11 +1170,11 @@ impl App { .await { Ok(()) => { - self.status = format!("{name} updated to {value}"); + self.set_status(format!("{name} updated to {value}")); self.weight_input_buffer.clear(); self.focus = Focus::List; } - Err(e) => self.status = format!("weight update failed: {e}"), + Err(e) => self.set_status(format!("weight update failed: {e}")), } } @@ -994,11 +1192,11 @@ impl App { }; match result { Ok(()) => { - self.status = "monitor state updated".to_string(); + self.set_status("monitor state updated".to_string()); self.media_items = self.client.list_media().await.unwrap_or_default(); self.recompute_library_view(); } - Err(e) => self.status = format!("monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("monitor toggle failed: {e}")), } } @@ -1014,12 +1212,12 @@ impl App { }; match result { Ok(()) => { - self.status = "monitor state updated".to_string(); + self.set_status("monitor state updated".to_string()); if let Ok(fresh) = self.client.media_detail(id).await { self.detail = Some(fresh); } } - Err(e) => self.status = format!("monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("monitor toggle failed: {e}")), } } @@ -1031,18 +1229,18 @@ impl App { }; if !self.confirm_delete { self.confirm_delete = true; - self.status = "press x again to confirm delete".to_string(); + self.set_status("press x again to confirm delete".to_string()); return; } self.confirm_delete = false; match self.client.delete_media(id).await { Ok(()) => { - self.status = "deleted".to_string(); + self.set_status("deleted".to_string()); self.detail = None; self.media_items = self.client.list_media().await.unwrap_or_default(); self.recompute_library_view(); } - Err(e) => self.status = format!("delete failed: {e}"), + Err(e) => self.set_status(format!("delete failed: {e}")), } } @@ -1057,7 +1255,7 @@ impl App { }; if !self.confirm_delete_file { self.confirm_delete_file = true; - self.status = "press d again to confirm deleting this file".to_string(); + self.set_status("press d again to confirm deleting this file".to_string()); return; } self.confirm_delete_file = false; @@ -1075,14 +1273,14 @@ impl App { }; match result { Ok(()) => { - self.status = "file deleted, will be re-searched".to_string(); + self.set_status("file deleted, will be re-searched".to_string()); if let Some(id) = self.selected_media_id() { if let Ok(fresh) = self.client.media_detail(id).await { self.detail = Some(fresh); } } } - Err(e) => self.status = format!("file delete failed: {e}"), + Err(e) => self.set_status(format!("file delete failed: {e}")), } } @@ -1118,14 +1316,51 @@ impl App { self.detail = Some(detail); self.focus = Focus::List; } - Err(e) => self.status = format!("error loading detail: {e}"), + Err(e) => self.set_status(format!("error loading detail: {e}")), + } + } + + /// Jumps from the selected Calendar row to that episode's Library + /// detail — same idea as `jump_to_stuck_target`, but also lands on the + /// matching SxxExx so `c`/`d` apply to the aired episode, not S01E01. + pub async fn jump_to_calendar_entry(&mut self) { + let Some(idx) = self.calendar_state.selected() else { + return; + }; + let Some(entry) = self.calendar.get(idx).cloned() else { + return; + }; + match self.client.media_detail(entry.media_item_id).await { + Ok(detail) => { + let ep = detail.episodes.iter().position(|e| { + e.season_number == entry.season_number + && e.episode_number == entry.episode_number + }); + self.tab = Tab::Library; + self.episode_state + .select(ep.or(if detail.episodes.is_empty() { + None + } else { + Some(0) + })); + self.detail = Some(detail); + self.focus = Focus::List; + } + Err(e) => self.set_status(format!("error loading detail: {e}")), } } } #[cfg(test)] mod tests { - use super::item_root_folder; + use super::{ + clamp_list_state, health_row_count, item_root_folder, next_missing_index, + title_matches_query, + }; + use breadarr_shared::dto::{ + DuplicateGroup, EpisodeSummary, FlaggedFile, LibraryHealthReport, LibrarySummary, + }; + use ratatui::widgets::ListState; #[test] fn item_root_folder_replaces_dot_and_dotdot() { @@ -1141,4 +1376,99 @@ mod tests { "/lib/Foo_ Bar_Baz (2020)" ); } + + #[test] + fn title_matches_query_is_case_insensitive_substring() { + assert!(title_matches_query("The Matrix", "matrix")); + assert!(title_matches_query("The Matrix", "")); + assert!(!title_matches_query("The Matrix", "inception")); + } + + fn ep(season: i64, number: i64, monitored: bool, has_file: bool) -> EpisodeSummary { + EpisodeSummary { + id: season * 100 + number, + season_number: season, + episode_number: number, + title: None, + air_date: None, + monitored, + has_file, + } + } + + #[test] + fn next_missing_index_wraps_and_skips_owned_or_unmonitored() { + let episodes = vec![ + ep(1, 1, true, true), + ep(1, 2, true, false), + ep(1, 3, false, false), + ep(1, 4, true, false), + ]; + assert_eq!(next_missing_index(&episodes, 0), Some(1)); + assert_eq!(next_missing_index(&episodes, 2), Some(3)); + assert_eq!(next_missing_index(&episodes, 4), Some(1)); + assert_eq!(next_missing_index(&[], 0), None); + assert_eq!( + next_missing_index(&[ep(1, 1, true, true), ep(1, 2, false, false)], 0), + None + ); + } + + #[test] + fn clamp_list_state_pins_past_the_end_and_clears_empty() { + let mut state = ListState::default(); + state.select(Some(4)); + clamp_list_state(&mut state, 3); + assert_eq!(state.selected(), Some(2)); + clamp_list_state(&mut state, 0); + assert_eq!(state.selected(), None); + clamp_list_state(&mut state, 2); + assert_eq!(state.selected(), Some(0)); + } + + fn empty_health() -> LibraryHealthReport { + LibraryHealthReport { + corrupt_files: vec![], + under_quality_files: vec![], + no_subtitle_files: vec![], + no_english_audio_files: vec![], + non_english_default_audio_files: vec![], + duplicate_groups: vec![], + summary: LibrarySummary { + total_files: 0, + total_size_bytes: 0, + probed_files: 0, + by_video_codec: vec![], + sd_count: 0, + hd_720p_count: 0, + full_hd_1080p_count: 0, + uhd_4k_count: 0, + pct_with_subtitles: 0.0, + }, + } + } + + fn flagged(title: &str) -> FlaggedFile { + FlaggedFile { + episode_file_id: 1, + media_title: title.to_string(), + episode_label: None, + path: "/x".to_string(), + } + } + + #[test] + fn health_row_count_includes_headers_and_empty_placeholder() { + let mut report = empty_health(); + assert_eq!(health_row_count(&report), 1); + report.corrupt_files.push(flagged("A")); + report.corrupt_files.push(flagged("B")); + report.duplicate_groups.push(DuplicateGroup { + media_title: "C".into(), + episode_label: None, + paths: vec!["/a".into(), "/b".into()], + }); + // header + 2 files + header + 1 group + assert_eq!(health_row_count(&report), 5); + } } diff --git a/breadarr-tui/src/main.rs b/breadarr-tui/src/main.rs index f4135e3..d8af4b0 100644 --- a/breadarr-tui/src/main.rs +++ b/breadarr-tui/src/main.rs @@ -6,7 +6,7 @@ use std::time::Duration; use anyhow::Result; use breadarr_shared::{Config, DaemonClient}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -51,6 +51,7 @@ async fn run( loop { app.poll_background().await; + app.expire_status(); if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh { app.refresh_active_tab().await; @@ -65,7 +66,7 @@ async fn run( if key.kind != KeyEventKind::Press { continue; } - handle_key(app, key.code, roots).await; + handle_key(app, key, roots).await; if app.should_quit { return Ok(()); } @@ -74,7 +75,9 @@ async fn run( } } -async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { +async fn handle_key(app: &mut App, key: KeyEvent, roots: &LibraryRoots) { + let code = key.code; + // Typing into the add-show search box takes priority over global keys. if matches!(app.tab, Tab::Add) && matches!(app.focus, Focus::AddSearchInput) { match code { @@ -86,7 +89,28 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { KeyCode::Esc => { app.add_query.clear(); } - KeyCode::Tab => cycle_tab(app), + KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)), + KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)), + _ => {} + } + return; + } + + // Incremental Library title filter — same input-mode isolation as Add. + if matches!(app.tab, Tab::Library) && matches!(app.focus, Focus::LibraryFilterInput) { + match code { + KeyCode::Enter => app.confirm_library_filter(), + KeyCode::Char(c) => { + app.library_query.push(c); + app.recompute_library_view(); + } + KeyCode::Backspace => { + app.library_query.pop(); + app.recompute_library_view(); + } + KeyCode::Esc => app.clear_library_filter(), + KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)), + KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)), _ => {} } return; @@ -131,12 +155,25 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { match code { KeyCode::Char('q') => app.should_quit = true, - KeyCode::Tab => cycle_tab(app), + KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)), + KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)), + KeyCode::Char(c) if c.is_ascii_digit() => { + if let Some(n) = c.to_digit(10) { + if (1..=Tab::ALL.len() as u32).contains(&n) { + switch_tab(app, Tab::ALL[(n as usize) - 1]); + } + } + } KeyCode::Char('j') | KeyCode::Down => app.move_selection(1), KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1), + KeyCode::Char('g') => app.select_edge(false), + KeyCode::Char('G') => app.select_edge(true), + KeyCode::PageDown => app.move_selection(10), + KeyCode::PageUp => app.move_selection(-10), KeyCode::Esc => match app.tab { Tab::Library if matches!(app.focus, Focus::Candidates) => app.close_candidates(), Tab::Library if app.detail.is_some() => app.close_detail(), + Tab::Library if !app.library_query.is_empty() => app.clear_library_filter(), Tab::Add => app.focus = Focus::AddSearchInput, Tab::Profiles if app.profile_detail.is_some() => app.close_profile_detail(), _ => {} @@ -155,6 +192,7 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { } Tab::Profiles => app.open_profile_detail(), Tab::Stuck => app.jump_to_stuck_target().await, + Tab::Calendar => app.jump_to_calendar_entry().await, _ => {} }, KeyCode::Left | KeyCode::Right if matches!(app.tab, Tab::Stuck) => { @@ -182,6 +220,16 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { KeyCode::Char('r') if matches!(app.tab, Tab::Review) => { app.reject_selected_review().await; } + KeyCode::Char('/') if matches!(app.tab, Tab::Library) && app.detail.is_none() => { + app.start_library_filter(); + } + KeyCode::Char('n') + if matches!(app.tab, Tab::Library) + && app.detail.is_some() + && !matches!(app.focus, Focus::Candidates) => + { + app.select_next_missing_episode(); + } KeyCode::Char('s') if matches!(app.tab, Tab::Library) && app.detail.is_some() => { app.search_now_selected().await; } @@ -211,9 +259,20 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { } } -fn cycle_tab(app: &mut App) { - let idx = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0); - app.tab = Tab::ALL[(idx + 1) % Tab::ALL.len()]; +fn tab_offset(current: Tab, delta: i32) -> Tab { + let idx = Tab::ALL.iter().position(|t| *t == current).unwrap_or(0) as i32; + let len = Tab::ALL.len() as i32; + Tab::ALL[(idx + delta).rem_euclid(len) as usize] +} + +fn switch_tab(app: &mut App, tab: Tab) { + if app.tab == tab { + return; + } + if matches!(app.focus, Focus::Candidates) { + app.close_candidates(); + } + app.tab = tab; app.detail = None; app.profile_detail = None; app.profile_weight_state.select(None); diff --git a/breadarr-tui/src/ui.rs b/breadarr-tui/src/ui.rs index 5f7735c..77ea537 100644 --- a/breadarr-tui/src/ui.rs +++ b/breadarr-tui/src/ui.rs @@ -5,6 +5,7 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs}; use ratatui::Frame; use crate::app::{App, Focus, StuckSection, Tab}; +use breadarr_shared::dto::CycleInfo; // Shared color palette — kept to these meanings so a color never has to be // second-guessed at a glance: @@ -58,6 +59,7 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { Tab::Library if app.detail.is_some() => vec![ ("Esc", "back"), ("s", "search now"), + ("n", "next missing"), ("m", "monitor show"), ("e", "monitor episode"), ("S", "monitor season"), @@ -65,8 +67,12 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { ("d", "delete file"), ("c", "pick release"), ], + Tab::Library if matches!(app.focus, Focus::LibraryFilterInput) => { + vec![("Enter", "keep filter"), ("Esc", "clear filter")] + } Tab::Library => vec![ ("Enter", "open detail"), + ("/", "filter title"), ("f", "cycle filter"), ("o", "cycle sort"), ("m", "monitor toggle"), @@ -81,13 +87,17 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { } Tab::Profiles => vec![("Enter", "open profile")], Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")], + Tab::Calendar => vec![("Enter", "jump to show")], _ => vec![], } } const GLOBAL_KEYS: &[(&str, &str)] = &[ - ("Tab", "switch tab"), + ("Tab/S-Tab", "switch tab"), + ("1-8", "jump tab"), ("j/k", "move"), + ("g/G", "first/last"), + ("PgUp/PgDn", "page"), ("?", "help"), ("R", "refresh now"), ("q", "quit"), @@ -128,7 +138,7 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) { ]) })); - let popup = centered_rect(50, lines.len() as u16 + 2, area); + let popup = centered_rect(52, lines.len() as u16 + 2, area); frame.render_widget(Clear, popup); let paragraph = Paragraph::new(lines).block( Block::default() @@ -138,8 +148,47 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) { frame.render_widget(paragraph, popup); } +fn tab_label(tab: Tab, app: &App) -> String { + match tab { + Tab::Library => { + let missing: i64 = app.media_items.iter().map(|m| m.missing_count).sum(); + if missing > 0 { + format!("Library ({missing})") + } else { + tab.title().to_string() + } + } + Tab::Review => { + let n = if matches!(app.tab, Tab::Review) { + app.review_items.len() + } else { + app.review_count + }; + if n > 0 { + format!("Review ({n})") + } else { + tab.title().to_string() + } + } + Tab::Stuck => { + let n = app.stuck.as_ref().map_or(app.stuck_count, |r| { + r.stalled_grabs.len() + r.maxed_out_search_targets.len() + }); + if n > 0 { + format!("Stuck ({n})") + } else { + tab.title().to_string() + } + } + _ => tab.title().to_string(), + } +} + fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) { - let titles: Vec = Tab::ALL.iter().map(|t| Line::from(t.title())).collect(); + let titles: Vec = Tab::ALL + .iter() + .map(|t| Line::from(tab_label(*t, app))) + .collect(); let selected = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0); let (daemon_label, daemon_color) = match (&app.daemon_up, &app.health) { @@ -190,6 +239,11 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { return; } + if detail.kind == "movie" || detail.episodes.is_empty() { + draw_movie_detail(frame, area, app, detail); + return; + } + let items: Vec = detail .episodes .iter() @@ -286,9 +340,19 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { ])) }) .collect(); + let search = if app.library_query.is_empty() { + String::new() + } else { + format!(" search: {}", app.library_query) + }; + let filter_caret = if matches!(app.focus, Focus::LibraryFilterInput) { + "▋" + } else { + "" + }; let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(format!( - "Monitored Shows ({}/{}) — filter: {} sort: {}", + "Library ({}/{}) — filter: {} sort: {}{search}{filter_caret}", app.library_view.len(), app.media_items.len(), app.library_filter.label(), @@ -299,6 +363,61 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { frame.render_stateful_widget(list, area, &mut state); } +fn draw_movie_detail( + frame: &mut Frame, + area: Rect, + app: &App, + detail: &breadarr_shared::dto::MediaItemDetail, +) { + let confirm = if app.confirm_delete { + " — x AGAIN TO DELETE" + } else if app.confirm_delete_file { + " — d AGAIN TO DELETE FILE" + } else { + "" + }; + let monitor_label = if detail.monitored { + "monitored" + } else { + "unmonitored" + }; + let have = app + .media_items + .iter() + .find(|m| m.id == detail.id) + .map(|m| m.missing_count == 0); + let file_line = match have { + Some(true) => ("file on disk", Color::Green), + Some(false) => ("missing", Color::Yellow), + None => ("file status unknown", Color::DarkGray), + }; + let kind = if detail.kind == "movie" { + "Movie" + } else { + "Series" + }; + let year = detail + .year + .map(|y| y.to_string()) + .unwrap_or_else(|| "—".into()); + let lines = vec![ + Line::from(Span::styled( + format!("{} ({year})", detail.title), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(format!("{kind} · {monitor_label}")), + Line::from(Span::styled(file_line.0, Style::default().fg(file_line.1))), + Line::from(format!("root: {}", detail.root_folder)), + Line::from(""), + Line::from("s search now c pick release m monitor d delete file x remove"), + ]; + let paragraph = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title( + format!("{} ({year}) [{monitor_label}]{confirm}", detail.title), + )); + frame.render_widget(paragraph, area); +} + /// Manual release picker — candidates for whatever episode/movie was /// selected when `c` was pressed, scored (or gate-rejected with a reason) /// exactly like the automatic search pipeline would see them. @@ -330,15 +449,19 @@ fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str) if c.is_season_pack { " [PACK]" } else { "" }, if c.is_repack { " [REPACK]" } else { "" }, ); - let verdict = match (c.score, &c.rejected_reason) { - (Some(score), _) => format!("score {score:.1}"), - (None, Some(reason)) => format!("REJECTED: {reason}"), - (None, None) => "unscored".to_string(), + let (verdict, color) = match (c.score, &c.rejected_reason) { + (Some(score), _) if score >= 8.0 => (format!("score {score:.1}"), Color::Green), + (Some(score), _) => (format!("score {score:.1}"), Color::Yellow), + (None, Some(reason)) => (format!("REJECTED: {reason}"), Color::Red), + (None, None) => ("unscored".to_string(), Color::DarkGray), }; - ListItem::new(format!( - "[{}] {} — {seeders} seeders, {size}{flags} — {verdict}", - c.source_name, c.raw_title - )) + ListItem::new(Line::from(vec![ + Span::raw(format!( + "[{}] {} — {seeders} seeders, {size}{flags} — ", + c.source_name, c.raw_title + )), + Span::styled(verdict, Style::default().fg(color)), + ])) }) .collect(); let list = List::new(items) @@ -350,18 +473,32 @@ fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str) frame.render_stateful_widget(list, area, &mut state); } +fn history_status_color(status: &str) -> Color { + match status { + "imported" => Color::Green, + "grabbed" => Color::Yellow, + "failed" => Color::Red, + _ => Color::DarkGray, + } +} + fn draw_history(frame: &mut Frame, area: Rect, app: &App) { let items: Vec = app .releases .iter() .map(|r| { - ListItem::new(format!( - "[{}] {} — {} (score {:.1})", - r.status, - r.media_title, - r.raw_title, - r.score.unwrap_or(0.0) - )) + ListItem::new(Line::from(vec![ + Span::styled( + format!("[{}]", r.status), + Style::default().fg(history_status_color(&r.status)), + ), + Span::raw(format!( + " {} — {} (score {:.1})", + r.media_title, + r.raw_title, + r.score.unwrap_or(0.0) + )), + ])) }) .collect(); let list = List::new(items) @@ -384,7 +521,10 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) { Color::Green }; ListItem::new(Line::from(vec![ - Span::styled(format!("({:.0}%)", r.confidence * 100.0), Style::default().fg(color)), + Span::styled( + format!("({:.0}%)", r.confidence * 100.0), + Style::default().fg(color), + ), Span::raw(format!( " {} -> {}", r.raw_release_title, @@ -439,7 +579,10 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) { .iter() .map(|g| { ListItem::new(Line::from(Span::styled( - format!("{} — {} (grabbed {})", g.media_title, g.raw_title, g.grabbed_at), + format!( + "{} — {} (grabbed {})", + g.media_title, g.raw_title, g.grabbed_at + ), Style::default().fg(Color::Yellow), ))) }) @@ -519,12 +662,21 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) { .add_results .iter() .map(|r| { - ListItem::new(format!( - "[{}] {} ({})", - r.kind.label(), - r.result.title, - r.result.year.map(|y| y.to_string()).unwrap_or_default() - )) + let kind_color = match r.kind { + crate::app::AddKind::Movie => Color::Magenta, + crate::app::AddKind::Series => Color::Blue, + }; + ListItem::new(Line::from(vec![ + Span::styled( + format!("[{}] ", r.kind.label()), + Style::default().fg(kind_color), + ), + Span::raw(format!( + "{} ({})", + r.result.title, + r.result.year.map(|y| y.to_string()).unwrap_or_default() + )), + ])) }) .collect(); let list = List::new(items) @@ -539,36 +691,43 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) { } /// What's aired recently or airs soon (a week back, three weeks forward — -/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Read-only, no -/// selection — a lookahead view, not something acted on directly here. +/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Selectable — +/// Enter jumps to that episode in Library. fn draw_calendar(frame: &mut Frame, area: Rect, app: &App) { let today = chrono::Local::now().date_naive().to_string(); let items: Vec = app .calendar .iter() .map(|e| { - let status = if e.has_file { - "✓" + let (status, color) = if e.has_file { + ("✓", Color::Green) } else if !e.monitored { - "-" + ("-", Color::DarkGray) } else if e.air_date.as_str() > today.as_str() { - "…" + ("…", Color::Yellow) } else { - "!" // aired, monitored, still missing + ("!", Color::Red) }; + let today_mark = if e.air_date == today { " today" } else { "" }; let title = e.title.as_deref().unwrap_or(""); - ListItem::new(format!( - "{status} {} {} S{:02}E{:02} {title}", - e.air_date, e.media_title, e.season_number, e.episode_number - )) + ListItem::new(Line::from(vec![ + Span::styled(status, Style::default().fg(color)), + Span::raw(format!( + " {} {} S{:02}E{:02} {title}{today_mark}", + e.air_date, e.media_title, e.season_number, e.episode_number + )), + ])) }) .collect(); - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .title("Calendar — ✓ have it … upcoming ! aired but missing"), - ); - frame.render_widget(list, area); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Calendar — Enter: jump ✓ have it … upcoming ! aired but missing"), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.calendar_state.clone(); + frame.render_stateful_widget(list, area, &mut state); } /// Read-only report on `media_file_probe` state: corruption, under-quality, @@ -588,7 +747,7 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(5), Constraint::Min(3)]) + .constraints([Constraint::Length(6), Constraint::Min(3)]) .split(area); let s = &report.summary; @@ -598,8 +757,13 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) { .map(|c| format!("{}={}", c.codec, c.count)) .collect::>() .join(", "); + let cycles = app + .health + .as_ref() + .map(cycle_summary_line) + .unwrap_or_default(); let summary_text = format!( - "{} files, {:.1} GB total, {} probed — resolution: SD={} 720p={} 1080p={} 4K={} \ + "{cycles}\n{} files, {:.1} GB total, {} probed — resolution: SD={} 720p={} 1080p={} 4K={} \ — subtitles: {:.0}% — codecs: {codec_summary}", s.total_files, s.total_size_bytes as f64 / 1_073_741_824.0, @@ -668,12 +832,38 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) { items.push(ListItem::new("Nothing flagged — library looks clean.")); } - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .title("Flagged files"), - ); - frame.render_widget(list, chunks[1]); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Flagged files"), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.health_state.clone(); + frame.render_stateful_widget(list, chunks[1], &mut state); +} + +fn cycle_bit(name: &str, info: &Option) -> String { + match info { + Some(c) if c.ok => format!("{name}: ok"), + Some(_) => format!("{name}: FAIL"), + None => format!("{name}: —"), + } +} + +fn cycle_summary_line(h: &breadarr_shared::dto::HealthDetail) -> String { + format!( + "{} | {} | {} | {}{}", + cycle_bit("grab", &h.last_grab_cycle), + cycle_bit("import", &h.last_import_cycle), + cycle_bit("search", &h.last_search_cycle), + cycle_bit("upgrade", &h.last_upgrade_cycle), + if h.search_halted { + " | search HALTED" + } else { + "" + } + ) } /// Quality-profile weight editing — list of profiles, then (once one is diff --git a/breadarrd/src/main.rs b/breadarrd/src/main.rs index a34cb89..2154730 100644 --- a/breadarrd/src/main.rs +++ b/breadarrd/src/main.rs @@ -355,6 +355,26 @@ 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 @@ -377,6 +397,21 @@ 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, @@ -501,9 +536,7 @@ async fn background_loop( let conn = conn.lock().await; scheduler::run_search_cycle( &conn, - &tpb_source, 3, - &scrape_source, 2, - &nyaa_source, 1, + &search_sources, &mut title_matcher, &qbit, &config.qbit.category, config.sources.search_budget_per_cycle, @@ -562,9 +595,7 @@ async fn background_loop( let conn = conn.lock().await; scheduler::run_upgrade_cycle( &conn, - &tpb_source, 3, - &scrape_source, 2, - &nyaa_source, 1, + &search_sources, &mut title_matcher, &qbit, &config.qbit.category, config.sources.upgrade_budget_per_cycle, @@ -679,9 +710,7 @@ async fn background_loop( Ok(targets) => scheduler::execute_search_targets( &conn, &targets, - &tpb_source, 3, - &scrape_source, 2, - &nyaa_source, 1, + &search_sources, &mut title_matcher, &qbit, &config.qbit.category, ).await, @@ -705,9 +734,7 @@ async fn background_loop( &conn, media_item_id, episode_id, - &tpb_source, 3, - &scrape_source, 2, - &nyaa_source, 1, + &search_sources, ).await }; let _ = reply.send(result); @@ -1133,6 +1160,22 @@ 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() { @@ -1144,6 +1187,21 @@ 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()); @@ -1151,12 +1209,7 @@ async fn debug_search_show(config: &Config, title: &str) -> Result<()> { let stats = scheduler::execute_search_targets( &conn, &targets, - &tpb_source, - 3, - &scrape_source, - 2, - &nyaa_source, - 1, + &search_sources, &mut title_matcher, &qbit, &config.qbit.category, diff --git a/breadarrd/src/scheduler.rs b/breadarrd/src/scheduler.rs index 9ec61d7..90b7d96 100644 --- a/breadarrd/src/scheduler.rs +++ b/breadarrd/src/scheduler.rs @@ -652,7 +652,8 @@ 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)?, @@ -911,8 +912,8 @@ pub async fn run_grab_cycle( Ok(stats) } -// --- Search-driven acquisition (1337x for general TV/movies, nyaa search -// for anime movies) --- +// --- Search-driven acquisition (TPB + YTS/csv/1337x fallbacks 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*, @@ -921,18 +922,12 @@ 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. -/// 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. +/// 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. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum SearchRoute { /// Primary general-content (movies + non-anime TV) route — a JSON API, @@ -947,6 +942,22 @@ 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, @@ -1565,35 +1576,16 @@ 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, - 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, + sources: &SearchSources<'_>, matcher: &mut TitleMatcher, qbit: &QbitClient, qbit_category: &str, budget: usize, ) -> Result { let targets = enumerate_search_targets(conn, budget)?; - execute_search_targets( - conn, - &targets, - tpb, - tpb_source_id, - scrape, - scrape_source_id, - nyaa_search, - nyaa_source_id, - matcher, - qbit, - qbit_category, - ) - .await + execute_search_targets(conn, &targets, sources, matcher, qbit, qbit_category).await } /// Upgrade-search counterpart to `run_search_cycle`: same execution engine @@ -1601,15 +1593,9 @@ 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, - 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, + sources: &SearchSources<'_>, matcher: &mut TitleMatcher, qbit: &QbitClient, qbit_category: &str, @@ -1617,20 +1603,7 @@ pub async fn run_upgrade_cycle( min_gain: f32, ) -> Result { let targets = enumerate_upgrade_targets(conn, budget, min_gain)?; - execute_search_targets( - conn, - &targets, - tpb, - tpb_source_id, - scrape, - scrape_source_id, - nyaa_search, - nyaa_source_id, - matcher, - qbit, - qbit_category, - ) - .await + execute_search_targets(conn, &targets, sources, matcher, qbit, qbit_category).await } /// Every currently-missing episode/movie for one specific `media_item`, @@ -1728,16 +1701,10 @@ pub fn find_media_item_id_by_title(conn: &Connection, title: &str) -> Result, matcher: &mut TitleMatcher, qbit: &QbitClient, qbit_category: &str, @@ -1753,8 +1720,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→1337x fallback can mean - // two different targets with the same query string were served by two + // which source actually answered too (the TPB → YTS/csv/1337x chain + // can mean two targets with the same query 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< @@ -1763,12 +1730,6 @@ 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) { @@ -1779,34 +1740,11 @@ pub async fn execute_search_targets( tokio::time::sleep(std::time::Duration::from_secs(jitter_secs)).await; } - 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) => { + match fetch_for_target(target, sources).await { + Ok(pair) => { consecutive_fetch_errors = 0; - query_cache.insert(cache_key, (items.clone(), result_source_id)); - (items, result_source_id) + query_cache.insert(cache_key, pair.clone()); + pair } Err(e) => { consecutive_fetch_errors += 1; @@ -1937,14 +1875,96 @@ pub async fn execute_search_targets( Ok(stats) } -fn source_route_name(route: SearchRoute) -> &'static str { - match route { - SearchRoute::Tpb => "tpb", - SearchRoute::X1337 => "1337x", - SearchRoute::NyaaSearch => "nyaa", +fn source_name_for_id(id: i64) -> &'static str { + match id { + 1 => "nyaa", + 2 => "1337x", + 3 => "tpb", + 4 => "torrents-csv", + 5 => "yts", + _ => "unknown", } } +/// 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 @@ -1954,29 +1974,18 @@ fn source_route_name(route: SearchRoute) -> &'static str { /// 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, - 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, + sources: &SearchSources<'_>, ) -> 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 (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 (items, source_id) = fetch_for_target(&target, sources).await?; let media_item = get_media_item(conn, media_item_id)?; let anime = match media_item.tvdb_id { @@ -2029,7 +2038,7 @@ pub async fn fetch_candidates( link: item.link.clone(), guid: item.guid.clone(), source_id, - source_name: source_route_name(target.route).to_string(), + source_name: source_name_for_id(source_id).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 646d82f..f4a32e1 100644 --- a/breadarrd/src/sources/mod.rs +++ b/breadarrd/src/sources/mod.rs @@ -1,6 +1,8 @@ pub mod rss; pub mod scrape; +pub mod torrents_csv; pub mod tpb; +pub mod yts; use anyhow::Result; use async_trait::async_trait; @@ -25,6 +27,39 @@ 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 new file mode 100644 index 0000000..ea9cb06 --- /dev/null +++ b/breadarrd/src/sources/torrents_csv.rs @@ -0,0 +1,130 @@ +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 e72d01f..6db4f35 100644 --- a/breadarrd/src/sources/tpb.rs +++ b/breadarrd/src/sources/tpb.rs @@ -2,18 +2,7 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use serde::Deserialize; -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'))) -} +use super::{build_magnet, is_valid_info_hash, urlencode, RawReleaseItem, ReleaseSource}; /// 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 @@ -38,23 +27,6 @@ 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 { @@ -154,7 +126,9 @@ 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 } @@ -167,7 +141,11 @@ 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 new file mode 100644 index 0000000..a29bf9c --- /dev/null +++ b/breadarrd/src/sources/yts.rs @@ -0,0 +1,194 @@ +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 6643c46..0c64aaa 100644 --- a/config.example.toml +++ b/config.example.toml @@ -72,6 +72,12 @@ 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 @@ -96,6 +102,7 @@ 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",