feat: gestion des téléchargements qBittorrent avec import automatique

- Nouvelle table `torrent_downloads` pour suivre les téléchargements gérés
- API : endpoint POST /torrent-downloads/notify (webhook optionnel) et GET /torrent-downloads
- Poller background toutes les 30s qui interroge qBittorrent pour détecter
  les torrents terminés — aucune config "run external program" nécessaire
- Import automatique : déplacement des fichiers vers la série cible,
  renommage selon le pattern existant (détection de la largeur des digits),
  support packs multi-volumes, scan job déclenché après import
- Page /downloads dans le backoffice : filtres, auto-refresh, carte par download
- Toggle auto-import intégré dans la card qBittorrent des settings
- Erreurs de détection download affichées dans le détail des jobs
- Volume /downloads monté dans docker-compose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:43:10 +01:00
parent a2de2e1601
commit 8fb273e32e
21 changed files with 1191 additions and 39 deletions

View File

@@ -2,6 +2,7 @@ use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::{error::ApiError, state::AppState};
@@ -10,12 +11,21 @@ use crate::{error::ApiError, state::AppState};
#[derive(Deserialize, ToSchema)]
pub struct QBittorrentAddRequest {
pub url: String,
/// When provided together with `series_name` and `expected_volumes`, tracks the download
/// in `torrent_downloads` and triggers automatic import on completion.
#[schema(value_type = Option<String>)]
pub library_id: Option<Uuid>,
pub series_name: Option<String>,
pub expected_volumes: Option<Vec<i32>>,
}
#[derive(Serialize, ToSchema)]
pub struct QBittorrentAddResponse {
pub success: bool,
pub message: String,
/// Set when `library_id` + `series_name` + `expected_volumes` were provided.
#[schema(value_type = Option<String>)]
pub torrent_download_id: Option<Uuid>,
}
#[derive(Serialize, ToSchema)]
@@ -34,7 +44,7 @@ struct QBittorrentConfig {
password: String,
}
async fn load_qbittorrent_config(
pub(crate) async fn load_qbittorrent_config(
pool: &sqlx::PgPool,
) -> Result<(String, String, String), ApiError> {
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'qbittorrent'")
@@ -58,7 +68,7 @@ async fn load_qbittorrent_config(
// ─── Login helper ───────────────────────────────────────────────────────────
async fn qbittorrent_login(
pub(crate) async fn qbittorrent_login(
client: &reqwest::Client,
base_url: &str,
username: &str,
@@ -120,6 +130,10 @@ pub async fn add_torrent(
return Err(ApiError::bad_request("url is required"));
}
let is_managed = body.library_id.is_some()
&& body.series_name.is_some()
&& body.expected_volumes.is_some();
let (base_url, username, password) = load_qbittorrent_config(&state.pool).await?;
let client = reqwest::Client::builder()
@@ -129,27 +143,74 @@ pub async fn add_torrent(
let sid = qbittorrent_login(&client, &base_url, &username, &password).await?;
let mut form_params: Vec<(&str, &str)> = vec![("urls", &body.url)];
let savepath = "/downloads";
if is_managed {
form_params.push(("savepath", savepath));
}
let resp = client
.post(format!("{base_url}/api/v2/torrents/add"))
.header("Cookie", format!("SID={sid}"))
.form(&[("urls", &body.url)])
.form(&form_params)
.send()
.await
.map_err(|e| ApiError::internal(format!("qBittorrent add request failed: {e}")))?;
if resp.status().is_success() {
Ok(Json(QBittorrentAddResponse {
success: true,
message: "Torrent added to qBittorrent".to_string(),
}))
} else {
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
Ok(Json(QBittorrentAddResponse {
return Ok(Json(QBittorrentAddResponse {
success: false,
message: format!("qBittorrent returned {status}: {text}"),
}))
torrent_download_id: None,
}));
}
// If managed download: record in torrent_downloads
let torrent_download_id = if is_managed {
let library_id = body.library_id.unwrap();
let series_name = body.series_name.as_deref().unwrap();
let expected_volumes = body.expected_volumes.as_deref().unwrap();
let qb_hash = extract_magnet_hash(&body.url);
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO torrent_downloads (id, library_id, series_name, expected_volumes, qb_hash) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind(id)
.bind(library_id)
.bind(series_name)
.bind(expected_volumes)
.bind(qb_hash.as_deref())
.execute(&state.pool)
.await?;
Some(id)
} else {
None
};
Ok(Json(QBittorrentAddResponse {
success: true,
message: "Torrent added to qBittorrent".to_string(),
torrent_download_id,
}))
}
/// Extract the info-hash from a magnet link (lowercased, hex or base32).
/// magnet:?xt=urn:btih:HASH...
fn extract_magnet_hash(url: &str) -> Option<String> {
let lower = url.to_lowercase();
let marker = "urn:btih:";
let start = lower.find(marker)? + marker.len();
let hash_part = &lower[start..];
let end = hash_part
.find(|c: char| !c.is_alphanumeric())
.unwrap_or(hash_part.len());
let hash = &hash_part[..end];
if hash.is_empty() { None } else { Some(hash.to_string()) }
}
/// Test connection to qBittorrent