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 MissingVolumeInput { pub volume_number: Option, #[allow(dead_code)] pub title: Option, } #[derive(Deserialize, ToSchema)] pub struct ProwlarrSearchRequest { pub series_name: String, pub volume_number: Option, pub custom_query: Option, pub missing_volumes: Option>, } #[derive(Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProwlarrRawRelease { 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, 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>, #[serde(skip_serializing_if = "Option::is_none")] pub matched_missing_volumes: 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>, } pub(crate) async fn load_prowlarr_config_internal( pool: &sqlx::PgPool, ) -> Result<(String, String, Vec), ApiError> { load_prowlarr_config(pool).await } pub(crate) async fn check_prowlarr_configured(pool: &sqlx::PgPool) -> Result<(), ApiError> { load_prowlarr_config(pool).await.map(|_| ()) } pub(crate) fn extract_volumes_from_title_pub(title: &str) -> Vec { extract_volumes_from_title(title) } 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)) } // ─── Volume matching ───────────────────────────────────────────────────────── /// Extract volume numbers from a release title. /// /// Handles individual volumes (T01, Tome 01, Vol. 01, v01, #01) and also /// **range packs** like `T01.T15`, `[T001.T104]`, `T01-T15`, `Tome 01 à Tome 15` /// — the range is expanded so every volume in [start..=end] is returned. fn extract_volumes_from_title(title: &str) -> Vec { let lower = title.to_lowercase(); let chars: Vec = lower.chars().collect(); let mut volumes = Vec::new(); // Pass 1 — range expansion: PREFIX NUMBER (SEP) PREFIX NUMBER // Separator: '.' | '-' | 'à' let mut i = 0; while i < chars.len() { if let Some((n1, after1)) = read_vol_prefix_number(&chars, i) { let mut j = after1; while j < chars.len() && chars[j] == ' ' { j += 1; } let after_sep = if j < chars.len() && (chars[j] == '.' || chars[j] == '-') { Some(j + 1) } else if j < chars.len() && chars[j] == '\u{00e0}' { // 'à' (U+00E0) — French "à" as in "Tome 01 à Tome 15" Some(j + 1) } else { None }; if let Some(sep_end) = after_sep { let mut k = sep_end; while k < chars.len() && chars[k] == ' ' { k += 1; } if let Some((n2, _)) = read_vol_prefix_number(&chars, k) { if n1 < n2 && n2 - n1 <= 500 { for v in n1..=n2 { if !volumes.contains(&v) { volumes.push(v); } } i = after1; continue; } } } } i += 1; } // Pass 2 — individual volumes not already captured by range expansion let prefixes = ["tome", "vol.", "vol ", "t", "v", "#"]; let len = chars.len(); for prefix in &prefixes { let mut start = 0; while let Some(pos) = lower[start..].find(prefix) { let abs_pos = start + pos; let after = abs_pos + prefix.len(); // For single-char prefixes (t, v), ensure it's at a word boundary if prefix.len() == 1 && *prefix != "#" { if abs_pos > 0 && chars[abs_pos - 1].is_alphanumeric() { start = after; continue; } } // Skip optional spaces after prefix let mut i = after; while i < len && chars[i] == ' ' { i += 1; } // Read digits let digit_start = i; while i < len && chars[i].is_ascii_digit() { i += 1; } if i > digit_start { if let Ok(num) = lower[digit_start..i].parse::() { if !volumes.contains(&num) { volumes.push(num); } } } start = after; } } volumes } /// Try to read a vol-prefixed number starting at `pos` in the `chars` slice. /// Returns `(number, position_after_last_digit)` or `None`. /// Prefixes recognised (longest first to avoid "t" matching "tome"): /// `tome`, `vol.`, `vol `, `t`, `v`, `#`. fn read_vol_prefix_number(chars: &[char], pos: usize) -> Option<(i32, usize)> { if pos >= chars.len() { return None; } // Build a look-ahead string from `pos` (at most 6 chars is enough for the longest prefix "tome ") let suffix: String = chars[pos..].iter().collect(); const PREFIXES: &[(&str, bool)] = &[ ("tome", false), ("vol.", false), ("vol ", false), ("t", true), ("v", true), ("#", false), ]; let mut prefix_char_count = 0usize; for (p, needs_boundary) in PREFIXES { if suffix.starts_with(p) { if *needs_boundary && pos > 0 && chars[pos - 1].is_alphanumeric() { continue; } prefix_char_count = p.chars().count(); break; } } if prefix_char_count == 0 { return None; } let mut i = pos + prefix_char_count; while i < chars.len() && chars[i] == ' ' { i += 1; } let digit_start = i; while i < chars.len() && chars[i].is_ascii_digit() { i += 1; } if i == digit_start { return None; } let n: i32 = chars[digit_start..i] .iter() .collect::() .parse() .ok()?; Some((n, i)) } /// Match releases against missing volume numbers. fn match_missing_volumes( releases: Vec, missing: &[MissingVolumeInput], ) -> Vec { let missing_numbers: Vec = missing .iter() .filter_map(|m| m.volume_number) .collect(); releases .into_iter() .map(|r| { let matched = if missing_numbers.is_empty() { None } else { let title_volumes = extract_volumes_from_title(&r.title); let matched: Vec = title_volumes .into_iter() .filter(|v| missing_numbers.contains(v)) .collect(); if matched.is_empty() { None } else { Some(matched) } }; ProwlarrRelease { guid: r.guid, title: r.title, size: r.size, download_url: r.download_url, indexer: r.indexer, seeders: r.seeders, leechers: r.leechers, publish_date: r.publish_date, protocol: r.protocol, info_url: r.info_url, categories: r.categories, matched_missing_volumes: matched, } }) .collect() } // ─── 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(custom) = &body.custom_query { custom.clone() } else 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 raw_text = resp .text() .await .map_err(|e| ApiError::internal(format!("Failed to read Prowlarr response: {e}")))?; tracing::debug!("Prowlarr raw response length: {} chars", raw_text.len()); let raw_releases: Vec = serde_json::from_str(&raw_text) .map_err(|e| { tracing::error!("Failed to parse Prowlarr response: {e}"); tracing::error!("Raw response (first 500 chars): {}", &raw_text[..raw_text.len().min(500)]); ApiError::internal(format!("Failed to parse Prowlarr response: {e}")) })?; let results = if let Some(missing) = &body.missing_volumes { match_missing_volumes(raw_releases, missing) } else { raw_releases .into_iter() .map(|r| ProwlarrRelease { guid: r.guid, title: r.title, size: r.size, download_url: r.download_url, indexer: r.indexer, seeders: r.seeders, leechers: r.leechers, publish_date: r.publish_date, protocol: r.protocol, info_url: r.info_url, categories: r.categories, matched_missing_volumes: None, }) .collect() }; 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, })), } } #[cfg(test)] mod tests { use super::extract_volumes_from_title; fn sorted(mut v: Vec) -> Vec { v.sort_unstable(); v } #[test] fn individual_volumes() { assert_eq!(sorted(extract_volumes_from_title("One Piece T05")), vec![5]); assert_eq!(sorted(extract_volumes_from_title("Naruto Tome 12")), vec![12]); assert_eq!(sorted(extract_volumes_from_title("Vol.03")), vec![3]); assert_eq!(sorted(extract_volumes_from_title("v07")), vec![7]); } #[test] fn range_dot_separator() { // T01.T15 → 1..=15 let v = sorted(extract_volumes_from_title("One Piece T01.T15")); assert_eq!(v, (1..=15).collect::>()); } #[test] fn range_dot_with_brackets() { // [T001.T104] → 1..=104 let v = sorted(extract_volumes_from_title("Naruto [T001.T104]")); assert_eq!(v.len(), 104); assert_eq!(v[0], 1); assert_eq!(v[103], 104); } #[test] fn range_dash_separator() { // T01-T15 let v = sorted(extract_volumes_from_title("Dragon Ball T01-T10")); assert_eq!(v, (1..=10).collect::>()); } #[test] fn range_french_a_grave() { // Tome 01 à Tome 05 let v = sorted(extract_volumes_from_title("Astérix Tome 01 à Tome 05")); assert_eq!(v, vec![1, 2, 3, 4, 5]); } #[test] fn range_long_prefix() { // Tome01.Tome15 let v = sorted(extract_volumes_from_title("Naruto Tome01.Tome15")); assert_eq!(v, (1..=15).collect::>()); } #[test] fn no_false_positive_version_string() { // v2.0 should NOT be treated as a range let v = extract_volumes_from_title("tool v2.0 release"); assert!(!v.contains(&0) || v.len() == 1); // only v2 at most } }