can't be bothered writing a commit message

This commit is contained in:
Breadway 2026-07-16 22:22:53 +08:00
commit 697b009627
55 changed files with 21320 additions and 0 deletions

180
breadarrd/src/api/mod.rs Normal file
View file

@ -0,0 +1,180 @@
pub mod routes;
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use rusqlite::Connection;
use tokio::sync::Mutex;
use crate::metadata::tmdb::TmdbClient;
use crate::metadata::tvdb::TvdbClient;
use crate::qbit::QbitClient;
use crate::scheduler::SearchCycleStats;
/// A `tokio::sync::Mutex`, not `std::sync::Mutex` — several handlers (e.g.
/// review-queue approval) interleave synchronous DB calls with `.await`ed
/// qBittorrent/TVDB calls, and a sync `MutexGuard` can't be held across an
/// await point. This one can.
#[derive(Clone)]
pub struct AppState {
pub conn: Arc<Mutex<Connection>>,
pub tvdb: Option<Arc<TvdbClient>>,
pub tmdb: Option<Arc<TmdbClient>>,
pub qbit: Option<Arc<QbitClient>>,
pub qbit_category: String,
pub cycle_status: Arc<std::sync::Mutex<CycleStatus>>,
pub config: breadarr_shared::Config,
/// `None` when the background loop isn't running (qbit not
/// configured) — routes that need the search pipeline (manual
/// "search now", the candidate picker) dispatch through this rather
/// than running it inline, since that pipeline holds a `Connection`/
/// `&dyn ReleaseSource` across `.await` points and is therefore
/// `!Send`, which axum's `Handler` trait doesn't allow. Routing
/// through the loop's own already-running instance also means these
/// reuse its already-loaded title-matcher/sources for free.
pub background_tx: Option<tokio::sync::mpsc::Sender<BackgroundRequest>>,
}
/// Everything an API handler needs the background loop to do on its
/// behalf, because the work involves `!Send` state (see `AppState::
/// background_tx`'s doc comment) that can't be run directly inside an
/// axum handler.
pub enum BackgroundRequest {
SearchNow {
media_item_id: i64,
reply: tokio::sync::oneshot::Sender<anyhow::Result<SearchCycleStats>>,
},
FetchCandidates {
media_item_id: i64,
episode_id: Option<i64>,
reply: tokio::sync::oneshot::Sender<
anyhow::Result<Vec<breadarr_shared::dto::ReleaseCandidate>>,
>,
},
GrabCandidate {
media_item_id: i64,
episode_id: Option<i64>,
source_id: i64,
raw_title: String,
link: String,
guid: String,
reply: tokio::sync::oneshot::Sender<anyhow::Result<()>>,
},
}
/// One record per completed grab/import cycle attempt — plain `std::sync::
/// Mutex` is fine here (unlike `conn`) since updates are a single field
/// write with no `.await` in between. Exists so a silently-stalled
/// background loop (e.g. every cycle erroring for hours) is visible from a
/// single `/health` call instead of only in the journal.
#[derive(Clone, Default)]
pub struct CycleStatus {
pub last_grab: Option<CycleRecord>,
pub last_import: Option<CycleRecord>,
pub last_search: Option<CycleRecord>,
pub last_upgrade: Option<CycleRecord>,
/// Set once the search-driven loop's consecutive-failure backoff hits
/// its ceiling — still ticking at max backoff underneath (self-healing
/// if the source recovers), but worth a loud, easy-to-spot signal that
/// something's wrong with 1337x/nyaa-search specifically.
pub search_halted: bool,
}
#[derive(Clone)]
pub struct CycleRecord {
pub at: chrono::DateTime<chrono::Utc>,
pub ok: bool,
pub detail: String,
}
/// Rejects any request lacking `Authorization: Bearer <config.daemon.api_token>`
/// once a token is actually configured — a no-op (every request passes)
/// when it's empty, so an unconfigured install behaves exactly as before.
/// `/health` is deliberately exempt even with a token configured: it's
/// commonly polled by external monitoring (e.g. an uptime dashboard) that
/// has no reason to hold the same credential as the TUI/API client, and it
/// exposes nothing more sensitive than "is the process alive."
async fn require_api_token(State(state): State<AppState>, req: Request, next: Next) -> Response {
if state.config.daemon.api_token.is_empty() || req.uri().path() == "/health" {
return next.run(req).await;
}
let authorized = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.is_some_and(|token| token == state.config.daemon.api_token);
if !authorized {
return (StatusCode::UNAUTHORIZED, "missing or invalid API token").into_response();
}
next.run(req).await
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/health", get(routes::health::health))
.route("/media", get(routes::media::list).post(routes::media::add))
.route(
"/media/:id",
get(routes::media::detail).delete(routes::media::delete),
)
.route("/media/movie", post(routes::media::add_movie))
.route("/media/:id/search", post(routes::media::search_now))
.route(
"/media/:id/candidates",
get(routes::media::list_candidates).post(routes::media::grab_candidate),
)
.route(
"/episode/:id/candidates",
get(routes::media::list_episode_candidates).post(routes::media::grab_episode_candidate),
)
.route("/media/:id/monitor", post(routes::media::monitor))
.route("/media/:id/unmonitor", post(routes::media::unmonitor))
.route(
"/media/:id/season/:season_number/monitor",
post(routes::media::monitor_season),
)
.route(
"/media/:id/season/:season_number/unmonitor",
post(routes::media::unmonitor_season),
)
.route("/episode/:id/monitor", post(routes::media::monitor_episode))
.route(
"/episode/:id/unmonitor",
post(routes::media::unmonitor_episode),
)
.route(
"/episode/:id/file",
axum::routing::delete(routes::media::delete_episode_file),
)
.route(
"/media/:id/file",
axum::routing::delete(routes::media::delete_movie_file),
)
.route("/releases", get(routes::releases::list))
.route("/review", get(routes::review::list))
.route("/review/:id/approve", post(routes::review::approve))
.route("/review/:id/reject", post(routes::review::reject))
.route("/search", get(routes::search::search))
.route("/stuck", get(routes::stuck::stuck))
.route("/calendar", get(routes::calendar::calendar))
.route(
"/library/health",
get(routes::library_health::library_health),
)
.route("/quality-profiles", get(routes::quality_profiles::list))
.route(
"/quality-profiles/:id/weights",
axum::routing::put(routes::quality_profiles::update_weights),
)
.layer(middleware::from_fn_with_state(
state.clone(),
require_api_token,
))
.with_state(state)
}

