Fix review-queue dead ends and harden grab/import/API paths
Some checks failed
check / check (push) Failing after 14m5s
dev release / build (push) Successful in 3m54s

Stop upgrade-search from queuing mid-confidence matches that approve
cannot honor (owned movies/episodes 409'd on the TUI). De-dupe pending
review rows, scope 1080p gates to the target episode, refuse unsafe
pack cleanup, and require a token for non-loopback binds.
This commit is contained in:
Breadway 2026-08-16 00:44:58 +08:00
parent 4a2adbc24d
commit 7ab28d30a7
31 changed files with 2536 additions and 328 deletions

View file

@ -1,8 +1,8 @@
use anyhow::Result;
use breadarr_shared::dto::{
CalendarEntry, HealthDetail, LibraryHealthReport, MediaItemDetail, MediaItemSummary,
QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchResult,
StuckReport, WeightsDto,
QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchNowResult,
SearchResult, StuckReport, WeightsDto,
};
use breadarr_shared::DaemonClient;
use ratatui::widgets::ListState;
@ -30,6 +30,13 @@ fn item_root_folder(root: &str, title: &str, year: Option<i64>) -> String {
.chars()
.map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c })
.collect();
// `.` / `..` survive the character class above and would make
// `root.join(...)` walk out of the library root.
let sanitized = if sanitized == "." || sanitized == ".." {
"_".to_string()
} else {
sanitized
};
let folder_name = match year {
Some(y) => format!("{sanitized} ({y})"),
None => sanitized,
@ -230,6 +237,16 @@ pub struct AddResult {
pub result: SearchResult,
}
/// Result of a long `DaemonClient` call spawned off the draw loop so
/// `search_now` / candidate fetch (up to 600s) cannot freeze key handling.
enum BackgroundOutcome {
SearchNow(Result<SearchNowResult>),
Candidates {
episode_id: Option<i64>,
result: Result<Vec<ReleaseCandidate>>,
},
}
pub struct App {
pub client: DaemonClient,
pub daemon_up: bool,
@ -306,6 +323,10 @@ pub struct App {
pub profile_weight_state: ListState,
/// In-progress digits while `Focus::WeightInput` is active.
pub weight_input_buffer: String,
/// True while a long search-now / candidate-fetch task is in flight.
pub busy: bool,
background: Option<tokio::task::JoinHandle<BackgroundOutcome>>,
}
impl App {
@ -350,6 +371,52 @@ impl App {
profile_detail: None,
profile_weight_state: ListState::default(),
weight_input_buffer: String::new(),
busy: false,
background: None,
}
}
/// 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.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}"),
}
}
@ -733,22 +800,21 @@ impl App {
/// Manual "search now" for the show/movie currently open in the Library
/// detail view — can take a while (jittered, one request per missing
/// item), so the status line makes that explicit rather than looking
/// like the UI hung.
/// item), so this is spawned off the draw loop and the status line
/// shows that work is in flight. A second press while busy is ignored.
pub async fn search_now_selected(&mut self) {
let Some(id) = self.detail.as_ref().map(|d| d.id) else {
return;
};
self.status = "searching now (this can take a while)...".to_string();
match self.client.search_now(id).await {
Ok(stats) => {
self.status = format!(
"search complete: {} target(s), {} grabbed, {} error(s)",
stats.targets, stats.grabbed, stats.errors
);
}
Err(e) => self.status = format!("search failed: {e}"),
if self.busy {
return;
}
self.status = "searching now...".to_string();
self.busy = true;
let client = self.client.clone();
self.background = Some(tokio::spawn(async move {
BackgroundOutcome::SearchNow(client.search_now(id).await)
}));
}
/// Fetches candidates for whatever's selected in the open detail view —
@ -771,23 +837,20 @@ impl App {
Some(episode.id)
};
let media_item_id = detail.id;
self.status = "fetching candidates (this can take a while)...".to_string();
let result = match episode_id {
Some(id) => self.client.episode_candidates(id).await,
None => self.client.movie_candidates(media_item_id).await,
};
match result {
Ok(candidates) => {
self.status = format!("{} candidate(s) found", candidates.len());
self.candidates_state
.select(if candidates.is_empty() { None } else { Some(0) });
self.candidates = candidates;
self.candidates_episode_id = episode_id;
self.focus = Focus::Candidates;
}
Err(e) => self.status = format!("candidate fetch failed: {e}"),
if self.busy {
return;
}
self.status = "fetching candidates...".to_string();
self.busy = true;
let client = self.client.clone();
self.background = Some(tokio::spawn(async move {
let result = match episode_id {
Some(id) => client.episode_candidates(id).await,
None => client.movie_candidates(media_item_id).await,
};
BackgroundOutcome::Candidates { episode_id, result }
}));
}
/// Grabs whichever candidate is currently selected in the picker
@ -1059,3 +1122,23 @@ impl App {
}
}
}
#[cfg(test)]
mod tests {
use super::item_root_folder;
#[test]
fn item_root_folder_replaces_dot_and_dotdot() {
assert_eq!(item_root_folder("/lib", "..", None), "/lib/_");
assert_eq!(item_root_folder("/lib", ".", None), "/lib/_");
assert_eq!(item_root_folder("/lib", "..", Some(2020)), "/lib/_ (2020)");
}
#[test]
fn item_root_folder_sanitizes_hostile_characters() {
assert_eq!(
item_root_folder("/lib", "Foo: Bar/Baz", Some(2020)),
"/lib/Foo_ Bar_Baz (2020)"
);
}
}

View file

@ -20,7 +20,7 @@ use app::{App, Focus, LibraryRoots, StuckSection, Tab};
async fn main() -> Result<()> {
let config = Config::load()?;
let base_url = format!("http://{}", config.daemon.listen_addr);
let client = DaemonClient::new(base_url, &config.daemon.api_token);
let client = DaemonClient::new(base_url, &config.daemon.api_token)?;
let roots = LibraryRoots {
series: config.default_root_folder().to_string_lossy().to_string(),
movies: config.movies_root_folder().to_string_lossy().to_string(),
@ -50,6 +50,8 @@ async fn run(
let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10);
loop {
app.poll_background().await;
if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh {
app.refresh_active_tab().await;
app.force_refresh = false;
@ -81,7 +83,9 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) {
KeyCode::Backspace => {
app.add_query.pop();
}
KeyCode::Esc => app.should_quit = true,
KeyCode::Esc => {
app.add_query.clear();
}
KeyCode::Tab => cycle_tab(app),
_ => {}
}

View file

@ -73,7 +73,7 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> {
],
Tab::Review => vec![("a", "approve"), ("r", "reject")],
Tab::Add => match app.focus {
Focus::AddSearchInput => vec![("Enter", "search")],
Focus::AddSearchInput => vec![("Enter", "search"), ("Esc", "clear")],
_ => vec![("Enter", "add"), ("Esc", "back to search")],
},
Tab::Profiles if app.profile_detail.is_some() => {
@ -745,6 +745,13 @@ fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
.collect();
format!("{} | ?: help", hints.join(" | "))
};
let status = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
let style = if app.busy {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let status = Paragraph::new(text)
.style(style)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(status, area);
}