diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index 61f728a..495c6b3 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -21,24 +21,25 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar - Global DHT, IPv6 DHT, PEX, and Local Peer Discovery toggles. - Optional piece-integrity verification, including the explicit policy that disables unverified seeding when verification is requested. +- Optional stall timeout through `bt-stop-timeout`, persisted with each + Torrent and re-applied when it starts or retries. A value of zero disables + the policy; Aria2 stops the Torrent after the configured consecutive + zero-download-speed interval. - Additional per-Torrent tracker URLs through `bt-tracker`, with bounded and credential-free HTTP/HTTPS/UDP validation. - Deterministic local Aria2 smoke coverage for metadata resolution, selected output, pause/resume, ownership, cancellation/removal, unavailable trackers, - and daemon failure; RPC-boundary coverage is separate. + daemon failure, and `bt-stop-timeout` terminal behavior; RPC-boundary + coverage is separate. ## Priority tiers for remaining work ### Tier 0 — reliability and user-visible control -1. **Stall timeout** — expose `bt-stop-timeout` with clear semantics for a - Torrent that has no download progress. The queue must reconcile the Aria2 - stop/error outcome and release its permit without turning an intentional - stall policy into a stale retry loop. -2. **Peer diagnostics** — expose `aria2.getPeers` as bounded, redacted, +1. **Peer diagnostics** — expose `aria2.getPeers` as bounded, redacted, read-only detail for the selected Torrent. Keep the current counts as the fast summary and treat peer IPs/IDs as sensitive display data. -3. **Tracker exclusion** — add `bt-exclude-tracker` alongside the existing +2. **Tracker exclusion** — add `bt-exclude-tracker` alongside the existing additional-tracker list. It must be persisted, re-normalized, and re-applied on every retry/GID replacement. Document that it filters announce URLs only; it does not disable DHT or PEX. @@ -68,6 +69,5 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar if the resulting child-GID ownership model can be represented safely; the current explicit metadata path intentionally avoids unmapped child jobs. -The first implementation in this task is the former missing Tier 0 intake -capability: remote `.torrent` metadata now uses Firelink's existing safe, -cached, lifecycle-aware Torrent path. +The first implementation in this task was remote `.torrent` metadata intake; +the follow-up implementation adds the former Tier 0 stall-timeout control. diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index 1a67306..bb1710d 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -629,7 +629,8 @@ async function main() { const finalDir = path.join(tempRoot, 'final'); const integrityDir = path.join(tempRoot, 'integrity'); const cancelDir = path.join(tempRoot, 'cancel'); - for (const directory of [seedRoot, probeDir, finalDir, integrityDir, cancelDir]) fs.mkdirSync(directory, { recursive: true }); + const stallDir = path.join(tempRoot, 'stall'); + for (const directory of [seedRoot, probeDir, finalDir, integrityDir, cancelDir, stallDir]) fs.mkdirSync(directory, { recursive: true }); const seederListenPort = await findAvailablePort(); const clientListenPort = await findAvailablePort(); @@ -779,6 +780,28 @@ async function main() { ); console.log('[OK] cancel/remove stopped the second torrent before completion'); + const stallGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ + trackerlessTorrentBytes.toString('base64'), + [], + { + dir: stallDir, + 'bt-stop-timeout': '2', + 'seed-time': '0', + 'auto-file-renaming': 'false', + }, + ]); + let stallStatus; + await waitFor('stalled Torrent to stop', async () => { + assertDaemonRunning(client); + stallStatus = await tellStatus(client, stallGid); + if (stallStatus.status === 'complete') { + throw new Error('stalled Torrent completed without a peer'); + } + return stallStatus.status === 'error'; + }, 10000); + assert(stallStatus.errorCode === '7', `unexpected stall timeout error: ${JSON.stringify(stallStatus)}`); + console.log('[OK] bt-stop-timeout ended a stalled Torrent without a retry loop'); + if (runFailurePaths) { await runFailurePathChecks({ client, tempRoot }); } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index fb32b11..0017236 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -197,6 +197,9 @@ pub struct DownloadItem { #[serde(default)] #[ts(optional)] pub torrent_trackers: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_stop_timeout: Option, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2ac4ad4..4190977 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5800,6 +5800,7 @@ async fn validate_torrent_enqueue( return Err("torrent transfer cannot be a media download".to_string()); } item.torrent_trackers = queue::normalize_torrent_trackers(item.torrent_trackers.as_deref())?; + item.torrent_stop_timeout = queue::normalize_torrent_stop_timeout(item.torrent_stop_timeout)?; validate_enqueue_uris("", item.mirrors.as_deref()).await?; if let Some(path) = item.torrent_path.as_deref() { let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?; diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 1e3ab4d..27f6f73 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -216,6 +216,7 @@ pub struct SpawnPayload { pub torrent_peer_speed_limit: Option, pub torrent_check_integrity: bool, pub torrent_trackers: Option, + pub torrent_stop_timeout: Option, } /// A sidecar spawner. In production this calls the real aria2/yt-dlp @@ -3048,6 +3049,7 @@ const ARIA2_STREAM_PIECE_SELECTOR: &str = "inorder"; const ARIA2_DEFAULT_TORRENT_MAX_PEERS: u32 = 55; const ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT: &str = "50K"; const MAX_TORRENT_MAX_PEERS: u32 = 1000; +pub(crate) const MAX_TORRENT_STOP_TIMEOUT: u32 = 7 * 24 * 60 * 60; const MAX_TORRENT_TRACKERS: usize = 64; const MAX_TORRENT_TRACKER_BYTES: usize = 16 * 1024; @@ -3109,6 +3111,18 @@ fn normalize_torrent_peer_speed_limit(value: Option<&str>) -> Result) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + if value > MAX_TORRENT_STOP_TIMEOUT { + return Err(format!( + "torrent stall timeout must be between 0 and {MAX_TORRENT_STOP_TIMEOUT} seconds" + )); + } + Ok(Some(value)) +} + pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result, String> { let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); @@ -3238,6 +3252,12 @@ fn apply_aria2_torrent_options( if let Some(trackers) = normalize_torrent_trackers(payload.torrent_trackers.as_deref())? { options.insert("bt-tracker".to_string(), serde_json::json!(trackers)); } + if let Some(stop_timeout) = normalize_torrent_stop_timeout(payload.torrent_stop_timeout)? { + options.insert( + "bt-stop-timeout".to_string(), + serde_json::json!(stop_timeout.to_string()), + ); + } if payload.torrent_check_integrity { options.insert( "check-integrity".to_string(), @@ -3849,6 +3869,9 @@ pub struct EnqueueItem { pub torrent_trackers: Option, #[serde(default)] #[ts(optional)] + pub torrent_stop_timeout: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -3898,6 +3921,7 @@ impl EnqueueItem { torrent_peer_speed_limit: self.torrent_peer_speed_limit, torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false), torrent_trackers: self.torrent_trackers, + torrent_stop_timeout: self.torrent_stop_timeout, }, } } @@ -4138,6 +4162,54 @@ mod tests { ); } + #[test] + fn torrent_stop_timeout_is_normalized_and_emitted() { + assert_eq!(normalize_torrent_stop_timeout(None).unwrap(), None); + assert_eq!(normalize_torrent_stop_timeout(Some(0)).unwrap(), Some(0)); + assert_eq!( + normalize_torrent_stop_timeout(Some(MAX_TORRENT_STOP_TIMEOUT)).unwrap(), + Some(MAX_TORRENT_STOP_TIMEOUT) + ); + assert!(normalize_torrent_stop_timeout(Some(MAX_TORRENT_STOP_TIMEOUT + 1)).is_err()); + + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_stop_timeout: Some(300), + ..Default::default() + }; + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + assert_eq!( + options.get("bt-stop-timeout"), + Some(&serde_json::json!("300")) + ); + + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_stop_timeout: Some(0), + ..Default::default() + }; + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + assert_eq!( + options.get("bt-stop-timeout"), + Some(&serde_json::json!("0")) + ); + } + + #[test] + fn torrent_stop_timeout_is_not_applied_to_non_torrent_payloads() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + torrent_stop_timeout: Some(300), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert!(!options.contains_key("bt-stop-timeout")); + } + #[test] fn enqueue_item_carries_torrent_trackers_into_the_spawn_payload() { let item: EnqueueItem = serde_json::from_value(serde_json::json!({ @@ -4158,6 +4230,26 @@ mod tests { ); } + #[test] + fn enqueue_item_carries_torrent_stop_timeout_into_the_spawn_payload() { + let item: EnqueueItem = serde_json::from_value(serde_json::json!({ + "id": "torrent-stop-timeout", + "queue_id": "main", + "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", + "destination": "/tmp/downloads", + "filename": "payload", + "is_media": false, + "is_torrent": true, + "torrent_stop_timeout": 300 + })) + .expect("frontend enqueue payload should deserialize"); + + assert_eq!( + item.into_task().payload.torrent_stop_timeout, + Some(300) + ); + } + #[test] fn torrent_options_reject_invalid_seed_values() { let mut options = serde_json::Map::new(); @@ -4354,6 +4446,11 @@ mod tests { assert!(!is_retryable_aria2_error("No URI available.")); } + #[test] + fn aria2_stall_timeout_outcome_is_not_automatically_retried() { + assert!(!is_retryable_aria2_error("aria2 error code 7: unfinished download")); + } + #[test] fn aria2_startup_rpc_errors_are_retryable() { assert!(is_aria2_rpc_unavailable( diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 43aace2..be31430 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentStopTimeout?: number, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index c39ee9b..b8d517f 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, lifecycle_generation?: string, }; +export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_stop_timeout?: number, lifecycle_generation?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 25dce16..21c2d20 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentTrackerList, normalizeSpeedLimitForBackend } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend } from '../utils/downloads'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { expandTilde, @@ -234,6 +234,7 @@ export const AddDownloadsModal = () => { const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState(''); const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); const [torrentTrackers, setTorrentTrackers] = useState(''); + const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); const [freeSpace, setFreeSpace] = useState('Unknown'); const freeSpaceRequestRef = useRef(0); @@ -376,6 +377,7 @@ export const AddDownloadsModal = () => { setTorrentPeerSpeedLimit(''); setTorrentCheckIntegrity(false); setTorrentTrackers(''); + setTorrentStopTimeout('0'); setUseAuth(false); setUsername(''); setPassword(''); @@ -976,6 +978,14 @@ export const AddDownloadsModal = () => { addToast({ message: t($ => $.addDownloads.torrentTrackersInvalid), variant: 'error', isActionable: true }); return; } + if ( + hasSelectedTorrent + && torrentStopTimeout.trim() + && (!Number.isInteger(Number(torrentStopTimeout)) || Number(torrentStopTimeout) < 0 || Number(torrentStopTimeout) > MAX_TORRENT_STOP_TIMEOUT) + ) { + addToast({ message: t($ => $.addDownloads.torrentStopTimeoutInvalid), variant: 'error', isActionable: true }); + return; + } if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) { addToast({ message: t($ => $.addDownloads.dedicatedFolderNameRequired), @@ -1456,6 +1466,7 @@ export const AddDownloadsModal = () => { : undefined, torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined, torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined, + torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), sizeBytes: item.sizeBytes }, action); @@ -2166,6 +2177,28 @@ export const AddDownloadsModal = () => { {t($ => $.addDownloads.torrentPeerOptionsHint)}

+
+ +
+ setTorrentStopTimeout(event.currentTarget.value)} + className="app-control w-24 px-2 py-1 text-end font-mono" + aria-describedby="torrent-stop-timeout-hint" + /> + {t($ => $.addDownloads.seconds)} +
+

+ {t($ => $.addDownloads.torrentStopTimeoutHint)} +

+
)} diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 09dcf83..b2ca407 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -16,7 +16,7 @@ import { formatDownloadTotal, resolveDownloadSizeDisplay } from '../utils/downloadProgress'; -import { isValidTorrentTrackerList, normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads'; +import { isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads'; import { useTranslation } from 'react-i18next'; import { formatDateTime, type CalendarPreference } from '../utils/dateTime'; import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; @@ -79,6 +79,7 @@ export const PropertiesModal = () => { const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState(''); const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); const [torrentTrackers, setTorrentTrackers] = useState(''); + const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false); const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false); const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false); @@ -172,6 +173,7 @@ export const PropertiesModal = () => { setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || ''); setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true); setTorrentTrackers(activeItem.torrentTrackers || ''); + setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout)); setErrorMessage(''); } else { setSelectedPropertiesDownloadId(null); @@ -271,6 +273,17 @@ export const PropertiesModal = () => { setErrorMessage(t($ => $.properties.torrentTrackersInvalid)); return; } + const normalizedStopTimeout = torrentStopTimeout.trim() + ? Number(torrentStopTimeout) + : undefined; + if ( + item.isTorrent + && normalizedStopTimeout !== undefined + && (!Number.isInteger(normalizedStopTimeout) || normalizedStopTimeout < 0 || normalizedStopTimeout > MAX_TORRENT_STOP_TIMEOUT) + ) { + setErrorMessage(t($ => $.properties.torrentStopTimeoutInvalid)); + return; + } const updates: Partial = { url, @@ -289,6 +302,7 @@ export const PropertiesModal = () => { torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined, torrentCheckIntegrity, torrentTrackers: torrentTrackers.trim() || undefined, + torrentStopTimeout: normalizedStopTimeout, } : {}), ...(connectionsDirty @@ -729,6 +743,29 @@ export const PropertiesModal = () => { {t($ => $.properties.torrentTrackersHint)}

+ +
+
+ setTorrentStopTimeout(event.currentTarget.value)} + disabled={transferLocked} + aria-describedby="torrent-stop-timeout-properties-hint" + className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" + /> + {t($ => $.properties.seconds)} +
+

+ {t($ => $.properties.torrentStopTimeoutHint)} +

+
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index ee4ebc0..052f5b4 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -245,6 +245,10 @@ const common = { torrentPeerSpeedLimit: 'Peer speed threshold', torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000', torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero', + seconds: 'seconds', + torrentStopTimeout: 'Stop stalled Torrent after', + torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy; changes apply when the Torrent starts or retries.', + torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds', liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}', category: 'Category', lastTry: 'Last try', @@ -480,6 +484,7 @@ const common = { seedAfterDownload: 'Seed after download completes', seedTime: 'Seed time', minutes: 'minutes', + seconds: 'seconds', seedRatio: 'Seed ratio', seedRatioHint: '0 means time-only seeding; otherwise seeding stops at the first limit reached.', limitTorrentUpload: 'Limit torrent upload', @@ -497,6 +502,9 @@ const common = { torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 peers and 50K). 0 peers means unlimited.', torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000', torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero', + torrentStopTimeout: 'Stop stalled Torrent after', + torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy.', + torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds', required: 'Required', free: 'Free', preview: 'Preview', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 267862b..ac860fe 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -245,6 +245,10 @@ const fa = { torrentPeerSpeedLimit: 'آستانه سرعت همتا', torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد', torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد', + seconds: 'ثانیه', + torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از', + torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.', + torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد', liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت به‌روزرسانی نشد: {{detail}}', category: 'دسته', lastTry: 'آخرین تلاش', @@ -480,6 +484,7 @@ const fa = { seedAfterDownload: 'پس از پایان دانلود سید شود', seedTime: 'مدت سید', minutes: 'دقیقه', + seconds: 'ثانیه', seedRatio: 'نسبت سید', seedRatioHint: '۰ یعنی فقط مدت زمان تعیین‌شده ملاک است؛ در غیر این صورت با رسیدن به اولین حد متوقف می‌شود.', limitTorrentUpload: 'محدود کردن آپلود تورنت', @@ -497,6 +502,9 @@ const fa = { torrentPeerOptionsHint: 'برای استفاده از پیش‌فرض‌های آریا۲ خالی بگذارید (۵۵ همتا و 50K). صفر یعنی نامحدود.', torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد', torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد', + torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از', + torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند.', + torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد', required: 'الزامی', free: 'فضای آزاد', preview: 'پیش‌نمایش', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 90c9921..faadb26 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -245,6 +245,10 @@ const he = { torrentPeerSpeedLimit: 'סף מהירות עמיתים', torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000', torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס', + seconds: 'שניות', + torrentStopTimeout: 'עצירת טורנט תקוע לאחר', + torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.', + torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות', liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}', category: 'קטגוריה', lastTry: 'ניסיון אחרון', @@ -480,6 +484,7 @@ const he = { seedAfterDownload: 'לשתף לאחר סיום ההורדה', seedTime: 'זמן שיתוף', minutes: 'דקות', + seconds: 'שניות', seedRatio: 'יחס שיתוף', seedRatioHint: '0 פירושו שיתוף לפי זמן בלבד; אחרת השיתוף ייפסק בהגעה למגבלה הראשונה.', limitTorrentUpload: 'הגבלת העלאת טורנט', @@ -497,6 +502,9 @@ const he = { torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (55 עמיתים ו-50K). אפס עמיתים פירושו ללא הגבלה.', torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000', torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס', + torrentStopTimeout: 'עצירת טורנט תקוע לאחר', + torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.', + torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות', required: 'נדרש', free: 'פנוי', preview: 'תצוגה מקדימה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 1bd338c..40b5a99 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -245,6 +245,10 @@ const ru = { torrentPeerSpeedLimit: 'Порог скорости пиров', torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000', torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля', + seconds: 'секунд', + torrentStopTimeout: 'Останавливать неактивный торрент через', + torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.', + torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд', liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}', category: 'Категория', lastTry: 'Последняя попытка', @@ -480,6 +484,7 @@ const ru = { seedAfterDownload: 'Раздавать после завершения загрузки', seedTime: 'Время раздачи', minutes: 'минут', + seconds: 'секунд', seedRatio: 'Коэффициент раздачи', seedRatioHint: '0 означает раздачу только по времени; иначе раздача остановится при достижении первого ограничения.', limitTorrentUpload: 'Ограничить отдачу торрента', @@ -497,6 +502,9 @@ const ru = { torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (55 пиров и 50K). 0 пиров означает без ограничений.', torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000', torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля', + torrentStopTimeout: 'Останавливать неактивный торрент через', + torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.', + torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд', required: 'Требуется', free: 'Свободно', preview: 'Предпросмотр', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 5a9ce70..9f4a324 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -245,6 +245,10 @@ const uk = { torrentPeerSpeedLimit: 'Поріг швидкості пірів', torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000', torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль', + seconds: 'секунд', + torrentStopTimeout: 'Зупиняти торрент без швидкості через', + torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.', + torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд', liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}', category: 'Категорія', lastTry: 'Остання спроба', @@ -480,6 +484,7 @@ const uk = { seedAfterDownload: 'Роздавати після завершення завантаження', seedTime: 'Час роздачі', minutes: 'хвилин', + seconds: 'секунд', seedRatio: 'Коефіцієнт роздачі', seedRatioHint: '0 означає роздачу лише за часом; інакше роздача зупиниться після досягнення першого обмеження.', limitTorrentUpload: 'Обмежити віддачу торрента', @@ -497,6 +502,9 @@ const uk = { torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (55 пірів і 50K). 0 пірів означає без обмежень.', torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000', torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль', + torrentStopTimeout: 'Зупиняти торрент без швидкості через', + torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.', + torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд', required: 'Обов\'язково', free: 'Вільно', preview: 'Попередній перегляд', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 566fa46..15481a2 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -245,6 +245,10 @@ const zhCN = { torrentPeerSpeedLimit: '对等节点速度阈值', torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数', torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零', + seconds: '秒', + torrentStopTimeout: '在此时间后停止无速度 Torrent', + torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。', + torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数', liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}', category: '类别', lastTry: '上次尝试', @@ -480,6 +484,7 @@ const zhCN = { seedAfterDownload: '下载完成后继续做种', seedTime: '做种时间', minutes: '分钟', + seconds: '秒', seedRatio: '做种比率', seedRatioHint: '0 表示仅按时间做种;否则达到第一个限制时停止做种。', limitTorrentUpload: '限制种子上传', @@ -497,6 +502,9 @@ const zhCN = { torrentPeerOptionsHint: '留空以使用 Aria2 默认值(55 个节点和 50K)。0 个节点表示不限制。', torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数', torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零', + torrentStopTimeout: '在此时间后停止无速度 Torrent', + torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。', + torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数', required: '必需', free: '可用空间', preview: '预览', diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 3781587..9fd6414 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -854,13 +854,15 @@ describe('useDownloadStore', () => { torrentMaxPeers: 'not-a-number' as unknown as number, torrentPeerSpeedLimit: 0 as unknown as string, torrentCheckIntegrity: 'yes' as unknown as boolean, - torrentTrackers: 123 as unknown as string + torrentTrackers: 123 as unknown as string, + torrentStopTimeout: 604801 }); expect(normalized.torrentMaxPeers).toBeUndefined(); expect(normalized.torrentPeerSpeedLimit).toBeUndefined(); expect(normalized.torrentCheckIntegrity).toBeUndefined(); expect(normalized.torrentTrackers).toBeUndefined(); + expect(normalized.torrentStopTimeout).toBeUndefined(); }); it('normalizes proxy settings for download dispatch', async () => { @@ -1512,7 +1514,8 @@ describe('useDownloadStore', () => { dateAdded: '', isTorrent: true, torrentCheckIntegrity: true, - torrentTrackers: 'https://tracker.example/announce' + torrentTrackers: 'https://tracker.example/announce', + torrentStopTimeout: 300 }, { type: 'start-now' }); const item = useDownloadStore.getState().downloads[0]; @@ -1524,7 +1527,8 @@ describe('useDownloadStore', () => { item: expect.objectContaining({ id: 'start-1', torrent_check_integrity: true, - torrent_trackers: 'https://tracker.example/announce' + torrent_trackers: 'https://tracker.example/announce', + torrent_stop_timeout: 300 }) }) ); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 37ac813..4e33e9f 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; -import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; @@ -352,6 +352,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined, torrent_check_integrity: item.torrentCheckIntegrity, torrent_trackers: item.torrentTrackers || undefined, + torrent_stop_timeout: item.torrentStopTimeout, lifecycle_generation: lifecycleGeneration.toString(), }; @@ -636,16 +637,25 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down const normalizedTrackers = typeof rawTrackers === 'string' && rawTrackers.trim() ? rawTrackers.trim() : undefined; + const rawStopTimeout = download.torrentStopTimeout as unknown; + const normalizedStopTimeout = typeof rawStopTimeout === 'number' && + Number.isInteger(rawStopTimeout) && + rawStopTimeout >= 0 && + rawStopTimeout <= MAX_TORRENT_STOP_TIMEOUT + ? rawStopTimeout + : undefined; const normalizedOptions = rawMaxPeers !== normalizedMaxPeers || rawPeerSpeedLimit !== normalizedPeerSpeedLimit || rawCheckIntegrity !== normalizedCheckIntegrity || - rawTrackers !== normalizedTrackers + rawTrackers !== normalizedTrackers || + rawStopTimeout !== normalizedStopTimeout ? { ...download, torrentMaxPeers: normalizedMaxPeers, torrentPeerSpeedLimit: normalizedPeerSpeedLimit, torrentCheckIntegrity: normalizedCheckIntegrity, - torrentTrackers: normalizedTrackers + torrentTrackers: normalizedTrackers, + torrentStopTimeout: normalizedStopTimeout } : download; @@ -2178,6 +2188,7 @@ export const useDownloadStore = create((set, get) => { torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined, torrent_check_integrity: item.torrentCheckIntegrity, torrent_trackers: item.torrentTrackers || undefined, + torrent_stop_timeout: item.torrentStopTimeout, lifecycle_generation: currentDownloadLifecycle(item.id).toString(), }); } diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index fa5bfd5..bb4d50d 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -120,6 +120,7 @@ export const normalizeSpeedLimitForBackend = (value?: string | null): string | n const MAX_TORRENT_TRACKERS = 64; const MAX_TORRENT_TRACKER_BYTES = 16 * 1024; +export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60; /** * Performs the same user-facing safety checks as the native tracker boundary.