diff --git a/Cargo.lock b/Cargo.lock index cf2dfb0..af3bf7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "api" -version = "2.12.0" +version = "2.12.1" dependencies = [ "anyhow", "argon2", @@ -1233,7 +1233,7 @@ dependencies = [ [[package]] name = "indexer" -version = "2.12.0" +version = "2.12.1" dependencies = [ "anyhow", "axum", @@ -1667,7 +1667,7 @@ dependencies = [ [[package]] name = "notifications" -version = "2.12.0" +version = "2.12.1" dependencies = [ "anyhow", "reqwest", @@ -1786,7 +1786,7 @@ dependencies = [ [[package]] name = "parsers" -version = "2.12.0" +version = "2.12.1" dependencies = [ "anyhow", "flate2", @@ -2923,7 +2923,7 @@ dependencies = [ [[package]] name = "stripstream-core" -version = "2.12.0" +version = "2.12.1" dependencies = [ "anyhow", "serde", diff --git a/Cargo.toml b/Cargo.toml index 2884caf..d3fbc2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "2.12.0" +version = "2.12.1" license = "MIT" [workspace.dependencies] diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index ec8af03..90b7bb7 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -123,6 +123,7 @@ async fn main() -> anyhow::Result<()> { .route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent)) .route("/qbittorrent/test", get(qbittorrent::test_qbittorrent)) .route("/torrent-downloads", get(torrent_import::list_torrent_downloads)) + .route("/torrent-downloads/:id", axum::routing::delete(torrent_import::delete_torrent_download)) .route("/telegram/test", get(telegram::test_telegram)) .route("/komga/sync", axum::routing::post(komga::sync_komga_read_books)) .route("/komga/reports", get(komga::list_sync_reports)) diff --git a/apps/api/src/qbittorrent.rs b/apps/api/src/qbittorrent.rs index 952971d..b6020b9 100644 --- a/apps/api/src/qbittorrent.rs +++ b/apps/api/src/qbittorrent.rs @@ -143,10 +143,24 @@ pub async fn add_torrent( let sid = qbittorrent_login(&client, &base_url, &username, &password).await?; + // Pre-generate the download ID so we can tag the torrent with it + let download_id = if is_managed { Some(Uuid::new_v4()) } else { None }; + let tag = download_id.map(|id| format!("sl-{id}")); + + // Snapshot existing torrents before adding (for hash resolution) + let torrents_before = if is_managed { + list_qbittorrent_torrents(&client, &base_url, &sid).await.unwrap_or_default() + } else { + Vec::new() + }; + 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 t) = tag { + form_params.push(("tags", t)); + } } let resp = client @@ -172,9 +186,16 @@ pub async fn add_torrent( 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(); + // Try to resolve hash: first from magnet, then by querying qBittorrent + let mut qb_hash = extract_magnet_hash(&body.url); + if qb_hash.is_none() { + // For .torrent URLs: wait briefly then query qBittorrent to find the torrent + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + qb_hash = resolve_hash_from_qbittorrent(&client, &base_url, &sid, &torrents_before, series_name).await; + } + + let id = download_id.unwrap(); sqlx::query( "INSERT INTO torrent_downloads (id, library_id, series_name, expected_volumes, qb_hash) \ VALUES ($1, $2, $3, $4, $5)", @@ -187,6 +208,8 @@ pub async fn add_torrent( .execute(&state.pool) .await?; + tracing::info!("Created torrent download {id} for {series_name}, qb_hash={qb_hash:?}"); + Some(id) } else { None @@ -199,8 +222,9 @@ pub async fn add_torrent( })) } -/// Extract the info-hash from a magnet link (lowercased, hex or base32). +/// 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. fn extract_magnet_hash(url: &str) -> Option { let lower = url.to_lowercase(); let marker = "urn:btih:"; @@ -210,7 +234,119 @@ fn extract_magnet_hash(url: &str) -> Option { .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()) } + if hash.is_empty() { + return None; + } + // 40-char hex hash: use as-is + if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(hash.to_string()); + } + // 32-char base32 hash: decode to hex + if hash.len() == 32 { + if let Some(hex) = base32_to_hex(hash) { + return Some(hex); + } + } + // Fallback: return as-is (may not match qBittorrent) + Some(hash.to_string()) +} + +/// Decode a base32-encoded string to a lowercase hex string. +fn base32_to_hex(input: &str) -> Option { + let input = input.to_uppercase(); + let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut bits: u64 = 0; + let mut bit_count = 0u32; + let mut bytes = Vec::with_capacity(20); + + for ch in input.bytes() { + let val = alphabet.iter().position(|&c| c == ch)? as u64; + bits = (bits << 5) | val; + bit_count += 5; + if bit_count >= 8 { + bit_count -= 8; + bytes.push((bits >> bit_count) as u8); + bits &= (1u64 << bit_count) - 1; + } + } + + if bytes.len() != 20 { + return None; + } + Some(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +/// Torrent entry from qBittorrent API. +#[derive(Deserialize, Clone)] +struct QbTorrentEntry { + hash: String, + #[serde(default)] + name: String, +} + +/// List all torrents currently in qBittorrent. +async fn list_qbittorrent_torrents( + client: &reqwest::Client, + base_url: &str, + sid: &str, +) -> Result, ApiError> { + let resp = client + .get(format!("{base_url}/api/v2/torrents/info")) + .header("Cookie", format!("SID={sid}")) + .send() + .await + .map_err(|e| ApiError::internal(format!("qBittorrent list failed: {e}")))?; + + if !resp.status().is_success() { + return Ok(Vec::new()); + } + + Ok(resp.json().await.unwrap_or_default()) +} + +/// Resolve the hash of a torrent after adding it to qBittorrent. +/// Strategy: +/// 1. Compare before/after snapshots to find the new torrent (works for new torrents) +/// 2. If no new torrent found (already existed), search by series name in torrent names +async fn resolve_hash_from_qbittorrent( + client: &reqwest::Client, + base_url: &str, + sid: &str, + torrents_before: &[QbTorrentEntry], + series_name: &str, +) -> Option { + let torrents_after = list_qbittorrent_torrents(client, base_url, sid).await.ok()?; + let before_hashes: std::collections::HashSet<&str> = torrents_before.iter().map(|t| t.hash.as_str()).collect(); + + // Strategy 1: diff — find the one new torrent + let new_torrents: Vec<&QbTorrentEntry> = torrents_after.iter() + .filter(|t| !before_hashes.contains(t.hash.as_str())) + .collect(); + if new_torrents.len() == 1 { + tracing::info!("[QBITTORRENT] Resolved hash {} via diff (new torrent: {})", new_torrents[0].hash, new_torrents[0].name); + return Some(new_torrents[0].hash.clone()); + } + + // Strategy 2: torrent already existed — search by series name in torrent names + let series_lower = series_name.to_lowercase(); + // Normalize: "Dandadan" matches "Dandadan.T02.FRENCH.CBZ..." + let candidates: Vec<&QbTorrentEntry> = torrents_after.iter() + .filter(|t| t.name.to_lowercase().contains(&series_lower)) + .collect(); + + if candidates.len() == 1 { + tracing::info!("[QBITTORRENT] Resolved hash {} via name match ({})", candidates[0].hash, candidates[0].name); + return Some(candidates[0].hash.clone()); + } + + if candidates.len() > 1 { + tracing::warn!("[QBITTORRENT] Multiple torrents match series '{}': {}", series_name, + candidates.iter().map(|c| c.name.as_str()).collect::>().join(", ")); + } else { + tracing::warn!("[QBITTORRENT] No torrent found matching series '{}'", series_name); + } + + None } /// Test connection to qBittorrent diff --git a/apps/api/src/torrent_import.rs b/apps/api/src/torrent_import.rs index 29679d5..2d49bbe 100644 --- a/apps/api/src/torrent_import.rs +++ b/apps/api/src/torrent_import.rs @@ -1,4 +1,4 @@ -use axum::{extract::State, Json}; +use axum::{extract::{Path, State}, Json}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{PgPool, Row}; @@ -35,6 +35,9 @@ pub struct TorrentDownloadDto { pub status: String, pub imported_files: Option, pub error_message: Option, + pub progress: f32, + pub download_speed: i64, + pub eta: i64, pub created_at: String, pub updated_at: String, } @@ -102,7 +105,7 @@ pub async fn list_torrent_downloads( ) -> Result>, ApiError> { let rows = sqlx::query( "SELECT id, library_id, series_name, expected_volumes, qb_hash, content_path, \ - status, imported_files, error_message, created_at, updated_at \ + status, imported_files, error_message, progress, download_speed, eta, created_at, updated_at \ FROM torrent_downloads ORDER BY created_at DESC LIMIT 100", ) .fetch_all(&state.pool) @@ -126,6 +129,9 @@ pub async fn list_torrent_downloads( status: row.get("status"), imported_files: row.get("imported_files"), error_message: row.get("error_message"), + progress: row.get("progress"), + download_speed: row.get("download_speed"), + eta: row.get("eta"), created_at: created_at.to_rfc3339(), updated_at: updated_at.to_rfc3339(), } @@ -135,6 +141,55 @@ pub async fn list_torrent_downloads( Ok(Json(dtos)) } +/// Delete a torrent download entry. If the torrent is still downloading, also remove it from qBittorrent. +pub async fn delete_torrent_download( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let row = sqlx::query("SELECT qb_hash, status FROM torrent_downloads WHERE id = $1") + .bind(id) + .fetch_optional(&state.pool) + .await?; + + let Some(row) = row else { + return Err(ApiError::not_found("torrent download not found")); + }; + + let qb_hash: Option = row.get("qb_hash"); + let status: String = row.get("status"); + + // If downloading, try to cancel in qBittorrent + if status == "downloading" { + if let Some(ref hash) = qb_hash { + if let Ok((base_url, username, password)) = load_qbittorrent_config(&state.pool).await { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .ok(); + if let Some(client) = client { + if let Ok(sid) = qbittorrent_login(&client, &base_url, &username, &password).await { + let _ = client + .post(format!("{base_url}/api/v2/torrents/delete")) + .header("Cookie", format!("SID={sid}")) + .form(&[("hashes", hash.as_str()), ("deleteFiles", "true")]) + .send() + .await; + info!("Deleted torrent {} from qBittorrent", hash); + } + } + } + } + } + + sqlx::query("DELETE FROM torrent_downloads WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await?; + + info!("Deleted torrent download {id}"); + Ok(Json(serde_json::json!({ "ok": true }))) +} + // ─── Background poller ──────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -144,6 +199,12 @@ struct QbTorrentInfo { content_path: Option, save_path: Option, name: Option, + #[serde(default)] + progress: f64, + #[serde(default)] + dlspeed: i64, + #[serde(default)] + eta: i64, } /// Completed states in qBittorrent: torrent is fully downloaded and seeding. @@ -152,29 +213,35 @@ const QB_COMPLETED_STATES: &[&str] = &[ ]; pub async fn run_torrent_poller(pool: PgPool, interval_seconds: u64) { - let wait = Duration::from_secs(interval_seconds.max(5)); + let idle_wait = Duration::from_secs(interval_seconds.max(5)); + let active_wait = Duration::from_secs(2); loop { - if let Err(e) = poll_qbittorrent_downloads(&pool).await { - warn!("[TORRENT_POLLER] {:#}", e); - } - tokio::time::sleep(wait).await; + let has_active = match poll_qbittorrent_downloads(&pool).await { + Ok(active) => active, + Err(e) => { + warn!("[TORRENT_POLLER] {:#}", e); + false + } + }; + tokio::time::sleep(if has_active { active_wait } else { idle_wait }).await; } } -async fn poll_qbittorrent_downloads(pool: &PgPool) -> anyhow::Result<()> { +/// Returns Ok(true) if there are active downloads, Ok(false) otherwise. +async fn poll_qbittorrent_downloads(pool: &PgPool) -> anyhow::Result { if !is_torrent_import_enabled(pool).await { - return Ok(()); + return Ok(false); } let rows = sqlx::query( - "SELECT id, qb_hash FROM torrent_downloads WHERE status = 'downloading' AND qb_hash IS NOT NULL", + "SELECT id, qb_hash FROM torrent_downloads WHERE status = 'downloading'", ) .fetch_all(pool) .await?; if rows.is_empty() { trace!("[TORRENT_POLLER] No active downloads to poll"); - return Ok(()); + return Ok(false); } let (base_url, username, password) = load_qbittorrent_config(pool) @@ -189,6 +256,16 @@ async fn poll_qbittorrent_downloads(pool: &PgPool) -> anyhow::Result<()> { .await .map_err(|e| anyhow::anyhow!("qBittorrent login: {}", e.message))?; + // Filter to rows that have a resolved hash + let rows: Vec<_> = rows.into_iter().filter(|r| { + let qb_hash: Option = r.get("qb_hash"); + qb_hash.is_some() + }).collect(); + + if rows.is_empty() { + return Ok(true); + } + let hashes: Vec = rows .iter() .map(|r| { let h: String = r.get("qb_hash"); h }) @@ -209,6 +286,25 @@ async fn poll_qbittorrent_downloads(pool: &PgPool) -> anyhow::Result<()> { let infos: Vec = resp.json().await?; for info in &infos { + // Update progress for all active torrents + let row = rows.iter().find(|r| { + let h: String = r.get("qb_hash"); + h == info.hash + }); + if let Some(row) = row { + let tid: Uuid = row.get("id"); + let _ = sqlx::query( + "UPDATE torrent_downloads SET progress = $1, download_speed = $2, eta = $3, updated_at = NOW() \ + WHERE id = $4 AND status = 'downloading'", + ) + .bind(info.progress as f32) + .bind(info.dlspeed) + .bind(info.eta) + .bind(tid) + .execute(pool) + .await; + } + if !QB_COMPLETED_STATES.contains(&info.state.as_str()) { continue; } @@ -228,15 +324,15 @@ async fn poll_qbittorrent_downloads(pool: &PgPool) -> anyhow::Result<()> { continue; }; - let row = rows.iter().find(|r| { + let Some(row) = rows.iter().find(|r| { let h: String = r.get("qb_hash"); h == info.hash - }); - let Some(row) = row else { continue; }; + }) else { continue; }; let torrent_id: Uuid = row.get("id"); let updated = sqlx::query( - "UPDATE torrent_downloads SET status = 'completed', content_path = $1, updated_at = NOW() \ + "UPDATE torrent_downloads SET status = 'completed', content_path = $1, progress = 1, \ + download_speed = 0, eta = 0, updated_at = NOW() \ WHERE id = $2 AND status = 'downloading'", ) .bind(&content_path) @@ -255,7 +351,15 @@ async fn poll_qbittorrent_downloads(pool: &PgPool) -> anyhow::Result<()> { } } - Ok(()) + // Still active if any rows remain in 'downloading' status + let still_active = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM torrent_downloads WHERE status = 'downloading'", + ) + .fetch_one(pool) + .await + .unwrap_or(0); + + Ok(still_active > 0) } // ─── Import processing ──────────────────────────────────────────────────────── diff --git a/apps/backoffice/app/(app)/downloads/DownloadsPage.tsx b/apps/backoffice/app/(app)/downloads/DownloadsPage.tsx index a68762f..f0ba25f 100644 --- a/apps/backoffice/app/(app)/downloads/DownloadsPage.tsx +++ b/apps/backoffice/app/(app)/downloads/DownloadsPage.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; import { TorrentDownloadDto } from "@/lib/api"; import { Card, CardContent, Button, Icon } from "@/app/components/ui"; import { useTranslation } from "@/lib/i18n/context"; @@ -43,6 +44,22 @@ function formatDate(iso: string): string { }); } +function formatSpeed(bytesPerSec: number): string { + if (bytesPerSec < 1024) return `${bytesPerSec} B/s`; + if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`; + return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`; +} + +function formatEta(seconds: number): string { + if (seconds <= 0 || seconds >= 8640000) return ""; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`; + if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`; + return `${s}s`; +} + interface DownloadsPageProps { initialDownloads: TorrentDownloadDto[]; } @@ -67,7 +84,7 @@ export function DownloadsPage({ initialDownloads }: DownloadsPageProps) { const hasActive = downloads.some(d => STATUS_ACTIVE.has(d.status)); useEffect(() => { if (!hasActive) return; - const id = setInterval(() => refresh(false), 5000); + const id = setInterval(() => refresh(false), 2000); return () => clearInterval(id); }, [hasActive, refresh]); @@ -133,7 +150,7 @@ export function DownloadsPage({ initialDownloads }: DownloadsPageProps) { ) : (
{visible.map(dl => ( - + refresh(false)} /> ))}
)} @@ -141,10 +158,23 @@ export function DownloadsPage({ initialDownloads }: DownloadsPageProps) { ); } -function DownloadCard({ dl }: { dl: TorrentDownloadDto }) { +function DownloadCard({ dl, onDeleted }: { dl: TorrentDownloadDto; onDeleted: () => void }) { const { t } = useTranslation(); + const [deleting, setDeleting] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); const importedCount = Array.isArray(dl.imported_files) ? dl.imported_files.length : 0; + async function handleDelete() { + setDeleting(true); + setShowConfirm(false); + try { + const resp = await fetch(`/api/torrent-downloads/${dl.id}`, { method: "DELETE" }); + if (resp.ok) onDeleted(); + } finally { + setDeleting(false); + } + } + return ( @@ -187,6 +217,28 @@ function DownloadCard({ dl }: { dl: TorrentDownloadDto }) { )} + {dl.status === "downloading" && ( +
+
+
+
+
+ + {Math.round(dl.progress * 100)}% + +
+ {(dl.download_speed > 0 || dl.eta > 0) && ( +
+ {dl.download_speed > 0 && {formatSpeed(dl.download_speed)}} + {dl.eta > 0 && dl.eta < 8640000 && ETA {formatEta(dl.eta)}} +
+ )} +
+ )} + {dl.content_path && dl.status !== "imported" && (

{dl.content_path} @@ -208,15 +260,57 @@ function DownloadCard({ dl }: { dl: TorrentDownloadDto }) { )}

- {/* Timestamp */} -
-

{formatDate(dl.created_at)}

- {dl.updated_at !== dl.created_at && ( -

maj {formatDate(dl.updated_at)}

- )} + {/* Actions */} +
+
+

{formatDate(dl.created_at)}

+ {dl.updated_at !== dl.created_at && ( +

maj {formatDate(dl.updated_at)}

+ )} +
+
+ + {showConfirm && createPortal( + <> +
setShowConfirm(false)} /> +
+
+
+

+ {dl.status === "downloading" ? t("downloads.cancel") : t("downloads.delete")} +

+

+ {dl.status === "downloading" ? t("downloads.confirmCancel") : t("downloads.confirmDelete")} +

+
+
+ + +
+
+
+ , + document.body + )} ); } diff --git a/apps/backoffice/app/(app)/jobs/[id]/components/DownloadDetectionCards.tsx b/apps/backoffice/app/(app)/jobs/[id]/components/DownloadDetectionCards.tsx index a7edbce..93d7b41 100644 --- a/apps/backoffice/app/(app)/jobs/[id]/components/DownloadDetectionCards.tsx +++ b/apps/backoffice/app/(app)/jobs/[id]/components/DownloadDetectionCards.tsx @@ -106,7 +106,13 @@ export function DownloadDetectionResultsCard({ results, libraryId, t }: {
{release.download_url && ( - + )} ))} diff --git a/apps/backoffice/app/api/torrent-downloads/[id]/route.ts b/apps/backoffice/app/api/torrent-downloads/[id]/route.ts new file mode 100644 index 0000000..5f28f7d --- /dev/null +++ b/apps/backoffice/app/api/torrent-downloads/[id]/route.ts @@ -0,0 +1,13 @@ +import { NextResponse, NextRequest } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const data = await apiFetch(`/torrent-downloads/${id}`, { method: "DELETE" }); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete torrent download"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/components/QbittorrentDownloadButton.tsx b/apps/backoffice/app/components/QbittorrentDownloadButton.tsx index 7a64a75..63405e0 100644 --- a/apps/backoffice/app/components/QbittorrentDownloadButton.tsx +++ b/apps/backoffice/app/components/QbittorrentDownloadButton.tsx @@ -21,7 +21,19 @@ export function QbittorrentProvider({ children }: { children: ReactNode }) { return {children}; } -export function QbittorrentDownloadButton({ downloadUrl, releaseId }: { downloadUrl: string; releaseId: string }) { +export function QbittorrentDownloadButton({ + downloadUrl, + releaseId, + libraryId, + seriesName, + expectedVolumes, +}: { + downloadUrl: string; + releaseId: string; + libraryId?: string; + seriesName?: string; + expectedVolumes?: number[]; +}) { const { t } = useTranslation(); const configured = useContext(QbConfigContext); const [sending, setSending] = useState(false); @@ -37,7 +49,12 @@ export function QbittorrentDownloadButton({ downloadUrl, releaseId }: { download const resp = await fetch("/api/qbittorrent/add", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: downloadUrl }), + body: JSON.stringify({ + url: downloadUrl, + ...(libraryId && { library_id: libraryId }), + ...(seriesName && { series_name: seriesName }), + ...(expectedVolumes && { expected_volumes: expectedVolumes }), + }), }); const data = await resp.json(); if (data.error) { diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 86686ec..2ef279b 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -1295,6 +1295,9 @@ export type TorrentDownloadDto = { status: "downloading" | "completed" | "importing" | "imported" | "error"; imported_files: Array<{ volume: number; source: string; destination: string }> | null; error_message: string | null; + progress: number; + download_speed: number; + eta: number; created_at: string; updated_at: string; }; diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 0a86018..b79d46f 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -898,6 +898,10 @@ const en: Record = { "downloads.status.importing": "Importing", "downloads.status.imported": "Imported", "downloads.status.error": "Error", + "downloads.delete": "Delete", + "downloads.cancel": "Cancel download", + "downloads.confirmDelete": "Delete this download?", + "downloads.confirmCancel": "Cancel this download? The torrent will also be removed from qBittorrent.", // Settings - Torrent Import "settings.torrentImport": "Auto import", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index f804140..0b2ad52 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -896,6 +896,10 @@ const fr = { "downloads.status.importing": "Import en cours", "downloads.status.imported": "Importé", "downloads.status.error": "Erreur", + "downloads.delete": "Supprimer", + "downloads.cancel": "Annuler le téléchargement", + "downloads.confirmDelete": "Supprimer ce téléchargement ?", + "downloads.confirmCancel": "Annuler ce téléchargement ? Le torrent sera aussi supprimé de qBittorrent.", // Settings - Torrent Import "settings.torrentImport": "Import automatique", diff --git a/apps/backoffice/package.json b/apps/backoffice/package.json index 699c996..d55c803 100644 --- a/apps/backoffice/package.json +++ b/apps/backoffice/package.json @@ -1,6 +1,6 @@ { "name": "stripstream-backoffice", - "version": "2.12.0", + "version": "2.12.1", "private": true, "scripts": { "dev": "next dev -p 7082", diff --git a/infra/migrations/0065_add_torrent_progress.sql b/infra/migrations/0065_add_torrent_progress.sql new file mode 100644 index 0000000..49e4a42 --- /dev/null +++ b/infra/migrations/0065_add_torrent_progress.sql @@ -0,0 +1,4 @@ +ALTER TABLE torrent_downloads + ADD COLUMN progress REAL NOT NULL DEFAULT 0, + ADD COLUMN download_speed BIGINT NOT NULL DEFAULT 0, + ADD COLUMN eta BIGINT NOT NULL DEFAULT 0;