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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user