diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index eaedaa5..c96fe55 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -31,6 +31,9 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar zero-download-speed interval. - Additional per-Torrent tracker URLs through `bt-tracker`, with bounded and credential-free HTTP/HTTPS/UDP validation. +- Per-Torrent tracker exclusion through `bt-exclude-tracker`, including Aria2's + explicit `*` value for excluding all announce URLs. Exclusions are persisted, + normalized, reapplied on retries, and do not change DHT or PEX settings. - Deterministic local Aria2 smoke coverage for metadata resolution, selected output, pause/resume, ownership, cancellation/removal, unavailable trackers, daemon failure, and `bt-stop-timeout` terminal behavior; RPC-boundary @@ -40,10 +43,7 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar ### Tier 0 — reliability and user-visible control -1. **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. +No remaining Tier 0 items. ### Tier 1 — transfer policy and storage behavior @@ -71,5 +71,5 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar current explicit metadata path intentionally avoids unmapped child jobs. The first implementation in this task was remote `.torrent` metadata intake; -follow-up implementations add stall-timeout control and bounded peer -diagnostics. +follow-up implementations add stall-timeout control, bounded peer diagnostics, +and persisted tracker exclusion. diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 97c0a38..31b176c 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -952,6 +952,7 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { } sanitize_portable_torrent_trackers(object); + sanitize_portable_torrent_exclude_trackers(object); if let Some(url) = object.get("url").and_then(Value::as_str) { if let Ok(mut parsed) = url::Url::parse(url) { @@ -1010,22 +1011,26 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { } } -fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map) { - let Some(raw_value) = object.get("torrentTrackers").cloned() else { +fn sanitize_portable_torrent_tracker_field( + object: &mut serde_json::Map, + key: &str, + normalize: fn(Option<&str>) -> Result, String>, +) { + let Some(raw_value) = object.get(key).cloned() else { return; }; let Some(raw) = raw_value.as_str().map(str::to_string) else { - object.remove("torrentTrackers"); + object.remove(key); mark_portable_download_unresumable(object); return; }; let raw = raw.trim(); if raw.is_empty() { - object.remove("torrentTrackers"); + object.remove(key); return; } - let Some(normalized) = crate::queue::normalize_torrent_trackers(Some(raw)).ok().flatten() else { - object.remove("torrentTrackers"); + let Some(normalized) = normalize(Some(raw)).ok().flatten() else { + object.remove(key); mark_portable_download_unresumable(object); return; }; @@ -1033,8 +1038,12 @@ fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map) { + sanitize_portable_torrent_tracker_field( + object, + "torrentTrackers", + crate::queue::normalize_torrent_trackers, + ); +} + +fn sanitize_portable_torrent_exclude_trackers(object: &mut serde_json::Map) { + sanitize_portable_torrent_tracker_field( + object, + "torrentExcludeTrackers", + crate::queue::normalize_torrent_exclude_trackers, + ); +} + fn value_is_empty(value: &Value) -> bool { value.as_str().is_some_and(str::is_empty) || value.as_array().is_some_and(Vec::is_empty) @@ -2041,7 +2063,8 @@ mod tests { "headers": "Authorization: Bearer secret", "mirrors": "https://user:secret@example.com/mirror", "proxy": "http://user:secret@example.com:8080", - "torrentTrackers": "https://tracker.example/announce?passkey=secret" + "torrentTrackers": "https://tracker.example/announce?passkey=secret", + "torrentExcludeTrackers": "https://tracker.example/exclude?passkey=secret" }]) .to_string(); @@ -2056,6 +2079,7 @@ mod tests { assert!(saved.get(key).is_none(), "portable data retained {key}"); } assert_eq!(saved["torrentTrackers"], "https://tracker.example/announce"); + assert_eq!(saved["torrentExcludeTrackers"], "https://tracker.example/exclude"); } #[test] @@ -2068,7 +2092,8 @@ mod tests { "status": "queued", "queueId": "main", "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", - "torrentTrackers": { "token": "secret" } + "torrentTrackers": { "token": "secret" }, + "torrentExcludeTrackers": { "token": "secret" } }]) .to_string(); @@ -2076,11 +2101,32 @@ mod tests { let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert!(saved.get("torrentTrackers").is_none()); + assert!(saved.get("torrentExcludeTrackers").is_none()); assert_eq!(saved["status"], "failed"); assert_eq!(saved["resumable"], false); assert!(!saved.to_string().contains("secret")); } + #[test] + fn portable_download_persistence_keeps_wildcard_tracker_exclusion() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "download-wildcard-exclusion", + "status": "queued", + "queueId": "main", + "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + "torrentExcludeTrackers": "*" + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert_eq!(saved["torrentExcludeTrackers"], "*"); + } + #[test] fn portable_download_persistence_drops_invalid_tracker_urls() { let temp = TempDir::new().unwrap(); @@ -2091,7 +2137,8 @@ mod tests { "status": "queued", "queueId": "main", "url": "https://example.com/file.bin", - "torrentTrackers": "ftp://tracker.example/announce" + "torrentTrackers": "ftp://tracker.example/announce", + "torrentExcludeTrackers": "ftp://tracker.example/announce" }]) .to_string(); @@ -2099,6 +2146,7 @@ mod tests { let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert!(saved.get("torrentTrackers").is_none()); + assert!(saved.get("torrentExcludeTrackers").is_none()); assert_eq!(saved["status"], "failed"); assert_eq!(saved["resumable"], false); } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index e97a613..a6561fb 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -199,6 +199,9 @@ pub struct DownloadItem { pub torrent_trackers: Option, #[serde(default)] #[ts(optional)] + pub torrent_exclude_trackers: Option, + #[serde(default)] + #[ts(optional)] pub torrent_stop_timeout: Option, } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 115eb1c..40dd567 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5800,6 +5800,8 @@ 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_exclude_trackers = + queue::normalize_torrent_exclude_trackers(item.torrent_exclude_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() { diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index f8d1247..9ea2919 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_exclude_trackers: Option, pub torrent_stop_timeout: Option, } @@ -3262,7 +3263,10 @@ pub(crate) fn parse_torrent_peer_diagnostics( }) } -pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result, String> { +fn normalize_torrent_tracker_list( + value: Option<&str>, + allow_wildcard: bool, +) -> Result, String> { let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; @@ -3273,6 +3277,7 @@ pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result) -> Result) -> Result) -> Result, String> { + normalize_torrent_tracker_list(value, false) +} + +pub(crate) fn normalize_torrent_exclude_trackers( + value: Option<&str>, +) -> Result, String> { + normalize_torrent_tracker_list(value, true) +} + fn apply_aria2_torrent_options( options: &mut serde_json::Map, payload: &SpawnPayload, @@ -3391,6 +3425,11 @@ 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(trackers) = + normalize_torrent_exclude_trackers(payload.torrent_exclude_trackers.as_deref())? + { + options.insert("bt-exclude-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(), @@ -4008,6 +4047,9 @@ pub struct EnqueueItem { pub torrent_trackers: Option, #[serde(default)] #[ts(optional)] + pub torrent_exclude_trackers: Option, + #[serde(default)] + #[ts(optional)] pub torrent_stop_timeout: Option, #[serde(default)] #[ts(optional)] @@ -4060,6 +4102,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_exclude_trackers: self.torrent_exclude_trackers, torrent_stop_timeout: self.torrent_stop_timeout, }, } @@ -4284,6 +4327,33 @@ mod tests { assert!(normalize_torrent_trackers(Some(&too_many)).is_err()); } + #[test] + fn torrent_exclude_trackers_support_wildcard_and_normalized_uris() { + assert_eq!(normalize_torrent_exclude_trackers(Some("*")).unwrap(), Some("*".to_string())); + assert_eq!( + normalize_torrent_exclude_trackers(Some( + " https://tracker.example/announce\nudp://tracker.example:6969/announce " + )) + .unwrap(), + Some("https://tracker.example/announce,udp://tracker.example:6969/announce".to_string()) + ); + assert_eq!(normalize_torrent_exclude_trackers(Some(" \n\n ")).unwrap(), None); + } + + #[test] + fn torrent_exclude_trackers_reject_unsafe_or_ambiguous_values() { + for value in [ + "ftp://tracker.example/announce", + "https://user:pass@tracker.example/announce", + "https://tracker.example/announce#fragment", + "https://tracker.example/announce,*", + "*,https://tracker.example/announce", + "https://tracker.example/announce,", + ] { + assert!(normalize_torrent_exclude_trackers(Some(value)).is_err(), "{value}"); + } + } + #[test] fn torrent_trackers_are_emitted_as_the_aria2_tracker_option() { let mut options = serde_json::Map::new(); @@ -4301,6 +4371,23 @@ mod tests { ); } + #[test] + fn torrent_exclude_trackers_are_emitted_as_the_aria2_option() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_exclude_trackers: Some("*".to_string()), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert_eq!( + options.get("bt-exclude-tracker"), + Some(&serde_json::json!("*")) + ); + } + #[test] fn torrent_stop_timeout_is_normalized_and_emitted() { assert_eq!(normalize_torrent_stop_timeout(None).unwrap(), None); @@ -4410,14 +4497,17 @@ mod tests { "filename": "payload", "is_media": false, "is_torrent": true, - "torrent_trackers": "https://tracker.example/announce" + "torrent_trackers": "https://tracker.example/announce", + "torrent_exclude_trackers": "*" })) .expect("frontend enqueue payload should deserialize"); + let payload = item.into_task().payload; assert_eq!( - item.into_task().payload.torrent_trackers.as_deref(), + payload.torrent_trackers.as_deref(), Some("https://tracker.example/announce") ); + assert_eq!(payload.torrent_exclude_trackers.as_deref(), Some("*")); } #[test] diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index be31430..c4573a0 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, torrentStopTimeout?: number, }; +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, torrentExcludeTrackers?: string, torrentStopTimeout?: number, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index b8d517f..6081408 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, torrent_stop_timeout?: number, 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_exclude_trackers?: string, torrent_stop_timeout?: number, lifecycle_generation?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 21c2d20..d96f99d 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, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, 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 [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); const [freeSpace, setFreeSpace] = useState('Unknown'); const freeSpaceRequestRef = useRef(0); @@ -377,6 +378,7 @@ export const AddDownloadsModal = () => { setTorrentPeerSpeedLimit(''); setTorrentCheckIntegrity(false); setTorrentTrackers(''); + setTorrentExcludeTrackers(''); setTorrentStopTimeout('0'); setUseAuth(false); setUsername(''); @@ -978,6 +980,10 @@ export const AddDownloadsModal = () => { addToast({ message: t($ => $.addDownloads.torrentTrackersInvalid), variant: 'error', isActionable: true }); return; } + if (hasSelectedTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) { + addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true }); + return; + } if ( hasSelectedTorrent && torrentStopTimeout.trim() @@ -1466,6 +1472,7 @@ export const AddDownloadsModal = () => { : undefined, torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined, torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined, + torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined, torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), sizeBytes: item.sizeBytes @@ -2144,6 +2151,23 @@ export const AddDownloadsModal = () => { {t($ => $.addDownloads.torrentTrackersHint)}

+
+ +