From da96e1ea0aa2e5bcb70e686c3b1feaa99121c8e1 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sat, 28 Mar 2026 13:42:23 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20r=C3=A9soudre=20les=20URLs=20Prowlarr=20?= =?UTF-8?q?avant=20envoi=20=C3=A0=20qBittorrent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qBittorrent ne suit pas les redirections 301 des URLs proxy Prowlarr. L'API résout maintenant les redirections elle-même : si l'URL mène à un magnet on le passe directement, si c'est un .torrent on l'uploade en multipart. Ajoute aussi des logs sur tous les points d'échec du endpoint /qbittorrent/add et un User-Agent "Stripstream-Librarian" sur les appels Prowlarr. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/download_detection.rs | 1 + apps/api/src/prowlarr.rs | 2 + apps/api/src/qbittorrent.rs | 173 +++++++++++++++++++++++++---- 3 files changed, 156 insertions(+), 20 deletions(-) diff --git a/apps/api/src/download_detection.rs b/apps/api/src/download_detection.rs index 6af3ed4..1dbb241 100644 --- a/apps/api/src/download_detection.rs +++ b/apps/api/src/download_detection.rs @@ -490,6 +490,7 @@ pub(crate) async fn process_download_detection( let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) + .user_agent("Stripstream-Librarian") .build() .map_err(|e| format!("failed to build HTTP client: {e}"))?; diff --git a/apps/api/src/prowlarr.rs b/apps/api/src/prowlarr.rs index 1e49014..27169e8 100644 --- a/apps/api/src/prowlarr.rs +++ b/apps/api/src/prowlarr.rs @@ -377,6 +377,7 @@ pub async fn search_prowlarr( let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) + .user_agent("Stripstream-Librarian") .build() .map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?; @@ -466,6 +467,7 @@ pub async fn test_prowlarr( let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) + .user_agent("Stripstream-Librarian") .build() .map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?; diff --git a/apps/api/src/qbittorrent.rs b/apps/api/src/qbittorrent.rs index 5696168..356f701 100644 --- a/apps/api/src/qbittorrent.rs +++ b/apps/api/src/qbittorrent.rs @@ -137,14 +137,47 @@ pub async fn add_torrent( && body.series_name.is_some() && body.expected_volumes.is_some(); - let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?; + tracing::info!("[QBITTORRENT] Add torrent request: url={}, managed={is_managed}", body.url); + + let (base_url, username, password) = load_qbittorrent_config(&state.pool).await.map_err(|e| { + tracing::error!("[QBITTORRENT] Failed to load config: {}", e.message); + e + })?; let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) .build() - .map_err(|e| ApiError::internal(format!("failed to build HTTP client: {e}")))?; + .map_err(|e| { + tracing::error!("[QBITTORRENT] Failed to build HTTP client: {e}"); + ApiError::internal(format!("failed to build HTTP client: {e}")) + })?; - let sid = qbittorrent_login(&client, &base_url, &username, &password).await?; + // Resolve the URL: if it's an HTTP(S) link (e.g. Prowlarr proxy), follow redirects + // and download the .torrent file ourselves, since qBittorrent may not handle redirects. + // If the final URL is a magnet, use it directly. + let resolved = resolve_torrent_url(&body.url).await.map_err(|e| { + tracing::error!("[QBITTORRENT] Failed to resolve torrent URL: {e}"); + ApiError::internal(format!("Failed to resolve torrent URL: {e}")) + })?; + let resolved_magnet_url; // keep owned String alive for borrowing + let torrent_bytes: Option>; + match resolved { + ResolvedTorrent::Magnet(m) => { + tracing::info!("[QBITTORRENT] Resolved to magnet link"); + resolved_magnet_url = Some(m); + torrent_bytes = None; + } + ResolvedTorrent::TorrentFile(bytes) => { + tracing::info!("[QBITTORRENT] Resolved to .torrent file ({} bytes)", bytes.len()); + resolved_magnet_url = None; + torrent_bytes = Some(bytes); + } + } + + let sid = qbittorrent_login(&client, &base_url, &username, &password).await.map_err(|e| { + tracing::error!("[QBITTORRENT] Login failed: {}", e.message); + e + })?; // Pre-generate the download ID; use a unique category per download so we can // reliably match the torrent back (tags/savepath are unreliable on qBittorrent 4.x). @@ -161,26 +194,52 @@ pub async fn add_torrent( .await; } - let mut form_params: Vec<(&str, &str)> = vec![("urls", &body.url)]; let savepath = "/downloads"; - if is_managed { - form_params.push(("savepath", savepath)); - if let Some(ref cat) = category { - form_params.push(("category", cat)); - } - } - let resp = client - .post(format!("{base_url}/api/v2/torrents/add")) - .header("Cookie", format!("SID={sid}")) - .form(&form_params) - .send() - .await - .map_err(|e| ApiError::internal(format!("qBittorrent add request failed: {e}")))?; + let resp = if let Some(ref torrent_data) = torrent_bytes { + // Upload .torrent file via multipart + let mut form = reqwest::multipart::Form::new() + .part("torrents", reqwest::multipart::Part::bytes(torrent_data.clone()) + .file_name("torrent.torrent") + .mime_str("application/x-bittorrent") + .unwrap()); + if is_managed { + form = form.text("savepath", savepath.to_string()); + if let Some(ref cat) = category { + form = form.text("category", cat.clone()); + } + } + client + .post(format!("{base_url}/api/v2/torrents/add")) + .header("Cookie", format!("SID={sid}")) + .multipart(form) + .send() + .await + } else { + // Pass magnet URL or original URL directly + let url_to_send = resolved_magnet_url.as_deref().unwrap_or(&body.url); + let mut form_params: Vec<(&str, &str)> = vec![("urls", url_to_send)]; + if is_managed { + form_params.push(("savepath", savepath)); + if let Some(ref cat) = category { + form_params.push(("category", cat)); + } + } + client + .post(format!("{base_url}/api/v2/torrents/add")) + .header("Cookie", format!("SID={sid}")) + .form(&form_params) + .send() + .await + }.map_err(|e| { + tracing::error!("[QBITTORRENT] Add torrent request failed: {e}"); + ApiError::internal(format!("qBittorrent add request failed: {e}")) + })?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); + tracing::error!("[QBITTORRENT] Add torrent failed — status={status}, body={text}"); return Ok(Json(QBittorrentAddResponse { success: false, message: format!("qBittorrent returned {status}: {text}"), @@ -216,9 +275,13 @@ pub async fn add_torrent( .bind(qb_hash.as_deref()) .bind(body.replace_existing) .execute(&state.pool) - .await?; + .await + .map_err(|e| { + tracing::error!("[QBITTORRENT] Failed to insert torrent_downloads row: {e}"); + ApiError::from(e) + })?; - tracing::info!("Created torrent download {id} for {series_name}, qb_hash={qb_hash:?}"); + tracing::info!("[QBITTORRENT] Created torrent download {id} for {series_name}, qb_hash={qb_hash:?}"); Some(id) } else { @@ -232,6 +295,76 @@ pub async fn add_torrent( })) } +// ─── URL resolution ───────────────────────────────────────────────────────── + +enum ResolvedTorrent { + Magnet(String), + TorrentFile(Vec), +} + +/// Resolve a torrent URL: if it's already a magnet link, return as-is. +/// Otherwise follow HTTP redirects. If the final URL is a magnet, return it. +/// If the response is a .torrent file, return the bytes. +async fn resolve_torrent_url( + url: &str, +) -> Result { + // Already a magnet link — nothing to resolve + if url.starts_with("magnet:") { + return Ok(ResolvedTorrent::Magnet(url.to_string())); + } + + // Build a client that does NOT follow redirects so we can inspect Location headers + let no_redirect_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .user_agent("Stripstream-Librarian") + .build() + .map_err(|e| format!("failed to build redirect client: {e}"))?; + + let mut current_url = url.to_string(); + for _ in 0..10 { + let resp = no_redirect_client + .get(¤t_url) + .send() + .await + .map_err(|e| format!("HTTP request failed for {current_url}: {e}"))?; + + let status = resp.status(); + if status.is_redirection() { + if let Some(location) = resp.headers().get("location").and_then(|v| v.to_str().ok()) { + let location = location.to_string(); + if location.starts_with("magnet:") { + tracing::info!("[QBITTORRENT] URL redirected to magnet link"); + return Ok(ResolvedTorrent::Magnet(location)); + } + tracing::debug!("[QBITTORRENT] Following redirect: {status} -> {location}"); + current_url = location; + continue; + } + return Err(format!("redirect {status} without Location header")); + } + + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {text}")); + } + + // Successful response — download the .torrent file + let bytes = resp + .bytes() + .await + .map_err(|e| format!("failed to read response body: {e}"))?; + + if bytes.is_empty() { + return Err("empty response body".to_string()); + } + + return Ok(ResolvedTorrent::TorrentFile(bytes.to_vec())); + } + + Err("too many redirects".to_string()) +} + /// Extract the info-hash from a magnet link (lowercased hex). /// magnet:?xt=urn:btih:HASH... /// Handles both hex (40 chars) and base32 (32 chars) encoded hashes.