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

View 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(())
}
}

View 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
View 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,
}

View file

@ -0,0 +1,6 @@
pub mod client;
pub mod config;
pub mod dto;
pub use client::DaemonClient;
pub use config::Config;