can't be bothered writing a commit message
This commit is contained in:
commit
697b009627
55 changed files with 21320 additions and 0 deletions
371
breadarr-shared/src/client.rs
Normal file
371
breadarr-shared/src/client.rs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::dto::{
|
||||
AddMovieRequest, AddMovieResponse, AddSeriesRequest, AddSeriesResponse, CalendarEntry,
|
||||
GrabCandidateRequest, HealthDetail, LibraryHealthReport, MediaItemDetail, MediaItemSummary,
|
||||
QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchNowResult,
|
||||
SearchResult, StuckReport, UpdateQualityProfileWeightsRequest, WeightsDto,
|
||||
};
|
||||
|
||||
pub struct DaemonClient {
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl DaemonClient {
|
||||
/// `api_token` mirrors `config.daemon.api_token` server-side — empty
|
||||
/// means "no auth configured," so this stays a no-op default header
|
||||
/// rather than sending a meaningless empty bearer token on every
|
||||
/// request.
|
||||
pub fn new(base_url: impl Into<String>, api_token: &str) -> Self {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
// A default so no request can hang the TUI forever with zero
|
||||
// feedback if the daemon is unreachable or a connection stalls.
|
||||
// Routes that legitimately need longer (search_now) or shorter
|
||||
// (health) windows set their own per-request `.timeout(...)`,
|
||||
// which overrides this.
|
||||
.timeout(std::time::Duration::from_secs(30));
|
||||
if !api_token.is_empty() {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if let Ok(value) =
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {api_token}"))
|
||||
{
|
||||
headers.insert(reqwest::header::AUTHORIZATION, value);
|
||||
}
|
||||
builder = builder.default_headers(headers);
|
||||
}
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
client: builder
|
||||
.build()
|
||||
.expect("reqwest client builder should not fail with only a timeout/headers set"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn health(&self) -> Result<bool> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/health", self.base_url))
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.send()
|
||||
.await;
|
||||
Ok(matches!(resp, Ok(r) if r.status().is_success()))
|
||||
}
|
||||
|
||||
/// The richer health payload (per-cycle status, search_halted) — a
|
||||
/// separate call from `health()` rather than folding this into it, so
|
||||
/// a caller that only needs the fast up/down check (a tight poll loop)
|
||||
/// isn't forced to also pay for parsing/holding the full detail.
|
||||
pub async fn health_detail(&self) -> Result<HealthDetail> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/health", self.base_url))
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.send()
|
||||
.await
|
||||
.context("health_detail request failed")?
|
||||
.error_for_status()
|
||||
.context("health_detail returned an error status")?;
|
||||
resp.json()
|
||||
.await
|
||||
.context("health_detail response was not valid JSON")
|
||||
}
|
||||
|
||||
pub async fn list_media(&self) -> Result<Vec<MediaItemSummary>> {
|
||||
self.get("/media").await
|
||||
}
|
||||
|
||||
pub async fn media_detail(&self, id: i64) -> Result<MediaItemDetail> {
|
||||
self.get(&format!("/media/{id}")).await
|
||||
}
|
||||
|
||||
pub async fn releases(&self) -> Result<Vec<ReleaseSummary>> {
|
||||
self.get("/releases").await
|
||||
}
|
||||
|
||||
pub async fn review_queue(&self) -> Result<Vec<ReviewQueueEntry>> {
|
||||
self.get("/review").await
|
||||
}
|
||||
|
||||
pub async fn stuck(&self) -> Result<StuckReport> {
|
||||
self.get("/stuck").await
|
||||
}
|
||||
|
||||
pub async fn calendar(&self) -> Result<Vec<CalendarEntry>> {
|
||||
self.get("/calendar").await
|
||||
}
|
||||
|
||||
pub async fn library_health(&self) -> Result<LibraryHealthReport> {
|
||||
self.get("/library/health").await
|
||||
}
|
||||
|
||||
pub async fn quality_profiles(&self) -> Result<Vec<QualityProfileSummary>> {
|
||||
self.get("/quality-profiles").await
|
||||
}
|
||||
|
||||
pub async fn update_quality_profile_weights(&self, id: i64, weights: WeightsDto) -> Result<()> {
|
||||
let req = UpdateQualityProfileWeightsRequest { weights };
|
||||
self.client
|
||||
.put(format!("{}/quality-profiles/{id}/weights", self.base_url))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.context("update_quality_profile_weights request failed")?
|
||||
.error_for_status()
|
||||
.context("update_quality_profile_weights returned an error status")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn approve_review(&self, id: i64) -> Result<()> {
|
||||
self.post_empty(&format!("/review/{id}/approve")).await
|
||||
}
|
||||
|
||||
pub async fn reject_review(&self, id: i64) -> Result<()> {
|
||||
self.post_empty(&format!("/review/{id}/reject")).await
|
||||
}
|
||||
|
||||
pub async fn search_series(&self, query: &str) -> Result<Vec<SearchResult>> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/search", self.base_url))
|
||||
.query(&[("q", query)])
|
||||
.send()
|
||||
.await
|
||||
.context("search request failed")?
|
||||
.error_for_status()
|
||||
.context("search returned an error status")?;
|
||||
resp.json()
|
||||
.await
|
||||
.context("search response was not valid JSON")
|
||||
}
|
||||
|
||||
pub async fn add_series(&self, req: &AddSeriesRequest) -> Result<AddSeriesResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/media", self.base_url))
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.context("add_series request failed")?
|
||||
.error_for_status()
|
||||
.context("add_series returned an error status")?;
|
||||
resp.json()
|
||||
.await
|
||||
.context("add_series response was not valid JSON")
|
||||
}
|
||||
|
||||
pub async fn search_movies(&self, query: &str) -> Result<Vec<SearchResult>> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/search", self.base_url))
|
||||
.query(&[("q", query), ("kind", "movie")])
|
||||
.send()
|
||||
.await
|
||||
.context("search request failed")?
|
||||
.error_for_status()
|
||||
.context("search returned an error status")?;
|
||||
resp.json()
|
||||
.await
|
||||
.context("search response was not valid JSON")
|
||||
}
|
||||
|
||||
pub async fn add_movie(&self, req: &AddMovieRequest) -> Result<AddMovieResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/media/movie", self.base_url))
|
||||
.json(req)
|
||||
.send()
|
||||
.await
|
||||
.context("add_movie request failed")?
|
||||
.error_for_status()
|
||||
.context("add_movie returned an error status")?;
|
||||
resp.json()
|
||||
.await
|
||||
.context("add_movie response was not valid JSON")
|
||||
}
|
||||
|
||||
/// Manual "search now" for one show/movie's whole current backlog — can
|
||||
/// take a while (jittered, one request per missing item), so callers
|
||||
/// should expect this to be slow, not instant.
|
||||
pub async fn search_now(&self, id: i64) -> Result<SearchNowResult> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/media/{id}/search", self.base_url))
|
||||
.timeout(std::time::Duration::from_secs(600))
|
||||
.send()
|
||||
.await
|
||||
.context("search_now request failed")?
|
||||
.error_for_status()
|
||||
.context("search_now returned an error status")?;
|
||||
resp.json()
|
||||
.await
|
||||
.context("search_now response was not valid JSON")
|
||||
}
|
||||
|
||||
/// Scored (or gate-rejected) candidates for a movie, without grabbing —
|
||||
/// the manual release picker. Same 600s allowance as `search_now` since
|
||||
/// it runs a real search under the hood.
|
||||
pub async fn movie_candidates(&self, media_item_id: i64) -> Result<Vec<ReleaseCandidate>> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/media/{media_item_id}/candidates",
|
||||
self.base_url
|
||||
))
|
||||
.timeout(std::time::Duration::from_secs(600))
|
||||
.send()
|
||||
.await
|
||||
.context("movie_candidates request failed")?
|
||||
.error_for_status()
|
||||
.context("movie_candidates returned an error status")?;
|
||||
resp.json()
|
||||
.await
|
||||
.context("movie_candidates response was not valid JSON")
|
||||
}
|
||||
|
||||
pub async fn episode_candidates(&self, episode_id: i64) -> Result<Vec<ReleaseCandidate>> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/episode/{episode_id}/candidates", self.base_url))
|
||||
.timeout(std::time::Duration::from_secs(600))
|
||||
.send()
|
||||
.await
|
||||
.context("episode_candidates request failed")?
|
||||
.error_for_status()
|
||||
.context("episode_candidates returned an error status")?;
|
||||
resp.json()
|
||||
.await
|
||||
.context("episode_candidates response was not valid JSON")
|
||||
}
|
||||
|
||||
pub async fn grab_movie_candidate(
|
||||
&self,
|
||||
media_item_id: i64,
|
||||
candidate: &ReleaseCandidate,
|
||||
) -> Result<()> {
|
||||
self.grab_candidate(&format!("/media/{media_item_id}/candidates"), candidate)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn grab_episode_candidate(
|
||||
&self,
|
||||
episode_id: i64,
|
||||
candidate: &ReleaseCandidate,
|
||||
) -> Result<()> {
|
||||
self.grab_candidate(&format!("/episode/{episode_id}/candidates"), candidate)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn grab_candidate(&self, path: &str, candidate: &ReleaseCandidate) -> Result<()> {
|
||||
let req = GrabCandidateRequest {
|
||||
raw_title: candidate.raw_title.clone(),
|
||||
link: candidate.link.clone(),
|
||||
guid: candidate.guid.clone(),
|
||||
source_id: candidate.source_id,
|
||||
};
|
||||
self.client
|
||||
.post(format!("{}{path}", self.base_url))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("grab_candidate POST {path} failed"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("grab_candidate POST {path} returned an error status"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn monitor(&self, id: i64) -> Result<()> {
|
||||
self.post_empty(&format!("/media/{id}/monitor")).await
|
||||
}
|
||||
|
||||
pub async fn unmonitor(&self, id: i64) -> Result<()> {
|
||||
self.post_empty(&format!("/media/{id}/unmonitor")).await
|
||||
}
|
||||
|
||||
pub async fn monitor_episode(&self, episode_id: i64) -> Result<()> {
|
||||
self.post_empty(&format!("/episode/{episode_id}/monitor"))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unmonitor_episode(&self, episode_id: i64) -> Result<()> {
|
||||
self.post_empty(&format!("/episode/{episode_id}/unmonitor"))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn monitor_season(&self, media_item_id: i64, season_number: i64) -> Result<()> {
|
||||
self.post_empty(&format!(
|
||||
"/media/{media_item_id}/season/{season_number}/monitor"
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unmonitor_season(&self, media_item_id: i64, season_number: i64) -> Result<()> {
|
||||
self.post_empty(&format!(
|
||||
"/media/{media_item_id}/season/{season_number}/unmonitor"
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_media(&self, id: i64) -> Result<()> {
|
||||
self.client
|
||||
.delete(format!("{}/media/{id}", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
.context("delete_media request failed")?
|
||||
.error_for_status()
|
||||
.context("delete_media returned an error status")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes an episode's imported file (from disk, not just tracking) so
|
||||
/// it can be re-grabbed — for a confirmed-wrong or broken file, not a
|
||||
/// routine action.
|
||||
pub async fn delete_episode_file(&self, episode_id: i64) -> Result<()> {
|
||||
self.client
|
||||
.delete(format!("{}/episode/{episode_id}/file", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
.context("delete_episode_file request failed")?
|
||||
.error_for_status()
|
||||
.context("delete_episode_file returned an error status")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Movie counterpart to `delete_episode_file`.
|
||||
pub async fn delete_movie_file(&self, media_item_id: i64) -> Result<()> {
|
||||
self.client
|
||||
.delete(format!("{}/media/{media_item_id}/file", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
.context("delete_movie_file request failed")?
|
||||
.error_for_status()
|
||||
.context("delete_movie_file returned an error status")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}{path}", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {path} failed"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("GET {path} returned an error status"))?;
|
||||
resp.json()
|
||||
.await
|
||||
.with_context(|| format!("GET {path} response was not valid JSON"))
|
||||
}
|
||||
|
||||
async fn post_empty(&self, path: &str) -> Result<()> {
|
||||
self.client
|
||||
.post(format!("{}{path}", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("POST {path} failed"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("POST {path} returned an error status"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
375
breadarr-shared/src/config.rs
Normal file
375
breadarr-shared/src/config.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub daemon: DaemonConfig,
|
||||
#[serde(default)]
|
||||
pub qbit: QbitConfig,
|
||||
#[serde(default)]
|
||||
pub jellyfin: JellyfinConfig,
|
||||
#[serde(default)]
|
||||
pub tvdb: TvdbConfig,
|
||||
#[serde(default)]
|
||||
pub tmdb: TmdbConfig,
|
||||
#[serde(default)]
|
||||
pub library: LibraryConfig,
|
||||
#[serde(default)]
|
||||
pub sources: SourcesConfig,
|
||||
#[serde(default)]
|
||||
pub notifications: NotificationsConfig,
|
||||
}
|
||||
|
||||
/// Where the TUI's "add show" flow places new series by default. Sonarr/
|
||||
/// Radarr let you pick a root folder per add; a single configured default
|
||||
/// is a reasonable v1 simplification — per-add picking can follow later.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct LibraryConfig {
|
||||
#[serde(default = "default_root_folder")]
|
||||
pub default_root_folder: String,
|
||||
}
|
||||
|
||||
fn default_root_folder() -> String {
|
||||
"~/breadarr-library".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SourcesConfig {
|
||||
/// 1337x's main domain bans IPs at the Cloudflare WAF level after
|
||||
/// bursts of automated traffic. Its community mirrors run on separate
|
||||
/// domains/Cloudflare zones, so a ban on one doesn't carry over — tried
|
||||
/// in order, falling back on failure, so losing one to a future ban
|
||||
/// doesn't take the source down. Verified working (not Cloudflare-
|
||||
/// challenged, genuine 1337x content) as of 2026-07-11.
|
||||
#[serde(default = "default_1337x_mirrors")]
|
||||
pub torrent_1337x_mirrors: Vec<String>,
|
||||
/// English-translated anime category — matches the actual anime library
|
||||
/// (nyaa carries no non-anime content, so a broader/unfiltered feed
|
||||
/// would just be pure noise against everything else monitored).
|
||||
#[serde(default = "default_nyaa_rss_url")]
|
||||
pub nyaa_rss_url: String,
|
||||
#[serde(default = "default_grab_poll_interval_secs")]
|
||||
pub grab_poll_interval_secs: u64,
|
||||
#[serde(default = "default_import_poll_interval_secs")]
|
||||
pub import_poll_interval_secs: u64,
|
||||
#[serde(default = "default_search_poll_interval_secs")]
|
||||
pub search_poll_interval_secs: u64,
|
||||
/// Search requests per cycle across 1337x + nyaa search combined — kept
|
||||
/// small since this is the one source with a ban history; clears a
|
||||
/// 50-item backlog in ~5 hours at the default interval without ever
|
||||
/// looking like a request flood to any single mirror.
|
||||
#[serde(default = "default_search_budget_per_cycle")]
|
||||
pub search_budget_per_cycle: usize,
|
||||
/// Human kill switch for the search-driven loop — a restart resets all
|
||||
/// in-memory mirror cooldown/backoff state, so this (not a persisted
|
||||
/// flag) is the deliberate way to keep it off across restarts.
|
||||
#[serde(default = "default_search_enabled")]
|
||||
pub search_enabled: bool,
|
||||
/// A community JSON API mirror of The Pirate Bay's search — the
|
||||
/// primary general-content (movies + non-anime TV) search source.
|
||||
/// Unlike 1337x this needs no HTML scraping and actually ranks by
|
||||
/// relevance rather than pure seeder count, which matters a lot for
|
||||
/// titles made of common words.
|
||||
#[serde(default = "default_tpb_api_url")]
|
||||
pub tpb_api_url: String,
|
||||
/// Human kill switch for the upgrade-search loop, same reasoning as
|
||||
/// `search_enabled` — off by default would mean nothing ever improves,
|
||||
/// but a user who's happy with their current files (or wants to save
|
||||
/// request budget) can disable it independently of missing-content
|
||||
/// search.
|
||||
#[serde(default = "default_upgrade_enabled")]
|
||||
pub upgrade_enabled: bool,
|
||||
/// Deliberately much longer than `search_poll_interval_secs` — this
|
||||
/// loop re-checks content that's already satisfied (a file exists), so
|
||||
/// there's no urgency the way a missing episode has, and every cycle
|
||||
/// still costs the same request budget as a missing-content search.
|
||||
#[serde(default = "default_upgrade_poll_interval_secs")]
|
||||
pub upgrade_poll_interval_secs: u64,
|
||||
#[serde(default = "default_upgrade_budget_per_cycle")]
|
||||
pub upgrade_budget_per_cycle: usize,
|
||||
/// Minimum score improvement (on top of the same weighted-score scale
|
||||
/// `should_grab` already compares) required before a periodic upgrade
|
||||
/// check will actually re-grab — without this, a file already on disk
|
||||
/// could get replaced over and over for score deltas too small to
|
||||
/// matter, wasting bandwidth on churn. Repacks/propers always supersede
|
||||
/// regardless of this threshold, same as the normal grab path.
|
||||
#[serde(default = "default_upgrade_min_score_gain")]
|
||||
pub upgrade_min_score_gain: f32,
|
||||
}
|
||||
|
||||
impl Default for SourcesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
torrent_1337x_mirrors: default_1337x_mirrors(),
|
||||
nyaa_rss_url: default_nyaa_rss_url(),
|
||||
grab_poll_interval_secs: default_grab_poll_interval_secs(),
|
||||
import_poll_interval_secs: default_import_poll_interval_secs(),
|
||||
search_poll_interval_secs: default_search_poll_interval_secs(),
|
||||
search_budget_per_cycle: default_search_budget_per_cycle(),
|
||||
search_enabled: default_search_enabled(),
|
||||
tpb_api_url: default_tpb_api_url(),
|
||||
upgrade_enabled: default_upgrade_enabled(),
|
||||
upgrade_poll_interval_secs: default_upgrade_poll_interval_secs(),
|
||||
upgrade_budget_per_cycle: default_upgrade_budget_per_cycle(),
|
||||
upgrade_min_score_gain: default_upgrade_min_score_gain(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_tpb_api_url() -> String {
|
||||
"https://apibay.org/q.php".to_string()
|
||||
}
|
||||
|
||||
fn default_upgrade_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_upgrade_poll_interval_secs() -> u64 {
|
||||
6 * 60 * 60
|
||||
}
|
||||
|
||||
fn default_upgrade_budget_per_cycle() -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn default_upgrade_min_score_gain() -> f32 {
|
||||
5.0
|
||||
}
|
||||
|
||||
fn default_search_poll_interval_secs() -> u64 {
|
||||
30 * 60
|
||||
}
|
||||
|
||||
fn default_search_budget_per_cycle() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_search_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_nyaa_rss_url() -> String {
|
||||
"https://nyaa.si/?page=rss&c=1_2".to_string()
|
||||
}
|
||||
|
||||
fn default_grab_poll_interval_secs() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_import_poll_interval_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_1337x_mirrors() -> Vec<String> {
|
||||
[
|
||||
"https://13377x.info",
|
||||
"https://13377x.email",
|
||||
"https://1337xto.info",
|
||||
"https://1337x.maskbay.info",
|
||||
"https://1337x.ninjaproxy.live",
|
||||
"https://1337x.proxyhive.pro",
|
||||
"https://1337x.torproxy.live",
|
||||
"https://1337x.unblockit.world",
|
||||
"https://1337x.unblockpirate.xyz",
|
||||
"https://1337x.unblockshark.info",
|
||||
"https://1337x.unblocktorrent.click",
|
||||
"https://1337x.unblocktorrent.info",
|
||||
"https://1337x.unblocktor.xyz",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DaemonConfig {
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: String,
|
||||
#[serde(default = "default_listen_addr")]
|
||||
pub listen_addr: String,
|
||||
#[serde(default = "default_db_path")]
|
||||
pub db_path: String,
|
||||
#[serde(default = "default_model_dir")]
|
||||
pub model_dir: String,
|
||||
/// Bearer token required on every API request when non-empty. Empty
|
||||
/// (the default) means auth is off entirely — `listen_addr` defaults to
|
||||
/// loopback-only, so a fresh install isn't suddenly locked out of its
|
||||
/// own unconfigured daemon. This matters once `listen_addr` is changed
|
||||
/// to bind non-loopback (e.g. so a TUI on a different host on the same
|
||||
/// tailnet can reach it) — without a token, that's unauthenticated
|
||||
/// add/delete/search access to anyone who can reach the port.
|
||||
#[serde(default)]
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
impl Default for DaemonConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_level: default_log_level(),
|
||||
listen_addr: default_listen_addr(),
|
||||
db_path: default_db_path(),
|
||||
model_dir: default_model_dir(),
|
||||
api_token: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// qBittorrent WebUI connection. `base_url` empty means "not configured" —
|
||||
/// callers should error out rather than guessing a default.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct QbitConfig {
|
||||
#[serde(default)]
|
||||
pub base_url: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[serde(default = "default_qbit_category")]
|
||||
pub category: String,
|
||||
/// qBittorrent's own container-internal path prefix for its downloads
|
||||
/// (e.g. "/downloads"), when it runs in Docker while breadarr runs
|
||||
/// natively on the same host — needed to translate the paths qBittorrent
|
||||
/// reports via its API into paths breadarr can actually open. Both empty
|
||||
/// means no remapping (qBittorrent's reported paths are used as-is).
|
||||
#[serde(default)]
|
||||
pub container_downloads_path: String,
|
||||
#[serde(default)]
|
||||
pub host_downloads_path: String,
|
||||
}
|
||||
|
||||
/// Push-notification target for events that otherwise sit invisible until
|
||||
/// the TUI is next opened (a review-queue item, a run of import failures,
|
||||
/// the search-driven loop halting). `webhook_url` empty means "not
|
||||
/// configured" — no notifications sent, matching every other optional
|
||||
/// integration's default. The payload is a simple `{"title", "message"}`
|
||||
/// JSON body, which is directly Gotify's own message API shape (this
|
||||
/// user's actual self-hosted push service) and close enough to what most
|
||||
/// other self-hosted webhook receivers expect that this isn't tied to one
|
||||
/// specific service.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct NotificationsConfig {
|
||||
#[serde(default)]
|
||||
pub webhook_url: String,
|
||||
}
|
||||
|
||||
/// Jellyfin API connection. `base_url`/`api_key` empty means "not configured".
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct JellyfinConfig {
|
||||
#[serde(default)]
|
||||
pub base_url: String,
|
||||
#[serde(default)]
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// TVDB v4 API key, exchanged for a short-lived JWT at request time.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct TvdbConfig {
|
||||
#[serde(default)]
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// TMDB API Read Access Token (v4 auth), used directly as a bearer token.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct TmdbConfig {
|
||||
#[serde(default)]
|
||||
pub bearer_token: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = config_path();
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&path)?;
|
||||
let cfg: Config = toml::from_str(&raw)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub fn db_path(&self) -> PathBuf {
|
||||
expand_home(&self.daemon.db_path)
|
||||
}
|
||||
|
||||
pub fn model_dir(&self) -> PathBuf {
|
||||
expand_home(&self.daemon.model_dir)
|
||||
}
|
||||
|
||||
pub fn default_root_folder(&self) -> PathBuf {
|
||||
expand_home(&self.library.default_root_folder)
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
|
||||
return Path::new(&xdg).join("breadarr").join("breadarrd.toml");
|
||||
}
|
||||
|
||||
expand_home("~/.config/breadarr/breadarrd.toml")
|
||||
}
|
||||
|
||||
fn expand_home(input: &str) -> PathBuf {
|
||||
if let Some(stripped) = input.strip_prefix("~/") {
|
||||
if let Ok(home) = env::var("HOME") {
|
||||
return Path::new(&home).join(stripped);
|
||||
}
|
||||
}
|
||||
PathBuf::from(input)
|
||||
}
|
||||
|
||||
fn default_log_level() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
|
||||
fn default_listen_addr() -> String {
|
||||
"127.0.0.1:7879".to_string()
|
||||
}
|
||||
|
||||
fn default_db_path() -> String {
|
||||
"~/.local/share/breadarr/breadarr.db".to_string()
|
||||
}
|
||||
|
||||
fn default_model_dir() -> String {
|
||||
"~/.cache/breadarr/models/all-MiniLM-L6-v2".to_string()
|
||||
}
|
||||
|
||||
fn default_qbit_category() -> String {
|
||||
"breadarr".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_has_expected_values() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.daemon.log_level, "info");
|
||||
assert_eq!(cfg.daemon.listen_addr, "127.0.0.1:7879");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_falls_back_to_default_when_file_missing() {
|
||||
// SAFETY: single-threaded test setting an isolated var it also restores below.
|
||||
unsafe {
|
||||
env::set_var("XDG_CONFIG_HOME", "/tmp/breadarr-test-nonexistent-dir");
|
||||
}
|
||||
let cfg = Config::load().unwrap();
|
||||
assert_eq!(cfg.daemon.log_level, "info");
|
||||
unsafe {
|
||||
env::remove_var("XDG_CONFIG_HOME");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_partial_toml_with_defaults() {
|
||||
let cfg: Config = toml::from_str("[daemon]\nlog_level = \"debug\"\n").unwrap();
|
||||
assert_eq!(cfg.daemon.log_level, "debug");
|
||||
assert_eq!(cfg.daemon.listen_addr, "127.0.0.1:7879");
|
||||
}
|
||||
}
|
||||
265
breadarr-shared/src/dto.rs
Normal file
265
breadarr-shared/src/dto.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaItemSummary {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
pub monitored: bool,
|
||||
pub episode_count: i64,
|
||||
pub missing_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EpisodeSummary {
|
||||
pub id: i64,
|
||||
pub season_number: i64,
|
||||
pub episode_number: i64,
|
||||
pub title: Option<String>,
|
||||
pub air_date: Option<String>,
|
||||
pub monitored: bool,
|
||||
pub has_file: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaItemDetail {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
pub monitored: bool,
|
||||
pub root_folder: String,
|
||||
pub episodes: Vec<EpisodeSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReleaseSummary {
|
||||
pub id: i64,
|
||||
pub media_title: String,
|
||||
pub raw_title: String,
|
||||
pub score: Option<f64>,
|
||||
pub status: String,
|
||||
pub grabbed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReviewQueueEntry {
|
||||
pub id: i64,
|
||||
pub raw_release_title: String,
|
||||
pub candidate_media_title: Option<String>,
|
||||
pub confidence: f64,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StalledGrab {
|
||||
pub release_id: i64,
|
||||
pub media_title: String,
|
||||
pub raw_title: String,
|
||||
pub grabbed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaxedSearchTarget {
|
||||
pub media_item_id: i64,
|
||||
pub media_title: String,
|
||||
pub search_count: i64,
|
||||
pub last_searched_at: Option<String>,
|
||||
}
|
||||
|
||||
/// The daily "why don't I have this yet" answer — grabs that have been
|
||||
/// sitting without importing longer than expected, how deep the review
|
||||
/// queue has backed up, and search targets that have been failing every
|
||||
/// attempt for so long their backoff has hit its ceiling.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StuckReport {
|
||||
pub stalled_grabs: Vec<StalledGrab>,
|
||||
pub review_queue_depth: i64,
|
||||
pub maxed_out_search_targets: Vec<MaxedSearchTarget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResult {
|
||||
pub external_id: String,
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddSeriesRequest {
|
||||
pub tvdb_id: String,
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
pub aliases: Vec<String>,
|
||||
pub root_folder: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddSeriesResponse {
|
||||
pub media_item_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddMovieRequest {
|
||||
pub tmdb_id: String,
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
pub root_folder: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddMovieResponse {
|
||||
pub media_item_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchNowResult {
|
||||
pub targets: usize,
|
||||
pub searched: usize,
|
||||
pub grabbed: usize,
|
||||
pub errors: usize,
|
||||
pub source_exhausted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CalendarEntry {
|
||||
pub media_item_id: i64,
|
||||
pub media_title: String,
|
||||
pub season_number: i64,
|
||||
pub episode_number: i64,
|
||||
pub title: Option<String>,
|
||||
pub air_date: String,
|
||||
pub monitored: bool,
|
||||
pub has_file: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthDetail {
|
||||
pub status: String,
|
||||
pub last_grab_cycle: Option<CycleInfo>,
|
||||
pub last_import_cycle: Option<CycleInfo>,
|
||||
pub last_search_cycle: Option<CycleInfo>,
|
||||
pub last_upgrade_cycle: Option<CycleInfo>,
|
||||
pub search_halted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CycleInfo {
|
||||
pub at: chrono::DateTime<chrono::Utc>,
|
||||
pub ok: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// One file flagged by a `media_file_probe` category — reused across every
|
||||
/// flag list in `LibraryHealthReport` so a client can render them all with
|
||||
/// the same widget.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlaggedFile {
|
||||
pub episode_file_id: i64,
|
||||
pub media_title: String,
|
||||
pub episode_label: Option<String>,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DuplicateGroup {
|
||||
pub media_title: String,
|
||||
pub episode_label: Option<String>,
|
||||
pub paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CodecCount {
|
||||
pub codec: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LibrarySummary {
|
||||
pub total_files: i64,
|
||||
pub total_size_bytes: i64,
|
||||
pub probed_files: i64,
|
||||
pub by_video_codec: Vec<CodecCount>,
|
||||
pub sd_count: i64,
|
||||
pub hd_720p_count: i64,
|
||||
pub full_hd_1080p_count: i64,
|
||||
pub uhd_4k_count: i64,
|
||||
pub pct_with_subtitles: f64,
|
||||
}
|
||||
|
||||
/// One search result scored (or gate-rejected) for manual review — the
|
||||
/// same candidate a search cycle would evaluate automatically, surfaced
|
||||
/// before a grab decision is made instead of after. `link`/`guid`/
|
||||
/// `source_id` are carried through so a chosen candidate can be grabbed
|
||||
/// directly without re-searching.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReleaseCandidate {
|
||||
pub raw_title: String,
|
||||
pub link: String,
|
||||
pub guid: String,
|
||||
pub source_id: i64,
|
||||
pub source_name: String,
|
||||
pub seeders: Option<u32>,
|
||||
pub leechers: Option<u32>,
|
||||
pub size_bytes: Option<u64>,
|
||||
/// `None` when gate-rejected — `rejected_reason` explains why.
|
||||
pub score: Option<f32>,
|
||||
pub rejected_reason: Option<String>,
|
||||
pub resolution: Option<u32>,
|
||||
pub is_repack: bool,
|
||||
pub is_season_pack: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GrabCandidateRequest {
|
||||
pub raw_title: String,
|
||||
pub link: String,
|
||||
pub guid: String,
|
||||
pub source_id: i64,
|
||||
}
|
||||
|
||||
/// Every scoring axis `QualityProfile::Weights` has, mirrored 1:1 for the
|
||||
/// wire — used both to show the currently-effective (defaults-plus-stored-
|
||||
/// override) values and to submit a full replacement set when the user
|
||||
/// edits one.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct WeightsDto {
|
||||
pub seeder: f32,
|
||||
pub resolution_tier: f32,
|
||||
pub source_tier: f32,
|
||||
pub codec_tier: f32,
|
||||
pub bit_depth: f32,
|
||||
pub container: f32,
|
||||
pub group_allowlist: f32,
|
||||
pub repack: f32,
|
||||
pub hdr: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QualityProfileSummary {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
/// Always the fully-resolved values (built-in defaults with any stored
|
||||
/// override already applied) — never a partial/sparse object, so the
|
||||
/// TUI always has something concrete to display and re-submit.
|
||||
pub weights: WeightsDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateQualityProfileWeightsRequest {
|
||||
pub weights: WeightsDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LibraryHealthReport {
|
||||
pub corrupt_files: Vec<FlaggedFile>,
|
||||
pub under_quality_files: Vec<FlaggedFile>,
|
||||
pub no_subtitle_files: Vec<FlaggedFile>,
|
||||
pub no_english_audio_files: Vec<FlaggedFile>,
|
||||
pub non_english_default_audio_files: Vec<FlaggedFile>,
|
||||
pub duplicate_groups: Vec<DuplicateGroup>,
|
||||
pub summary: LibrarySummary,
|
||||
}
|
||||
6
breadarr-shared/src/lib.rs
Normal file
6
breadarr-shared/src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod dto;
|
||||
|
||||
pub use client::DaemonClient;
|
||||
pub use config::Config;
|
||||
Loading…
Add table
Add a link
Reference in a new issue