View file

@ -0,0 +1,52 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use breadarr_shared::dto::CalendarEntry;
use crate::api::AppState;
/// How far back/forward "upcoming" spans — matches the common Sonarr
/// calendar default window (a week either side) rather than an arbitrary
/// wider range, since the point is "what's relevant right now."
const DAYS_PAST: i64 = 7;
const DAYS_FUTURE: i64 = 21;
pub async fn calendar(
State(state): State<AppState>,
) -> Result<Json<Vec<CalendarEntry>>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let mut stmt = conn
.prepare(
"SELECT e.media_item_id, m.title, e.season_number, e.episode_number, e.title,
e.air_date, e.monitored, e.has_file
FROM episode e JOIN media_item m ON m.id = e.media_item_id
WHERE e.air_date IS NOT NULL
AND date(e.air_date) BETWEEN date('now', ?1) AND date('now', ?2)
ORDER BY e.air_date ASC",
)
.map_err(internal)?;
let rows = stmt
.query_map(
[format!("-{DAYS_PAST} days"), format!("+{DAYS_FUTURE} days")],
|row| {
Ok(CalendarEntry {
media_item_id: row.get(0)?,
media_title: row.get(1)?,
season_number: row.get(2)?,
episode_number: row.get(3)?,
title: row.get(4)?,
air_date: row.get(5)?,
monitored: row.get::<_, i64>(6)? != 0,
has_file: row.get::<_, i64>(7)? != 0,
})
},
)
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
Ok(Json(rows))
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}

View file

@ -0,0 +1,25 @@
use axum::extract::State;
use axum::Json;
use breadarr_shared::dto::{CycleInfo, HealthDetail};
use crate::api::AppState;
fn to_info(r: &crate::api::CycleRecord) -> CycleInfo {
CycleInfo {
at: r.at,
ok: r.ok,
detail: r.detail.clone(),
}
}
pub async fn health(State(state): State<AppState>) -> Json<HealthDetail> {
let status = state.cycle_status.lock().expect("cycle_status poisoned");
Json(HealthDetail {
status: "ok".to_string(),
last_grab_cycle: status.last_grab.as_ref().map(to_info),
last_import_cycle: status.last_import.as_ref().map(to_info),
last_search_cycle: status.last_search.as_ref().map(to_info),
last_upgrade_cycle: status.last_upgrade.as_ref().map(to_info),
search_halted: status.search_halted,
})
}

View file

@ -0,0 +1,321 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use breadarr_shared::dto::{
CodecCount, DuplicateGroup, FlaggedFile, LibraryHealthReport, LibrarySummary,
};
use rusqlite::Connection;
use crate::api::AppState;
/// Every flag category shares the same shape (id/title/episode-label/path),
/// resolved through the same TV-or-movie join every time — `episode_id` is
/// set for a TV file (join via `episode`→`media_item`) and NULL for a movie
/// file (join `media_item` directly via `episode_file.media_item_id`), the
/// same nullable-episode_id convention used throughout this schema.
const FLAGGED_FILE_SELECT: &str = "
SELECT ef.id,
COALESCE(mi_direct.title, mi_ep.title) AS media_title,
CASE WHEN ef.episode_id IS NOT NULL
THEN 'S' || printf('%02d', e.season_number) || 'E' || printf('%02d', e.episode_number)
ELSE NULL END AS episode_label,
ef.path
FROM episode_file ef
JOIN media_file_probe p ON p.episode_file_id = ef.id
LEFT JOIN media_item mi_direct ON mi_direct.id = ef.media_item_id
LEFT JOIN episode e ON e.id = ef.episode_id
LEFT JOIN media_item mi_ep ON mi_ep.id = e.media_item_id
WHERE ";
fn fetch_flagged(conn: &Connection, where_clause: &str) -> rusqlite::Result<Vec<FlaggedFile>> {
let sql = format!("{FLAGGED_FILE_SELECT}{where_clause}");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |row| {
Ok(FlaggedFile {
episode_file_id: row.get(0)?,
media_title: row.get(1)?,
episode_label: row.get(2)?,
path: row.get(3)?,
})
})?;
rows.collect()
}
fn fetch_duplicate_groups(conn: &Connection) -> rusqlite::Result<Vec<DuplicateGroup>> {
let mut groups = Vec::new();
let mut stmt = conn.prepare(
"SELECT m.title,
'S' || printf('%02d', e.season_number) || 'E' || printf('%02d', e.episode_number),
group_concat(ef.path, '||')
FROM episode_file ef
JOIN episode e ON e.id = ef.episode_id
JOIN media_item m ON m.id = e.media_item_id
GROUP BY ef.episode_id
HAVING count(*) > 1",
)?;
groups.extend(
stmt.query_map([], |row| {
let paths: String = row.get(2)?;
Ok(DuplicateGroup {
media_title: row.get(0)?,
episode_label: row.get(1)?,
paths: paths.split("||").map(str::to_string).collect(),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?,
);
let mut stmt = conn.prepare(
"SELECT m.title, group_concat(ef.path, '||')
FROM episode_file ef
JOIN media_item m ON m.id = ef.media_item_id
WHERE ef.episode_id IS NULL
GROUP BY ef.media_item_id
HAVING count(*) > 1",
)?;
groups.extend(
stmt.query_map([], |row| {
let paths: String = row.get(1)?;
Ok(DuplicateGroup {
media_title: row.get(0)?,
episode_label: None,
paths: paths.split("||").map(str::to_string).collect(),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?,
);
Ok(groups)
}
fn fetch_summary(conn: &Connection) -> rusqlite::Result<LibrarySummary> {
let (total_files, total_size_bytes): (i64, i64) = conn.query_row(
"SELECT count(*), COALESCE(sum(size_bytes), 0) FROM episode_file",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
let probed_files: i64 = conn.query_row("SELECT count(*) FROM media_file_probe", [], |row| {
row.get(0)
})?;
let mut stmt = conn.prepare(
"SELECT video_codec, count(*) FROM media_file_probe
WHERE video_codec IS NOT NULL GROUP BY video_codec ORDER BY count(*) DESC",
)?;
let by_video_codec = stmt
.query_map([], |row| {
Ok(CodecCount {
codec: row.get(0)?,
count: row.get(1)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
// Resolution buckets are computed against `height` directly rather than
// reusing `flag_under_quality` (which is only a <1080p boolean) — this
// view wants the full breakdown, not just the pass/fail split.
let bucket = |where_clause: &str| -> rusqlite::Result<i64> {
conn.query_row(
&format!(
"SELECT count(*) FROM media_file_probe WHERE height IS NOT NULL AND {where_clause}"
),
[],
|row| row.get(0),
)
};
let sd_count = bucket("height < 720")?;
let hd_720p_count = bucket("height >= 720 AND height < 1080")?;
let full_hd_1080p_count = bucket("height >= 1080 AND height < 2160")?;
let uhd_4k_count = bucket("height >= 2160")?;
let with_subs: i64 = conn.query_row(
"SELECT count(*) FROM media_file_probe WHERE flag_no_subtitles = 0",
[],
|row| row.get(0),
)?;
let pct_with_subtitles = if probed_files > 0 {
(with_subs as f64 / probed_files as f64) * 100.0
} else {
0.0
};
Ok(LibrarySummary {
total_files,
total_size_bytes,
probed_files,
by_video_codec,
sd_count,
hd_720p_count,
full_hd_1080p_count,
uhd_4k_count,
pct_with_subtitles,
})
}
pub async fn library_health(
State(state): State<AppState>,
) -> Result<Json<LibraryHealthReport>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let report = LibraryHealthReport {
corrupt_files: fetch_flagged(
&conn,
"p.corruption_status IN ('probe_failed','decode_failed')",
)
.map_err(internal)?,
under_quality_files: fetch_flagged(&conn, "p.flag_under_quality = 1").map_err(internal)?,
no_subtitle_files: fetch_flagged(&conn, "p.flag_no_subtitles = 1").map_err(internal)?,
no_english_audio_files: fetch_flagged(&conn, "p.flag_no_english_audio = 1")
.map_err(internal)?,
non_english_default_audio_files: fetch_flagged(
&conn,
"p.flag_non_english_default_audio = 1",
)
.map_err(internal)?,
duplicate_groups: fetch_duplicate_groups(&conn).map_err(internal)?,
summary: fetch_summary(&conn).map_err(internal)?,
};
Ok(Json(report))
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
/// A TV episode file (id=1, under-quality) and a movie file (id=2,
/// no English audio) with `media_file_probe` rows already populated —
/// exercises both halves of the nullable-episode_id join every query in
/// this file relies on.
fn seeded_conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
crate::db::init(&conn).unwrap();
conn.execute(
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
VALUES (1, 'series', 'Some Show', 2020, 1, 1, '/tmp')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file)
VALUES (1, 1, 2, 5, 1, 1)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
VALUES (1, 1, NULL, '/tmp/show.mkv', 1000, 'none')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, height, video_codec,
corruption_status, flag_under_quality, flag_no_subtitles, flag_no_english_audio, flag_non_english_default_audio)
VALUES (1, datetime('now'), 1000, 0, 480, 'h264', 'probe_ok', 1, 0, 0, 0)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder)
VALUES (2, 'movie', 'Some Movie', 2019, 1, 2, '/tmp')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
VALUES (2, NULL, 2, '/tmp/movie.mkv', 2000, 'none')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, height, video_codec,
corruption_status, flag_under_quality, flag_no_subtitles, flag_no_english_audio, flag_non_english_default_audio)
VALUES (2, datetime('now'), 2000, 0, 1080, 'hevc', 'probe_ok', 0, 1, 1, 0)",
[],
)
.unwrap();
conn
}
#[test]
fn fetch_flagged_resolves_titles_for_both_tv_and_movie_files() {
let conn = seeded_conn();
let under_quality = fetch_flagged(&conn, "p.flag_under_quality = 1").unwrap();
assert_eq!(under_quality.len(), 1);
assert_eq!(under_quality[0].media_title, "Some Show");
assert_eq!(under_quality[0].episode_label.as_deref(), Some("S02E05"));
let no_english = fetch_flagged(&conn, "p.flag_no_english_audio = 1").unwrap();
assert_eq!(no_english.len(), 1);
assert_eq!(no_english[0].media_title, "Some Movie");
assert_eq!(no_english[0].episode_label, None);
}
#[test]
fn fetch_duplicate_groups_finds_both_tv_and_movie_duplicates() {
let conn = seeded_conn();
// A second file for the same TV episode, and a second for the same movie.
conn.execute(
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
VALUES (3, 1, NULL, '/tmp/show-dup.mkv', 1000, 'none')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status)
VALUES (4, NULL, 2, '/tmp/movie-dup.mkv', 2000, 'none')",
[],
)
.unwrap();
let groups = fetch_duplicate_groups(&conn).unwrap();
assert_eq!(groups.len(), 2);
let tv_group = groups
.iter()
.find(|g| g.media_title == "Some Show")
.unwrap();
assert_eq!(tv_group.paths.len(), 2);
let movie_group = groups
.iter()
.find(|g| g.media_title == "Some Movie")
.unwrap();
assert_eq!(movie_group.paths.len(), 2);
}
#[test]
fn fetch_duplicate_groups_ignores_files_without_duplicates() {
let conn = seeded_conn();
assert!(fetch_duplicate_groups(&conn).unwrap().is_empty());
}
#[test]
fn fetch_summary_buckets_resolutions_and_computes_subtitle_percentage() {
let conn = seeded_conn();
let summary = fetch_summary(&conn).unwrap();
assert_eq!(summary.total_files, 2);
assert_eq!(summary.total_size_bytes, 3000);
assert_eq!(summary.probed_files, 2);
assert_eq!(summary.sd_count, 1); // the 480p show episode
assert_eq!(summary.full_hd_1080p_count, 1); // the 1080p movie
// One of the two probed files (the movie) has flag_no_subtitles=1.
assert_eq!(summary.pct_with_subtitles, 50.0);
}
#[test]
fn fetch_summary_handles_an_empty_library_without_dividing_by_zero() {
let conn = Connection::open_in_memory().unwrap();
crate::db::init(&conn).unwrap();
let summary = fetch_summary(&conn).unwrap();
assert_eq!(summary.total_files, 0);
assert_eq!(summary.pct_with_subtitles, 0.0);
}
}

View file

@ -0,0 +1,507 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use breadarr_shared::dto::{
AddMovieRequest, AddMovieResponse, AddSeriesRequest, AddSeriesResponse, EpisodeSummary,
MediaItemDetail, MediaItemSummary, SearchNowResult,
};
use rusqlite::{params, OptionalExtension};
use crate::api::AppState;
use crate::metadata;
pub async fn list(
State(state): State<AppState>,
) -> Result<Json<Vec<MediaItemSummary>>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let mut stmt = conn
.prepare(
"SELECT m.id, m.kind, m.title, m.year, m.monitored,
(SELECT count(*) FROM episode e WHERE e.media_item_id = m.id) AS episode_count,
(SELECT count(*) FROM episode e WHERE e.media_item_id = m.id AND e.monitored = 1 AND e.has_file = 0) AS missing_count
FROM media_item m ORDER BY m.title",
)
.map_err(internal)?;
let rows = stmt
.query_map([], |row| {
Ok(MediaItemSummary {
id: row.get(0)?,
kind: row.get(1)?,
title: row.get(2)?,
year: row.get(3)?,
monitored: row.get::<_, i64>(4)? != 0,
episode_count: row.get(5)?,
missing_count: row.get(6)?,
})
})
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
Ok(Json(rows))
}
pub async fn detail(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<MediaItemDetail>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let (kind, title, year, monitored, root_folder) = conn
.query_row(
"SELECT kind, title, year, monitored, root_folder FROM media_item WHERE id = ?1",
params![id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<i64>>(2)?,
row.get::<_, i64>(3)? != 0,
row.get::<_, String>(4)?,
))
},
)
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
let mut stmt = conn
.prepare(
"SELECT id, season_number, episode_number, title, air_date, monitored, has_file
FROM episode WHERE media_item_id = ?1 ORDER BY season_number, episode_number",
)
.map_err(internal)?;
let episodes = stmt
.query_map(params![id], |row| {
Ok(EpisodeSummary {
id: row.get(0)?,
season_number: row.get(1)?,
episode_number: row.get(2)?,
title: row.get(3)?,
air_date: row.get(4)?,
monitored: row.get::<_, i64>(5)? != 0,
has_file: row.get::<_, i64>(6)? != 0,
})
})
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
Ok(Json(MediaItemDetail {
id,
kind,
title,
year,
monitored,
root_folder,
episodes,
}))
}
pub async fn add(
State(state): State<AppState>,
Json(req): Json<AddSeriesRequest>,
) -> Result<Json<AddSeriesResponse>, (StatusCode, String)> {
let Some(tvdb) = &state.tvdb else {
return Err((
StatusCode::PRECONDITION_FAILED,
"tvdb.api_key is not configured".into(),
));
};
// Fetch before taking the lock — a sync MutexGuard can't be held across
// an `.await` point.
let episodes = tvdb.episodes(&req.tvdb_id).await.map_err(internal)?;
let media_item_id = {
let conn = state.conn.lock().await;
metadata::insert_series(
&conn,
&req.tvdb_id,
&req.title,
req.year.map(|y| y as u32),
&req.aliases,
&req.root_folder,
1,
&episodes,
)
.map_err(internal)?
};
Ok(Json(AddSeriesResponse { media_item_id }))
}
pub async fn add_movie(
State(state): State<AppState>,
Json(req): Json<AddMovieRequest>,
) -> Result<Json<AddMovieResponse>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let media_item_id = metadata::insert_movie(
&conn,
&req.tmdb_id,
&req.title,
req.year.map(|y| y as u32),
&req.root_folder,
2,
)
.map_err(internal)?;
Ok(Json(AddMovieResponse { media_item_id }))
}
pub async fn monitor(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
set_monitored(&state, id, true).await
}
pub async fn unmonitor(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
set_monitored(&state, id, false).await
}
async fn set_monitored(
state: &AppState,
id: i64,
monitored: bool,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let rows = conn
.execute(
"UPDATE media_item SET monitored = ?1 WHERE id = ?2",
params![monitored as i64, id],
)
.map_err(internal)?;
if rows == 0 {
return Err((StatusCode::NOT_FOUND, format!("no media_item {id}")));
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn monitor_episode(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
set_episode_monitored(&state, id, true).await
}
pub async fn unmonitor_episode(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
set_episode_monitored(&state, id, false).await
}
async fn set_episode_monitored(
state: &AppState,
episode_id: i64,
monitored: bool,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let rows = conn
.execute(
"UPDATE episode SET monitored = ?1 WHERE id = ?2",
params![monitored as i64, episode_id],
)
.map_err(internal)?;
if rows == 0 {
return Err((StatusCode::NOT_FOUND, format!("no episode {episode_id}")));
}
Ok(StatusCode::NO_CONTENT)
}
/// Toggles every episode in one season at once — a season-level "row" isn't
/// separately tracked (the `season` table exists in the schema but was
/// never actually populated by any insert path, so resurrecting it just to
/// hold one redundant monitored flag would mean keeping two copies of the
/// same state in sync for no behavioral gain); bulk-updating the episodes
/// directly gets the identical practical effect — this season's episodes
/// stop appearing in search enumeration — with one source of truth.
pub async fn monitor_season(
State(state): State<AppState>,
Path((media_item_id, season_number)): Path<(i64, i64)>,
) -> Result<StatusCode, (StatusCode, String)> {
set_season_monitored(&state, media_item_id, season_number, true).await
}
pub async fn unmonitor_season(
State(state): State<AppState>,
Path((media_item_id, season_number)): Path<(i64, i64)>,
) -> Result<StatusCode, (StatusCode, String)> {
set_season_monitored(&state, media_item_id, season_number, false).await
}
async fn set_season_monitored(
state: &AppState,
media_item_id: i64,
season_number: i64,
monitored: bool,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let rows = conn
.execute(
"UPDATE episode SET monitored = ?1 WHERE media_item_id = ?2 AND season_number = ?3",
params![monitored as i64, media_item_id, season_number],
)
.map_err(internal)?;
if rows == 0 {
return Err((
StatusCode::NOT_FOUND,
format!("no episodes in media_item {media_item_id} season {season_number}"),
));
}
Ok(StatusCode::NO_CONTENT)
}
/// Removes a media item (and, via cascade, its episodes/aliases/releases)
/// from the library. Deliberately does not touch anything on disk — the
/// same "stop managing this, don't delete the user's files" default
/// Sonarr/Radarr use.
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let rows = conn
.execute("DELETE FROM media_item WHERE id = ?1", params![id])
.map_err(internal)?;
if rows == 0 {
return Err((StatusCode::NOT_FOUND, format!("no media_item {id}")));
}
Ok(StatusCode::NO_CONTENT)
}
/// Deletes an episode's imported file — both the row *and* the actual file
/// on disk — and clears `has_file`, freeing the episode to be grabbed again
/// by a future search. Unlike `delete` above, this deliberately does touch
/// disk: the whole point is "this specific file is wrong/broken," and
/// leaving it in place would (a) mislead the user into thinking they still
/// have a good copy, and (b) block a future re-grab outright — `import_one`
/// refuses to overwrite an existing file with an equal-or-lower-scoring one
/// (see its dest-collision check), so a bad file that happened to score
/// high would silently reject its own replacement forever if left on disk.
pub async fn delete_episode_file(
State(state): State<AppState>,
Path(episode_id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let row: Option<(i64, String)> = conn
.query_row(
"SELECT id, path FROM episode_file WHERE episode_id = ?1",
params![episode_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(internal)?;
let Some((file_id, path)) = row else {
return Err((
StatusCode::NOT_FOUND,
format!("no file tracked for episode {episode_id}"),
));
};
delete_file_and_clear(&conn, &path, file_id)?;
conn.execute(
"UPDATE episode SET has_file = 0 WHERE id = ?1",
params![episode_id],
)
.map_err(internal)?;
Ok(StatusCode::NO_CONTENT)
}
/// Movie counterpart to `delete_episode_file` — movies have no `has_file`
/// column of their own (`movie_needs_grab` derives "has a file" purely from
/// `episode_file` row existence), so clearing the row is the entire state
/// reset needed.
pub async fn delete_movie_file(
State(state): State<AppState>,
Path(media_item_id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let row: Option<(i64, String)> = conn
.query_row(
"SELECT id, path FROM episode_file WHERE media_item_id = ?1 AND episode_id IS NULL",
params![media_item_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(internal)?;
let Some((file_id, path)) = row else {
return Err((
StatusCode::NOT_FOUND,
format!("no file tracked for media_item {media_item_id}"),
));
};
delete_file_and_clear(&conn, &path, file_id)?;
Ok(StatusCode::NO_CONTENT)
}
/// Shared cleanup: removes the file from disk (tolerating "already gone" —
/// nothing left to do, not an error) and deletes its `episode_file` row by
/// that row's own primary key — never by `episode_id`/`media_item_id`
/// alone, which a TV show shares across every one of its episode rows and
/// would otherwise risk wiping an entire show's tracked files instead of
/// the one the caller actually looked up.
fn delete_file_and_clear(
conn: &rusqlite::Connection,
path: &str,
file_id: i64,
) -> Result<(), (StatusCode, String)> {
if let Err(e) = std::fs::remove_file(path) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to delete {path}: {e}"),
));
}
}
conn.execute("DELETE FROM episode_file WHERE id = ?1", params![file_id])
.map_err(internal)?;
Ok(())
}
/// Manual "search now" for a single show/movie's whole current backlog —
/// bypasses the background loop's per-cycle budget and due-ness cadence
/// (those exist to pace unattended, indefinite operation; a single
/// user-triggered request doesn't need throttling against itself).
///
/// Dispatched to the background loop via a channel rather than run inline
/// here: the search pipeline (`execute_search_targets` and everything it
/// calls) holds a `Connection`/`&dyn ReleaseSource` across `.await` points,
/// which makes it `!Send` — fine for the background loop (part of the root
/// future, never `Send`-constrained) but not callable directly from an axum
/// handler (whose future axum requires to be `Send`). Routing through the
/// background loop's own already-running instance also means this reuses
/// its already-loaded title-matcher/sources instead of paying to construct
/// fresh ones per request.
pub async fn search_now(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<SearchNowResult>, (StatusCode, String)> {
let Some(tx) = &state.background_tx else {
return Err((
StatusCode::PRECONDITION_FAILED,
"background loop is not running (qbit.base_url is not configured)".into(),
));
};
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
tx.send(crate::api::BackgroundRequest::SearchNow {
media_item_id: id,
reply: reply_tx,
})
.await
.map_err(internal)?;
let stats = reply_rx.await.map_err(internal)?.map_err(internal)?;
Ok(Json(SearchNowResult {
targets: stats.targets,
searched: stats.searched,
grabbed: stats.grabbed,
errors: stats.errors,
source_exhausted: stats.source_exhausted,
}))
}
/// Movie candidate picker: `episode_id` is always `None` for a movie
/// target, matching every other movie/episode split in this codebase.
pub async fn list_candidates(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Vec<breadarr_shared::dto::ReleaseCandidate>>, (StatusCode, String)> {
fetch_candidates_via_background(&state, id, None).await
}
pub async fn grab_candidate(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(req): Json<breadarr_shared::dto::GrabCandidateRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
grab_candidate_via_background(&state, id, None, req).await
}
/// Episode candidate picker — resolves the episode's own `media_item_id`
/// first (every other candidate/search-target function needs it), same
/// pattern as `set_episode_monitored`.
pub async fn list_episode_candidates(
State(state): State<AppState>,
Path(episode_id): Path<i64>,
) -> Result<Json<Vec<breadarr_shared::dto::ReleaseCandidate>>, (StatusCode, String)> {
let media_item_id = episode_media_item_id(&state, episode_id).await?;
fetch_candidates_via_background(&state, media_item_id, Some(episode_id)).await
}
pub async fn grab_episode_candidate(
State(state): State<AppState>,
Path(episode_id): Path<i64>,
Json(req): Json<breadarr_shared::dto::GrabCandidateRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
let media_item_id = episode_media_item_id(&state, episode_id).await?;
grab_candidate_via_background(&state, media_item_id, Some(episode_id), req).await
}
async fn episode_media_item_id(
state: &AppState,
episode_id: i64,
) -> Result<i64, (StatusCode, String)> {
let conn = state.conn.lock().await;
conn.query_row(
"SELECT media_item_id FROM episode WHERE id = ?1",
params![episode_id],
|row| row.get(0),
)
.optional()
.map_err(internal)?
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("no episode {episode_id}")))
}
async fn fetch_candidates_via_background(
state: &AppState,
media_item_id: i64,
episode_id: Option<i64>,
) -> Result<Json<Vec<breadarr_shared::dto::ReleaseCandidate>>, (StatusCode, String)> {
let Some(tx) = &state.background_tx else {
return Err((
StatusCode::PRECONDITION_FAILED,
"background loop is not running (qbit.base_url is not configured)".into(),
));
};
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
tx.send(crate::api::BackgroundRequest::FetchCandidates {
media_item_id,
episode_id,
reply: reply_tx,
})
.await
.map_err(internal)?;
let candidates = reply_rx.await.map_err(internal)?.map_err(internal)?;
Ok(Json(candidates))
}
async fn grab_candidate_via_background(
state: &AppState,
media_item_id: i64,
episode_id: Option<i64>,
req: breadarr_shared::dto::GrabCandidateRequest,
) -> Result<StatusCode, (StatusCode, String)> {
let Some(tx) = &state.background_tx else {
return Err((
StatusCode::PRECONDITION_FAILED,
"background loop is not running (qbit.base_url is not configured)".into(),
));
};
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
tx.send(crate::api::BackgroundRequest::GrabCandidate {
media_item_id,
episode_id,
source_id: req.source_id,
raw_title: req.raw_title,
link: req.link,
guid: req.guid,
reply: reply_tx,
})
.await
.map_err(internal)?;
reply_rx.await.map_err(internal)?.map_err(internal)?;
Ok(StatusCode::NO_CONTENT)
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}

View file

@ -0,0 +1,9 @@
pub mod calendar;
pub mod health;
pub mod library_health;
pub mod media;
pub mod quality_profiles;
pub mod releases;
pub mod review;
pub mod search;
pub mod stuck;

View file

@ -0,0 +1,87 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use breadarr_shared::dto::{QualityProfileSummary, UpdateQualityProfileWeightsRequest, WeightsDto};
use crate::api::AppState;
use crate::scoring::{ProfileKind, QualityProfile};
fn to_dto(weights: &crate::scoring::profile::Weights) -> WeightsDto {
WeightsDto {
seeder: weights.seeder,
resolution_tier: weights.resolution_tier,
source_tier: weights.source_tier,
codec_tier: weights.codec_tier,
bit_depth: weights.bit_depth,
container: weights.container,
group_allowlist: weights.group_allowlist,
repack: weights.repack,
hdr: weights.hdr,
}
}
/// Lists every quality profile with its currently-effective weights
/// (built-in defaults plus any stored override already applied) — never
/// the raw, possibly-partial stored JSON, so a client always has a
/// complete, concrete set of numbers to show and re-submit.
pub async fn list(
State(state): State<AppState>,
) -> Result<Json<Vec<QualityProfileSummary>>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let mut stmt = conn
.prepare("SELECT id, name, kind, weights FROM quality_profile ORDER BY id")
.map_err(internal)?;
let rows: Vec<(i64, String, String, String)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})
.map_err(internal)?
.collect::<rusqlite::Result<_>>()
.map_err(internal)?;
let profiles = rows
.into_iter()
.map(|(id, name, kind, weights_json)| {
let profile_kind = if kind == "movie" {
ProfileKind::Movie
} else {
ProfileKind::Tv
};
let resolved = QualityProfile::with_weights_override(profile_kind, &weights_json);
QualityProfileSummary {
id,
name,
kind,
weights: to_dto(&resolved.weights),
}
})
.collect();
Ok(Json(profiles))
}
/// Overwrites a profile's stored `weights` column with exactly the
/// submitted values (a full replacement, not a partial patch) — the TUI
/// always submits the complete resolved set it displayed, so there's no
/// ambiguity about what "the rest stays as it was" would even mean here.
pub async fn update_weights(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(req): Json<UpdateQualityProfileWeightsRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
let weights_json = serde_json::to_string(&req.weights).map_err(internal)?;
let conn = state.conn.lock().await;
let updated = conn
.execute(
"UPDATE quality_profile SET weights = ?1 WHERE id = ?2",
rusqlite::params![weights_json, id],
)
.map_err(internal)?;
if updated == 0 {
return Err((StatusCode::NOT_FOUND, "quality profile not found".into()));
}
Ok(StatusCode::NO_CONTENT)
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}

View file

@ -0,0 +1,38 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use breadarr_shared::dto::ReleaseSummary;
use crate::api::AppState;
pub async fn list(
State(state): State<AppState>,
) -> Result<Json<Vec<ReleaseSummary>>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let mut stmt = conn
.prepare(
"SELECT r.id, m.title, r.raw_title, r.score, r.status, r.grabbed_at
FROM release r JOIN media_item m ON m.id = r.media_item_id
ORDER BY r.grabbed_at DESC LIMIT 200",
)
.map_err(internal)?;
let rows = stmt
.query_map([], |row| {
Ok(ReleaseSummary {
id: row.get(0)?,
media_title: row.get(1)?,
raw_title: row.get(2)?,
score: row.get(3)?,
status: row.get(4)?,
grabbed_at: row.get(5)?,
})
})
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
Ok(Json(rows))
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}

View file

@ -0,0 +1,108 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use breadarr_shared::dto::ReviewQueueEntry;
use crate::api::AppState;
use crate::scheduler;
pub async fn list(
State(state): State<AppState>,
) -> Result<Json<Vec<ReviewQueueEntry>>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let mut stmt = conn
.prepare(
"SELECT rq.id, rq.raw_release_title, m.title, rq.confidence, rq.status, rq.created_at
FROM review_queue rq LEFT JOIN media_item m ON m.id = rq.candidate_media_item_id
WHERE rq.status = 'pending'
ORDER BY rq.created_at DESC",
)
.map_err(internal)?;
let rows = stmt
.query_map([], |row| {
Ok(ReviewQueueEntry {
id: row.get(0)?,
raw_release_title: row.get(1)?,
candidate_media_title: row.get(2)?,
confidence: row.get(3)?,
status: row.get(4)?,
created_at: row.get(5)?,
})
})
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
Ok(Json(rows))
}
pub async fn approve(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let Some(qbit) = &state.qbit else {
return Err((
StatusCode::PRECONDITION_FAILED,
"qbit.base_url is not configured".into(),
));
};
let prep = {
let conn = state.conn.lock().await;
scheduler::prepare_review_approval(&conn, id).map_err(internal)?
};
let prepared = match prep {
scheduler::ApprovalPrep::Ready(p) => p,
scheduler::ApprovalPrep::NotPending => {
return Err((StatusCode::CONFLICT, "review item is not pending".into()))
}
scheduler::ApprovalPrep::MissingGrabData => {
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"review item has no stored link/source to grab".into(),
))
}
scheduler::ApprovalPrep::CouldNotResolveEpisode => {
return Err((
StatusCode::UNPROCESSABLE_ENTITY,
"could not resolve which episode this release is".into(),
))
}
scheduler::ApprovalPrep::NotMonitoredOrAlreadyHave => {
return Err((
StatusCode::CONFLICT,
"episode is not monitored or is already downloaded".into(),
))
}
};
let torrent_hash = scheduler::grab_prepared_approval(qbit, &state.qbit_category, &prepared)
.await
.map_err(internal)?;
{
let conn = state.conn.lock().await;
scheduler::finalize_review_approval(
&conn,
id,
&prepared,
&state.qbit_category,
torrent_hash.as_deref(),
)
.map_err(internal)?;
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn reject(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
scheduler::reject_review(&conn, id).map_err(internal)?;
Ok(StatusCode::NO_CONTENT)
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}

View file

@ -0,0 +1,63 @@
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::Json;
use breadarr_shared::dto::SearchResult;
use serde::Deserialize;
use crate::api::AppState;
fn default_kind() -> String {
"series".to_string()
}
#[derive(Deserialize)]
pub struct SearchParams {
q: String,
#[serde(default = "default_kind")]
kind: String,
}
pub async fn search(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Result<Json<Vec<SearchResult>>, (StatusCode, String)> {
if params.kind == "movie" {
let Some(tmdb) = &state.tmdb else {
return Err((
StatusCode::PRECONDITION_FAILED,
"tmdb.bearer_token is not configured".into(),
));
};
let results = tmdb
.search_movie(&params.q)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
.into_iter()
.map(|r| SearchResult {
external_id: r.external_id,
title: r.title,
year: r.year.map(|y| y as i64),
})
.collect();
return Ok(Json(results));
}
let Some(tvdb) = &state.tvdb else {
return Err((
StatusCode::PRECONDITION_FAILED,
"tvdb.api_key is not configured".into(),
));
};
let results = tvdb
.search_series(&params.q)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
.into_iter()
.map(|r| SearchResult {
external_id: r.external_id,
title: r.name,
year: r.year.map(|y| y as i64),
})
.collect();
Ok(Json(results))
}

View file

@ -0,0 +1,84 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use breadarr_shared::dto::{MaxedSearchTarget, StalledGrab, StuckReport};
use crate::api::AppState;
/// Deliberately earlier than importer::mod's own `STALL_THRESHOLD_HOURS`
/// (72h) — this report exists to surface a stuck grab *before* the
/// daemon's own auto-fail kicks in, not just repeat it after the fact.
const STALLED_GRAB_HOURS: f64 = 48.0;
/// Matches the point where the search-driven loop's exponential backoff
/// (`6h * 2^(count-1)`, capped at 168h) saturates — see `scheduler::
/// DUE_CLAUSE`. A target still at this count with no successful grab has
/// been failing its search every single time for at least a week.
const MAXED_SEARCH_COUNT: i64 = 6;
pub async fn stuck(
State(state): State<AppState>,
) -> Result<Json<StuckReport>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let mut stmt = conn
.prepare(
"SELECT r.id, m.title, r.raw_title, r.grabbed_at
FROM release r JOIN media_item m ON m.id = r.media_item_id
WHERE r.status = 'grabbed'
AND (julianday('now') - julianday(r.grabbed_at)) * 24.0 > ?1
ORDER BY r.grabbed_at ASC",
)
.map_err(internal)?;
let stalled_grabs = stmt
.query_map([STALLED_GRAB_HOURS], |row| {
Ok(StalledGrab {
release_id: row.get(0)?,
media_title: row.get(1)?,
raw_title: row.get(2)?,
grabbed_at: row.get(3)?,
})
})
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
let review_queue_depth: i64 = conn
.query_row(
"SELECT count(*) FROM review_queue WHERE status = 'pending'",
[],
|row| row.get(0),
)
.map_err(internal)?;
let mut stmt = conn
.prepare(
"SELECT ss.media_item_id, m.title, ss.search_count, ss.last_searched_at
FROM search_state ss JOIN media_item m ON m.id = ss.media_item_id
WHERE ss.search_count >= ?1 AND ss.last_result != 'grabbed'
ORDER BY ss.search_count DESC",
)
.map_err(internal)?;
let maxed_out_search_targets = stmt
.query_map([MAXED_SEARCH_COUNT], |row| {
Ok(MaxedSearchTarget {
media_item_id: row.get(0)?,
media_title: row.get(1)?,
search_count: row.get(2)?,
last_searched_at: row.get(3)?,
})
})
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
Ok(Json(StuckReport {
stalled_grabs,
review_queue_depth,
maxed_out_search_targets,
}))
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}