diff --git a/Cargo.lock b/Cargo.lock index 7eef4a6..c1bc5e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,6 +379,7 @@ dependencies = [ "serde", "serde_json", "sonarr_api", + "sportarr_api", "tokio", "toml", "tracing", @@ -1822,6 +1823,16 @@ dependencies = [ "url", ] +[[package]] +name = "sportarr_api" +version = "1.0.0" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index c0e7367..6be78ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["doplarr", "radarr_api", "sonarr_api", "seerr_api"] +members = ["doplarr", "radarr_api", "sonarr_api", "seerr_api", "sportarr_api"] resolver = "3" [workspace.package] diff --git a/config.example.toml b/config.example.toml index 03d7db1..6c1e470 100644 --- a/config.example.toml +++ b/config.example.toml @@ -193,3 +193,27 @@ api_key = "your_sonarr_api_key" # url = "http://localhost:5055" # api_key = "your_seerr_admin_api_key" # media_filter = "tv" + +# ------------------------------------------------------------------------------ +# SPORTARR BACKEND (Sports leagues) +# ------------------------------------------------------------------------------ +# Adds a /request sport command: users search the Sportarr league catalog +# and requested leagues are added monitored, so new events get grabbed as +# they air. + +# [[backends]] +# media = "sport" +# +# [backends.config.Sportarr] +# url = "http://localhost:1867" +# api_key = "your_sportarr_api_key" + +# All settings below are OPTIONAL +# If omitted, users will select them at runtime via Discord UI + +# Quality profile name (must match exactly what's in Sportarr) +# quality_profile = "WEB-1080p" + +# Note: root folder and monitoring scope follow Sportarr's own defaults +# (per-root-folder default profiles and the league's Future monitor type), +# so those are never asked in Discord. diff --git a/doplarr/Cargo.toml b/doplarr/Cargo.toml index 81e2de2..ec18ded 100644 --- a/doplarr/Cargo.toml +++ b/doplarr/Cargo.toml @@ -20,6 +20,7 @@ async-trait = "0.1" # Backend APIs radarr_api = { path = "../radarr_api" } sonarr_api = { path = "../sonarr_api" } +sportarr_api = { path = "../sportarr_api" } seerr_api = { path = "../seerr_api" } # Discord libraries diff --git a/doplarr/src/config.rs b/doplarr/src/config.rs index 57345f5..6d891c8 100644 --- a/doplarr/src/config.rs +++ b/doplarr/src/config.rs @@ -64,6 +64,13 @@ pub enum BackendConfig { /// Offer an "All Seasons" option in the season picker (default: true) allow_all_seasons: Option, }, + Sportarr { + url: String, + api_key: String, + /// Pin every request to this quality profile by name; when absent, the + /// requester picks from a dropdown + quality_profile: Option, + }, } /// Starter config written when no config file exists and no migration @@ -100,6 +107,14 @@ discord_token = "your_discord_bot_token" # [backends.config.Radarr] # url = "http://localhost:7878" # api_key = "${RADARR_API_KEY}" + +# --- Sportarr --- +# [[backends]] +# media = "sport" +# +# [backends.config.Sportarr] +# url = "http://localhost:1867" +# api_key = "${SPORTARR_API_KEY}" "#; /// Expand `${VAR}` references against the process environment. Expansion diff --git a/doplarr/src/main.rs b/doplarr/src/main.rs index 9deb629..6a971e0 100644 --- a/doplarr/src/main.rs +++ b/doplarr/src/main.rs @@ -4,6 +4,7 @@ use config::{Backend, BackendConfig}; use discord::InteractionContinue; use providers::{ MediaBackend, UserFacingError, radarr::Radarr, seerr::Seerr as SeerrBackend, sonarr::Sonarr, + sportarr::Sportarr, }; use std::{ collections::{HashMap, HashSet}, @@ -114,6 +115,9 @@ async fn main() -> anyhow::Result<()> { BackendConfig::Seerr { .. } => { Arc::new(SeerrBackend::connect(config.clone(), backend_http.clone()).await?) } + BackendConfig::Sportarr { .. } => { + Arc::new(Sportarr::connect(config.clone(), backend_http.clone()).await?) + } }; backends.insert(media.as_str(), backend); } diff --git a/doplarr/src/providers/mod.rs b/doplarr/src/providers/mod.rs index 91b2daa..296f21b 100644 --- a/doplarr/src/providers/mod.rs +++ b/doplarr/src/providers/mod.rs @@ -27,6 +27,7 @@ mod api_logging; pub mod radarr; pub mod seerr; pub mod sonarr; +pub mod sportarr; /// Sentinel id for an "All Seasons" entry in a season multi-select. Real season /// numbers are >= 0, so -1 never collides. The Discord layer treats an option diff --git a/doplarr/src/providers/sportarr.rs b/doplarr/src/providers/sportarr.rs new file mode 100644 index 0000000..49ab872 --- /dev/null +++ b/doplarr/src/providers/sportarr.rs @@ -0,0 +1,368 @@ +use super::*; +use crate::config::BackendConfig; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use sportarr_api::{ + apis::{ + Error as SportarrApiError, + configuration::Configuration, + leagues_api::{ + api_leagues_get, api_leagues_post, api_leagues_search_get, api_qualityprofile_get, + }, + }, + models::{AddLeagueRequest, CatalogLeague, QualityProfile}, +}; +use tracing::{debug, error, info}; + +mod field_keys { + pub const QUALITY_PROFILE: &str = "sportarr:quality_profile"; +} + +fn log_api_error(err: &SportarrApiError, context: &str) { + match err { + SportarrApiError::ResponseError(response) => { + super::api_logging::log_api_error_details(response.status, &response.content, context); + } + SportarrApiError::Reqwest(e) => { + error!("{} - Reqwest error: {}", context, e); + } + SportarrApiError::Serde(e) => { + error!("{} - Serialization error: {}", context, e); + } + } +} + +#[derive(Debug, Clone)] +pub struct Sportarr { + config: Configuration, + details: Details, +} + +#[derive(Debug, Clone)] +struct Details { + quality_profiles: Vec, +} + +/// A catalog league enriched with whether it already exists in the library. +/// Sportarr's catalog search doesn't carry library state, so `search` +/// cross-references the library list once per search. +#[derive(Debug, Clone)] +pub struct SportarrMedia { + league: CatalogLeague, + /// (library id, monitored) when the league is already added + existing: Option<(i32, bool)>, +} + +impl MediaItem for SportarrMedia { + fn to_dropdown(&self) -> DropdownOption { + let mut description = self.league.str_sport.clone(); + if let Some(country) = &self.league.str_country + && !country.is_empty() + { + description = format!("{description} · {country}"); + } + DropdownOption { + title: self.league.str_league.clone(), + description: Some(description), + id: Some(SelectableId::String(self.league.id_league.clone())), + } + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + +impl Sportarr { + /// Builds the Sportarr connection and attempts to use it + pub async fn new( + base_path: String, + key: String, + quality_profile: Option, + client: reqwest::Client, + ) -> Result { + info!("Connecting to Sportarr at {}", base_path); + + let config = Configuration { + base_path, + user_agent: None, + client, + api_key: Some(key), + }; + + // This will fail fast if the server is unreachable or the key is wrong + let mut quality_profiles = api_qualityprofile_get(&config).await.inspect_err(|e| { + log_api_error(e, "Failed to get quality profiles from Sportarr"); + })?; + debug!("Retrieved {} quality profiles", quality_profiles.len()); + + // Pin the quality profile if configured + if let Some(qp) = quality_profile { + let qp_idx = quality_profiles + .iter() + .position(|x| x.name == qp) + .with_context(|| { + let available = quality_profiles + .iter() + .map(|x| x.name.as_str()) + .collect::>() + .join(", "); + format!( + "Quality profile '{}' not found. Available options: [{}]", + qp, available + ) + })?; + let selected = quality_profiles.swap_remove(qp_idx); + quality_profiles = vec![selected]; + } + + Ok(Self { + config, + details: Details { quality_profiles }, + }) + } + + pub async fn connect(backend: BackendConfig, client: reqwest::Client) -> Result { + if let BackendConfig::Sportarr { + url, + api_key, + quality_profile, + } = backend + { + Self::new(url, api_key, quality_profile, client).await + } else { + bail!("Configured backend not for Sportarr"); + } + } +} + +impl From
for Vec { + fn from(details: Details) -> Vec { + let quality_profile_options = details + .quality_profiles + .iter() + .map(|x| DropdownOption { + title: x.name.clone(), + description: None, + id: Some(SelectableId::Integer(x.id)), + }) + .collect(); + + vec![RequestDetails { + title: "Quality Profile".to_string(), + options: quality_profile_options, + metadata: Some(field_keys::QUALITY_PROFILE.to_string()), + selected_indices: vec![], + field_type: FieldType::Dropdown, + always_show: false, + }] + } +} + +#[async_trait] +impl MediaBackend for Sportarr { + async fn search(&self, term: &str) -> Result>> { + info!("Searching Sportarr for league: {}", term); + let results = api_leagues_search_get(&self.config, term) + .await + .inspect_err(|e| { + log_api_error(e, "Failed to search Sportarr"); + })?; + debug!("Found {} league results", results.len()); + + // Cross-reference the library so already-added leagues are known + let library = api_leagues_get(&self.config).await.inspect_err(|e| { + log_api_error(e, "Failed to list Sportarr library leagues"); + })?; + + Ok(results + .into_iter() + .map(|league| { + let existing = library + .iter() + .find(|l| { + l.external_id.as_deref() == Some(league.id_league.as_str()) + && !league.id_league.is_empty() + }) + .map(|l| (l.id, l.monitored)); + Box::new(SportarrMedia { league, existing }) as Box + }) + .collect()) + } + + fn early_stop(&self, media: &dyn MediaItem) -> bool { + let Some(media) = media.as_any().downcast_ref::() else { + error!("early_stop called with wrong media type for Sportarr backend"); + return false; + }; + + if let Some((id, true)) = media.existing { + info!(league_id = id, "League already monitored"); + return true; + } + + false + } + + fn display_info(&self, media: &dyn MediaItem) -> MediaDisplayInfo { + let Some(media) = media.as_any().downcast_ref::() else { + error!("display_info called with wrong media type for Sportarr backend"); + return MediaDisplayInfo { + title: String::new(), + subtitle: None, + description: None, + thumbnail_url: None, + }; + }; + + let mut subtitle = media.league.str_sport.clone(); + if let Some(year) = &media.league.int_formed_year + && !year.is_empty() + { + subtitle = format!("{subtitle} · est. {year}"); + } + + MediaDisplayInfo { + title: media.league.str_league.clone(), + subtitle: Some(subtitle), + description: media.league.str_description_en.clone(), + thumbnail_url: media + .league + .str_badge + .clone() + .or_else(|| media.league.str_poster.clone()), + } + } + + async fn additional_details(&self, media: &dyn MediaItem) -> Result> { + let media = media + .as_any() + .downcast_ref::() + .context("Invalid media type for Sportarr")?; + + if let Some((_, false)) = media.existing { + // Re-monitoring an existing league is a deliberate library + // decision (it may have been unmonitored for a reason), so send + // the user to Sportarr instead of silently flipping it here. + bail!(UserFacingError(format!( + "{} is already in the library but unmonitored. Enable monitoring in Sportarr to resume grabbing it.", + media.league.str_league + ))); + } + + // New league: the only decision to collect is the quality profile + // (root folder and monitoring scope follow Sportarr's own defaults) + Ok(self.details.clone().into()) + } + + async fn request( + &self, + details: Vec, + media: Box, + requester_discord_id: u64, + ) -> Result<()> { + let media = media + .into_any() + .downcast::() + .ok() + .context("Invalid media type for Sportarr")?; + + let mut quality_profile_id = None; + for detail in &details { + let Some(selection) = detail.selected_option() else { + bail!("No option was selected for '{}'", detail.title); + }; + match detail.metadata.as_deref() { + Some(field_keys::QUALITY_PROFILE) => { + quality_profile_id = match &selection.id { + Some(SelectableId::Integer(i)) => Some(*i), + other => bail!("Quality profile must have an integer ID, got {other:?}"), + }; + } + other => bail!("Unknown metadata key: {other:?}"), + } + } + + info!( + league = %media.league.str_league, + requester = requester_discord_id, + "Adding league to Sportarr" + ); + + let body = AddLeagueRequest { + external_id: (!media.league.id_league.is_empty()) + .then(|| media.league.id_league.clone()), + name: media.league.str_league.clone(), + sport: media.league.str_sport.clone(), + country: media.league.str_country.clone(), + description: media.league.str_description_en.clone(), + monitored: true, + quality_profile_id, + }; + + api_leagues_post(&self.config, &body) + .await + .inspect_err(|e| { + log_api_error(e, "Failed to add league to Sportarr"); + })?; + + Ok(()) + } + + fn success_message( + &self, + _details: &[RequestDetails], + media: &dyn MediaItem, + ) -> SuccessMessage { + let Some(media) = media.as_any().downcast_ref::() else { + error!("success_message called with wrong media type for Sportarr backend"); + return SuccessMessage { + summary: "Request submitted".into(), + description: "Will be downloaded when available.".into(), + thumbnail_url: None, + embed_data: None, + }; + }; + + let overview = media.league.str_description_en.clone().unwrap_or_default(); + let external_url = media + .league + .str_website + .clone() + .filter(|w| !w.is_empty()) + .map(|w| { + if w.starts_with("http") { + w + } else { + format!("https://{w}") + } + }) + .unwrap_or_else(|| "https://github.com/Sportarr/Sportarr".to_string()); + + SuccessMessage { + summary: format!("{} has been requested!", media.league.str_league), + description: "New events will be grabbed as they become available.".into(), + thumbnail_url: media.league.str_badge.clone(), + embed_data: Some(EmbedData { + title: media.league.str_league.clone(), + media_type: "league", + overview, + poster_url: media + .league + .str_poster + .clone() + .or_else(|| media.league.str_badge.clone()) + .unwrap_or_default(), + genres: vec![media.league.str_sport.clone()], + runtime_minutes: None, + studio_or_network: media.league.str_country.clone(), + director: None, + external_url, + }), + } + } +} diff --git a/sportarr_api/Cargo.toml b/sportarr_api/Cargo.toml new file mode 100644 index 0000000..4b2fb46 --- /dev/null +++ b/sportarr_api/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "sportarr_api" +version = "1.0.0" +description = "Minimal hand-written client for the Sportarr API (league search, add, profiles, root folders)" +license = "MIT OR Apache-2.0" +edition = "2021" + +[dependencies] +serde = { version = "^1.0", features = ["derive"] } +serde_json = "^1.0" +percent-encoding = "2" +reqwest = { version = "^0.13", default-features = false, features = [ + "json", + "query", +] } + +[features] +default = ["rustls-tls"] +native-tls = ["reqwest/native-tls"] +rustls-tls = ["reqwest/rustls"] diff --git a/sportarr_api/src/apis/configuration.rs b/sportarr_api/src/apis/configuration.rs new file mode 100644 index 0000000..700fd08 --- /dev/null +++ b/sportarr_api/src/apis/configuration.rs @@ -0,0 +1,21 @@ +#[derive(Debug, Clone)] +pub struct Configuration { + /// Base url including any url base, without a trailing slash, + /// e.g. `http://localhost:1867` + pub base_path: String, + pub user_agent: Option, + pub client: reqwest::Client, + /// Sent as the `X-Api-Key` header on every request. + pub api_key: Option, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + base_path: "http://localhost:1867".to_owned(), + user_agent: Some("sportarr_api/1.0.0/rust".to_owned()), + client: reqwest::Client::new(), + api_key: None, + } + } +} diff --git a/sportarr_api/src/apis/leagues_api.rs b/sportarr_api/src/apis/leagues_api.rs new file mode 100644 index 0000000..ccf76fd --- /dev/null +++ b/sportarr_api/src/apis/leagues_api.rs @@ -0,0 +1,70 @@ +use super::{configuration::Configuration, Error, ResponseContent}; +use crate::models; + +async fn execute(req: reqwest::RequestBuilder) -> Result { + let resp = req.send().await?; + let status = resp.status(); + let content = resp.text().await?; + + if status.is_success() { + Ok(serde_json::from_str(&content)?) + } else { + Err(Error::ResponseError(ResponseContent { status, content })) + } +} + +fn request(config: &Configuration, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + let mut req = config + .client + .request(method, format!("{}{}", config.base_path, path)); + if let Some(ua) = &config.user_agent { + req = req.header(reqwest::header::USER_AGENT, ua); + } + if let Some(key) = &config.api_key { + req = req.header("X-Api-Key", key); + } + req +} + +/// `GET /api/leagues/search/{query}` - search the metadata catalog for +/// leagues to add. +pub async fn api_leagues_search_get( + config: &Configuration, + query: &str, +) -> Result, Error> { + // Path-segment encoding: form encoding would turn spaces into `+`, which + // the server takes literally and multi-word searches silently miss. + let encoded = + percent_encoding::utf8_percent_encode(query, percent_encoding::NON_ALPHANUMERIC); + execute(request( + config, + reqwest::Method::GET, + &format!("/api/leagues/search/{encoded}"), + )) + .await +} + +/// `GET /api/leagues` - leagues already in the library. +pub async fn api_leagues_get(config: &Configuration) -> Result, Error> { + execute(request(config, reqwest::Method::GET, "/api/leagues")).await +} + +/// `GET /api/qualityprofile` +pub async fn api_qualityprofile_get( + config: &Configuration, +) -> Result, Error> { + execute(request(config, reqwest::Method::GET, "/api/qualityprofile")).await +} + +/// `GET /api/rootfolder` +pub async fn api_rootfolder_get(config: &Configuration) -> Result, Error> { + execute(request(config, reqwest::Method::GET, "/api/rootfolder")).await +} + +/// `POST /api/leagues` - add a league to the library. +pub async fn api_leagues_post( + config: &Configuration, + body: &models::AddLeagueRequest, +) -> Result { + execute(request(config, reqwest::Method::POST, "/api/leagues").json(body)).await +} diff --git a/sportarr_api/src/apis/mod.rs b/sportarr_api/src/apis/mod.rs new file mode 100644 index 0000000..6cad81b --- /dev/null +++ b/sportarr_api/src/apis/mod.rs @@ -0,0 +1,47 @@ +pub mod configuration; +pub mod leagues_api; + +use std::fmt; + +#[derive(Debug)] +pub enum Error { + Reqwest(reqwest::Error), + Serde(serde_json::Error), + ResponseError(ResponseContent), +} + +#[derive(Debug)] +pub struct ResponseContent { + pub status: reqwest::StatusCode, + pub content: String, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Reqwest(e) => write!(f, "error in reqwest: {e}"), + Error::Serde(e) => write!(f, "error in serde: {e}"), + Error::ResponseError(r) => { + write!( + f, + "error in response: status code {}: {}", + r.status, r.content + ) + } + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(e: reqwest::Error) -> Self { + Error::Reqwest(e) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::Serde(e) + } +} diff --git a/sportarr_api/src/lib.rs b/sportarr_api/src/lib.rs new file mode 100644 index 0000000..f63f2b5 --- /dev/null +++ b/sportarr_api/src/lib.rs @@ -0,0 +1,10 @@ +//! Minimal hand-written client for the Sportarr API. +//! +//! Sportarr does not publish an OpenAPI document for its native API, so +//! unlike the generated `sonarr_api`/`radarr_api`/`seerr_api` crates this +//! is a small hand-written subset covering exactly what the request flow +//! needs: league catalog search, the already-added league list, quality +//! profiles, root folders, and adding a league. + +pub mod apis; +pub mod models; diff --git a/sportarr_api/src/models/mod.rs b/sportarr_api/src/models/mod.rs new file mode 100644 index 0000000..82392fc --- /dev/null +++ b/sportarr_api/src/models/mod.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +/// A league from the catalog search (`GET /api/leagues/search/{query}`). +/// Field names follow Sportarr's metadata-catalog conventions. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogLeague { + #[serde(default)] + pub id_league: String, + #[serde(default)] + pub str_league: String, + #[serde(default)] + pub str_sport: String, + pub str_league_alternate: Option, + pub int_formed_year: Option, + pub str_country: Option, + #[serde(rename = "strDescriptionEN")] + pub str_description_en: Option, + pub str_badge: Option, + pub str_logo: Option, + pub str_banner: Option, + pub str_poster: Option, + pub str_website: Option, +} + +/// A league already in the library (`GET /api/leagues`). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryLeague { + pub id: i32, + pub external_id: Option, + #[serde(default)] + pub name: String, + #[serde(default)] + pub sport: String, + #[serde(default)] + pub monitored: bool, + pub quality_profile_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QualityProfile { + pub id: i32, + #[serde(default)] + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RootFolder { + pub id: i32, + #[serde(default)] + pub path: String, +} + +/// Body for `POST /api/leagues`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AddLeagueRequest { + pub external_id: Option, + pub name: String, + pub sport: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub monitored: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality_profile_id: Option, +} + +/// Response of `POST /api/leagues` (subset). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddedLeague { + /// Sportarr answers `POST /api/leagues` with a confirmation envelope, + /// not the created entity: `{message, leagueId, monitored}`. + pub league_id: Option, + #[serde(default)] + pub message: String, + #[serde(default)] + pub monitored: bool, +}