use axum::{extract::State, Json}; use serde::{Deserialize, Serialize}; use sqlx::Row; use utoipa::ToSchema; use crate::{error::ApiError, state::AppState}; // ─── Types ────────────────────────────────────────────────────────────────── #[derive(Deserialize, ToSchema)] pub struct ProwlarrSearchRequest { pub series_name: String, pub volume_number: Option, } #[derive(Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProwlarrRelease { pub guid: String, pub title: String, pub size: i64, pub download_url: Option, pub indexer: Option, pub seeders: Option, pub leechers: Option, pub publish_date: Option, pub protocol: Option, pub info_url: Option, pub categories: Option>, } #[derive(Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProwlarrCategory { pub id: i32, pub name: Option, } #[derive(Serialize, ToSchema)] pub struct ProwlarrSearchResponse { pub results: Vec, pub query: String, } #[derive(Serialize, ToSchema)] pub struct ProwlarrTestResponse { pub success: bool, pub message: String, pub indexer_count: Option, } // ─── Config helper ────────────────────────────────────────────────────────── #[derive(Deserialize)] struct ProwlarrConfig { url: String, api_key: String, categories: Option>, } async fn load_prowlarr_config( pool: &sqlx::PgPool, ) -> Result<(String, String, Vec), ApiError> { let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'prowlarr'") .fetch_optional(pool) .await?; let row = row.ok_or_else(|| ApiError::bad_request("Prowlarr is not configured"))?; let value: serde_json::Value = row.get("value"); let config: ProwlarrConfig = serde_json::from_value(value) .map_err(|e| ApiError::internal(format!("invalid prowlarr config: {e}")))?; if config.url.is_empty() || config.api_key.is_empty() { return Err(ApiError::bad_request( "Prowlarr URL and API key must be configured in settings", )); } let url = config.url.trim_end_matches('/').to_string(); let categories = config.categories.unwrap_or_else(|| vec![7030, 7020]); Ok((url, config.api_key, categories)) } // ─── Handlers ─────────────────────────────────────────────────────────────── /// Search for releases on Prowlarr #[utoipa::path( post, path = "/prowlarr/search", tag = "prowlarr", request_body = ProwlarrSearchRequest, responses( (status = 200, body = ProwlarrSearchResponse), (status = 400, description = "Bad request or Prowlarr not configured"), (status = 401, description = "Unauthorized"), (status = 500, description = "Prowlarr connection error"), ), security(("Bearer" = [])) )] pub async fn search_prowlarr( State(state): State, Json(body): Json, ) -> Result, ApiError> { let (url, api_key, categories) = load_prowlarr_config(&state.pool).await?; let query = if let Some(vol) = body.volume_number { format!("\"{}\" {}", body.series_name, vol) } else { format!("\"{}\"", body.series_name) }; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?; let mut params: Vec<(&str, String)> = vec![ ("query", query.clone()), ("type", "search".to_string()), ]; for cat in &categories { params.push(("categories", cat.to_string())); } let resp = client .get(format!("{url}/api/v1/search")) .query(¶ms) .header("X-Api-Key", &api_key) .send() .await .map_err(|e| ApiError::internal(format!("Prowlarr request failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); return Err(ApiError::internal(format!( "Prowlarr returned {status}: {text}" ))); } let results: Vec = resp .json() .await .map_err(|e| ApiError::internal(format!("Failed to parse Prowlarr response: {e}")))?; Ok(Json(ProwlarrSearchResponse { results, query })) } /// Test connection to Prowlarr #[utoipa::path( get, path = "/prowlarr/test", tag = "prowlarr", responses( (status = 200, body = ProwlarrTestResponse), (status = 400, description = "Prowlarr not configured"), (status = 401, description = "Unauthorized"), ), security(("Bearer" = [])) )] pub async fn test_prowlarr( State(state): State, ) -> Result, ApiError> { let (url, api_key, _categories) = load_prowlarr_config(&state.pool).await?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?; let resp = client .get(format!("{url}/api/v1/indexer")) .header("X-Api-Key", &api_key) .send() .await; match resp { Ok(r) if r.status().is_success() => { let indexers: Vec = r.json().await.unwrap_or_default(); Ok(Json(ProwlarrTestResponse { success: true, message: format!("Connected successfully ({} indexers)", indexers.len()), indexer_count: Some(indexers.len() as i32), })) } Ok(r) => { let status = r.status(); let text = r.text().await.unwrap_or_default(); Ok(Json(ProwlarrTestResponse { success: false, message: format!("Prowlarr returned {status}: {text}"), indexer_count: None, })) } Err(e) => Ok(Json(ProwlarrTestResponse { success: false, message: format!("Connection failed: {e}"), indexer_count: None, })), } }