can't be bothered writing a commit message
This commit is contained in:
commit
697b009627
55 changed files with 21320 additions and 0 deletions
52
breadarrd/src/api/routes/calendar.rs
Normal file
52
breadarrd/src/api/routes/calendar.rs
Normal 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())
|
||||
}
|
||||
25
breadarrd/src/api/routes/health.rs
Normal file
25
breadarrd/src/api/routes/health.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
321
breadarrd/src/api/routes/library_health.rs
Normal file
321
breadarrd/src/api/routes/library_health.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
507
breadarrd/src/api/routes/media.rs
Normal file
507
breadarrd/src/api/routes/media.rs
Normal 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())
|
||||
}
|
||||
9
breadarrd/src/api/routes/mod.rs
Normal file
9
breadarrd/src/api/routes/mod.rs
Normal 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;
|
||||
87
breadarrd/src/api/routes/quality_profiles.rs
Normal file
87
breadarrd/src/api/routes/quality_profiles.rs
Normal 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())
|
||||
}
|
||||
38
breadarrd/src/api/routes/releases.rs
Normal file
38
breadarrd/src/api/routes/releases.rs
Normal 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())
|
||||
}
|
||||
108
breadarrd/src/api/routes/review.rs
Normal file
108
breadarrd/src/api/routes/review.rs
Normal 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())
|
||||
}
|
||||
63
breadarrd/src/api/routes/search.rs
Normal file
63
breadarrd/src/api/routes/search.rs
Normal 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(¶ms.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(¶ms.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))
|
||||
}
|
||||
84
breadarrd/src/api/routes/stuck.rs
Normal file
84
breadarrd/src/api/routes/stuck.rs
Normal 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())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue