mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-02 07:30:18 +00:00
feat(torrents): support tracker exclusion
This commit is contained in:
+6
-6
@@ -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.
|
||||
|
||||
+63
-15
@@ -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<String, Value>) {
|
||||
let Some(raw_value) = object.get("torrentTrackers").cloned() else {
|
||||
fn sanitize_portable_torrent_tracker_field(
|
||||
object: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
normalize: fn(Option<&str>) -> Result<Option<String>, 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<String, Value
|
||||
let mut sanitized = Vec::new();
|
||||
let mut removed_context = false;
|
||||
for token in normalized.split(',') {
|
||||
if token == "*" {
|
||||
sanitized.push(token.to_string());
|
||||
continue;
|
||||
}
|
||||
let Ok(mut parsed) = url::Url::parse(token) else {
|
||||
object.remove("torrentTrackers");
|
||||
object.remove(key);
|
||||
mark_portable_download_unresumable(object);
|
||||
return;
|
||||
};
|
||||
@@ -1053,18 +1062,31 @@ fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map<String, Value
|
||||
}
|
||||
|
||||
if sanitized.is_empty() {
|
||||
object.remove("torrentTrackers");
|
||||
object.remove(key);
|
||||
} else {
|
||||
object.insert(
|
||||
"torrentTrackers".to_string(),
|
||||
Value::String(sanitized.join(",")),
|
||||
);
|
||||
object.insert(key.to_string(), Value::String(sanitized.join(",")));
|
||||
}
|
||||
if removed_context {
|
||||
mark_portable_download_unresumable(object);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map<String, Value>) {
|
||||
sanitize_portable_torrent_tracker_field(
|
||||
object,
|
||||
"torrentTrackers",
|
||||
crate::queue::normalize_torrent_trackers,
|
||||
);
|
||||
}
|
||||
|
||||
fn sanitize_portable_torrent_exclude_trackers(object: &mut serde_json::Map<String, Value>) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -199,6 +199,9 @@ pub struct DownloadItem {
|
||||
pub torrent_trackers: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_exclude_trackers: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+93
-3
@@ -216,6 +216,7 @@ pub struct SpawnPayload {
|
||||
pub torrent_peer_speed_limit: Option<String>,
|
||||
pub torrent_check_integrity: bool,
|
||||
pub torrent_trackers: Option<String>,
|
||||
pub torrent_exclude_trackers: Option<String>,
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -3262,7 +3263,10 @@ pub(crate) fn parse_torrent_peer_diagnostics(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<String>, String> {
|
||||
fn normalize_torrent_tracker_list(
|
||||
value: Option<&str>,
|
||||
allow_wildcard: bool,
|
||||
) -> Result<Option<String>, 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<Option<S
|
||||
}
|
||||
|
||||
let mut trackers = Vec::new();
|
||||
let mut wildcard = false;
|
||||
let mut serialized_bytes = 0usize;
|
||||
for line in raw.split(['\r', '\n']) {
|
||||
let line = line.trim();
|
||||
@@ -3287,6 +3292,22 @@ pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<S
|
||||
if token.chars().any(char::is_control) {
|
||||
return Err("torrent tracker URI contains a control character".to_string());
|
||||
}
|
||||
if allow_wildcard && token == "*" {
|
||||
if !trackers.is_empty() {
|
||||
return Err(
|
||||
"torrent tracker exclusion wildcard cannot be combined with tracker URLs"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
wildcard = true;
|
||||
continue;
|
||||
}
|
||||
if wildcard {
|
||||
return Err(
|
||||
"torrent tracker exclusion wildcard cannot be combined with tracker URLs"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let parsed = url::Url::parse(token)
|
||||
.map_err(|_| "torrent tracker URI is invalid".to_string())?;
|
||||
if !matches!(parsed.scheme(), "http" | "https" | "udp") {
|
||||
@@ -3324,12 +3345,25 @@ pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<S
|
||||
}
|
||||
}
|
||||
|
||||
if wildcard {
|
||||
return Ok(Some("*".to_string()));
|
||||
}
|
||||
if trackers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(trackers.join(",")))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<String>, String> {
|
||||
normalize_torrent_tracker_list(value, false)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_torrent_exclude_trackers(
|
||||
value: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
normalize_torrent_tracker_list(value, true)
|
||||
}
|
||||
|
||||
fn apply_aria2_torrent_options(
|
||||
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_exclude_trackers: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
#[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]
|
||||
|
||||
@@ -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<number>, 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<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, };
|
||||
|
||||
@@ -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<number>, 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<number>, 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, };
|
||||
|
||||
@@ -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)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-border-modal/50">
|
||||
<label htmlFor="torrent-exclude-trackers" className="block text-text-muted">
|
||||
{t($ => $.addDownloads.torrentExcludeTrackers)}
|
||||
</label>
|
||||
<textarea
|
||||
id="torrent-exclude-trackers"
|
||||
rows={3}
|
||||
value={torrentExcludeTrackers}
|
||||
onChange={event => setTorrentExcludeTrackers(event.currentTarget.value)}
|
||||
placeholder="https://tracker.example/announce or *"
|
||||
aria-describedby="torrent-exclude-trackers-hint"
|
||||
className="app-control mt-1 min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono"
|
||||
/>
|
||||
<p id="torrent-exclude-trackers-hint" className="mt-1 text-[10px] text-text-muted">
|
||||
{t($ => $.addDownloads.torrentExcludeTrackersHint)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
|
||||
<label htmlFor="torrent-max-peers" className="text-text-muted">
|
||||
{t($ => $.addDownloads.torrentMaxPeers)}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
import { isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads';
|
||||
import { isValidTorrentExcludeTrackerList, 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';
|
||||
@@ -88,6 +88,7 @@ export const PropertiesModal = () => {
|
||||
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
|
||||
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
|
||||
const [torrentTrackers, setTorrentTrackers] = useState('');
|
||||
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
|
||||
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
||||
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
|
||||
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
|
||||
@@ -190,6 +191,7 @@ export const PropertiesModal = () => {
|
||||
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
|
||||
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
|
||||
setTorrentTrackers(activeItem.torrentTrackers || '');
|
||||
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
|
||||
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
@@ -337,6 +339,10 @@ export const PropertiesModal = () => {
|
||||
setErrorMessage(t($ => $.properties.torrentTrackersInvalid));
|
||||
return;
|
||||
}
|
||||
if (item.isTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) {
|
||||
setErrorMessage(t($ => $.properties.torrentExcludeTrackersInvalid));
|
||||
return;
|
||||
}
|
||||
const normalizedStopTimeout = torrentStopTimeout.trim()
|
||||
? Number(torrentStopTimeout)
|
||||
: undefined;
|
||||
@@ -366,6 +372,7 @@ export const PropertiesModal = () => {
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined,
|
||||
torrentCheckIntegrity,
|
||||
torrentTrackers: torrentTrackers.trim() || undefined,
|
||||
torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined,
|
||||
torrentStopTimeout: normalizedStopTimeout,
|
||||
}
|
||||
: {}),
|
||||
@@ -882,6 +889,24 @@ export const PropertiesModal = () => {
|
||||
{t($ => $.properties.torrentTrackersHint)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="text-xs text-text-muted text-right" htmlFor="torrent-exclude-trackers-properties">
|
||||
{t($ => $.properties.torrentExcludeTrackers)}
|
||||
</label>
|
||||
<div>
|
||||
<textarea
|
||||
id="torrent-exclude-trackers-properties"
|
||||
rows={3}
|
||||
value={torrentExcludeTrackers}
|
||||
onChange={event => setTorrentExcludeTrackers(event.currentTarget.value)}
|
||||
placeholder="https://tracker.example/announce or *"
|
||||
disabled={transferLocked}
|
||||
aria-describedby="torrent-exclude-trackers-properties-hint"
|
||||
className="app-control min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
<p id="torrent-exclude-trackers-properties-hint" className="mt-1 text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentExcludeTrackersHint)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="text-xs text-text-muted text-right" htmlFor="torrent-stop-timeout-properties">
|
||||
{t($ => $.properties.torrentStopTimeout)}
|
||||
</label>
|
||||
|
||||
@@ -239,6 +239,9 @@ const common = {
|
||||
torrentTrackers: 'Additional Torrent trackers',
|
||||
torrentTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line. Optional comma-separated entries are also accepted; credentials are not allowed.',
|
||||
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
|
||||
torrentExcludeTrackers: 'Excluded Torrent trackers',
|
||||
torrentExcludeTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line, or * to exclude all announce URLs. Credentials are not allowed; DHT and PEX settings are unchanged.',
|
||||
torrentExcludeTrackersInvalid: 'Excluded Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials, or *.',
|
||||
torrentVerifyIntegrity: 'Verify Torrent integrity',
|
||||
torrentVerifyIntegrityHint: 'Applied when this Torrent starts or retries. It may recheck pieces and download damaged data; active transfers cannot change it.',
|
||||
torrentMaxPeers: 'Maximum Torrent peers',
|
||||
@@ -508,6 +511,9 @@ const common = {
|
||||
torrentTrackers: 'Additional Torrent trackers',
|
||||
torrentTrackersHint: 'Saved with this Torrent and applied on its next start or retry.',
|
||||
torrentTrackersInvalid: 'Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials.',
|
||||
torrentExcludeTrackers: 'Excluded Torrent trackers',
|
||||
torrentExcludeTrackersHint: 'Saved with this Torrent and applied on its next start or retry. * excludes all announce URLs; DHT and PEX settings are unchanged.',
|
||||
torrentExcludeTrackersInvalid: 'Excluded Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials, or *.',
|
||||
torrentVerifyIntegrity: 'Verify Torrent integrity',
|
||||
torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.',
|
||||
torrentMaxPeers: 'Maximum Torrent peers',
|
||||
|
||||
@@ -239,6 +239,9 @@ const fa = {
|
||||
torrentTrackers: 'Trackerهای اضافی تورنت',
|
||||
torrentTrackersHint: 'هر Tracker را در یک خط بنویسید. HTTP، HTTPS یا UDP؛ اطلاعات ورود مجاز نیست.',
|
||||
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
|
||||
torrentExcludeTrackers: 'Trackerهای مستثناشده تورنت',
|
||||
torrentExcludeTrackersHint: 'در هر خط یک Tracker از نوع HTTP، HTTPS یا UDP، یا * برای حذف همه آدرسهای announce وارد کنید. اطلاعات ورود مجاز نیست؛ تنظیمات DHT و PEX تغییر نمیکند.',
|
||||
torrentExcludeTrackersInvalid: 'فهرست Trackerهای مستثناشده نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود، یا * استفاده کنید.',
|
||||
torrentVerifyIntegrity: 'بررسی صحت تورنت',
|
||||
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال میشود. ممکن است قطعهها دوباره بررسی و دادههای خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
|
||||
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
||||
@@ -508,6 +511,9 @@ const fa = {
|
||||
torrentTrackers: 'Trackerهای اضافی تورنت',
|
||||
torrentTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود.',
|
||||
torrentTrackersInvalid: 'فهرست Trackerهای تورنت نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود استفاده کنید.',
|
||||
torrentExcludeTrackers: 'Trackerهای مستثناشده تورنت',
|
||||
torrentExcludeTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. * همه آدرسهای announce را حذف میکند؛ تنظیمات DHT و PEX تغییر نمیکند.',
|
||||
torrentExcludeTrackersInvalid: 'فهرست Trackerهای مستثناشده نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود، یا * استفاده کنید.',
|
||||
torrentVerifyIntegrity: 'بررسی صحت تورنت',
|
||||
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعهها را بررسی میکند؛ قطعههای خراب ممکن است دوباره دانلود شوند.',
|
||||
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
||||
|
||||
@@ -239,6 +239,9 @@ const he = {
|
||||
torrentTrackers: 'עוקבי טורנט נוספים',
|
||||
torrentTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה. פרטי התחברות אינם מותרים.',
|
||||
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
|
||||
torrentExcludeTrackers: 'עוקבי טורנט להחרגה',
|
||||
torrentExcludeTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה, או * כדי להחריג את כל כתובות ההכרזה. פרטי התחברות אינם מותרים; הגדרות DHT ו-PEX לא ישתנו.',
|
||||
torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.',
|
||||
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
|
||||
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
|
||||
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
||||
@@ -508,6 +511,9 @@ const he = {
|
||||
torrentTrackers: 'עוקבי טורנט נוספים',
|
||||
torrentTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא.',
|
||||
torrentTrackersInvalid: 'רשימת עוקבי הטורנט אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות.',
|
||||
torrentExcludeTrackers: 'עוקבי טורנט להחרגה',
|
||||
torrentExcludeTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא. * מחריג את כל כתובות ההכרזה; הגדרות DHT ו-PEX לא ישתנו.',
|
||||
torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.',
|
||||
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
|
||||
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
|
||||
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
||||
|
||||
@@ -239,6 +239,9 @@ const ru = {
|
||||
torrentTrackers: 'Дополнительные трекеры торрента',
|
||||
torrentTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке. Данные для входа не допускаются.',
|
||||
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
|
||||
torrentExcludeTrackers: 'Исключаемые трекеры торрента',
|
||||
torrentExcludeTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке или * для исключения всех announce-адресов. Данные для входа не допускаются; настройки DHT и PEX не изменяются.',
|
||||
torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.',
|
||||
torrentVerifyIntegrity: 'Проверять целостность торрента',
|
||||
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
|
||||
torrentMaxPeers: 'Максимум пиров торрента',
|
||||
@@ -508,6 +511,9 @@ const ru = {
|
||||
torrentTrackers: 'Дополнительные трекеры торрента',
|
||||
torrentTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке.',
|
||||
torrentTrackersInvalid: 'Список трекеров торрента недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа.',
|
||||
torrentExcludeTrackers: 'Исключаемые трекеры торрента',
|
||||
torrentExcludeTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке. * исключает все announce-адреса; настройки DHT и PEX не изменяются.',
|
||||
torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.',
|
||||
torrentVerifyIntegrity: 'Проверять целостность торрента',
|
||||
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
|
||||
torrentMaxPeers: 'Максимум пиров торрента',
|
||||
|
||||
@@ -239,6 +239,9 @@ const uk = {
|
||||
torrentTrackers: 'Додаткові трекери торрента',
|
||||
torrentTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку. Дані для входу не дозволені.',
|
||||
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
|
||||
torrentExcludeTrackers: 'Виключені трекери торрента',
|
||||
torrentExcludeTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку або * для виключення всіх announce-адрес. Дані для входу не дозволені; налаштування DHT і PEX не змінюються.',
|
||||
torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.',
|
||||
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
|
||||
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
|
||||
torrentMaxPeers: 'Максимум пірів торрента',
|
||||
@@ -508,6 +511,9 @@ const uk = {
|
||||
torrentTrackers: 'Додаткові трекери торрента',
|
||||
torrentTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби.',
|
||||
torrentTrackersInvalid: 'Список трекерів торрента недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу.',
|
||||
torrentExcludeTrackers: 'Виключені трекери торрента',
|
||||
torrentExcludeTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. * виключає всі announce-адреси; налаштування DHT і PEX не змінюються.',
|
||||
torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.',
|
||||
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
|
||||
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
|
||||
torrentMaxPeers: 'Максимум пірів торрента',
|
||||
|
||||
@@ -239,6 +239,9 @@ const zhCN = {
|
||||
torrentTrackers: '其他 Torrent Tracker',
|
||||
torrentTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker。不允许填写凭据。',
|
||||
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
|
||||
torrentExcludeTrackers: '排除的 Torrent Tracker',
|
||||
torrentExcludeTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker,或使用 * 排除所有 announce 地址。不允许填写凭据;不会更改 DHT 和 PEX 设置。',
|
||||
torrentExcludeTrackersInvalid: '排除的 Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址,或使用 *。',
|
||||
torrentVerifyIntegrity: '验证 Torrent 完整性',
|
||||
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
|
||||
torrentMaxPeers: 'Torrent 最大对等节点数',
|
||||
@@ -508,6 +511,9 @@ const zhCN = {
|
||||
torrentTrackers: '其他 Torrent Tracker',
|
||||
torrentTrackersHint: '随该 Torrent 保存,并在下次启动或重试时应用。',
|
||||
torrentTrackersInvalid: 'Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址。',
|
||||
torrentExcludeTrackers: '排除的 Torrent Tracker',
|
||||
torrentExcludeTrackersHint: '随该 Torrent 保存,并在下次启动或重试时应用。* 会排除所有 announce 地址;不会更改 DHT 和 PEX 设置。',
|
||||
torrentExcludeTrackersInvalid: '排除的 Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址,或使用 *。',
|
||||
torrentVerifyIntegrity: '验证 Torrent 完整性',
|
||||
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
|
||||
torrentMaxPeers: 'Torrent 最大对等节点数',
|
||||
|
||||
@@ -855,6 +855,7 @@ describe('useDownloadStore', () => {
|
||||
torrentPeerSpeedLimit: 0 as unknown as string,
|
||||
torrentCheckIntegrity: 'yes' as unknown as boolean,
|
||||
torrentTrackers: 123 as unknown as string,
|
||||
torrentExcludeTrackers: 123 as unknown as string,
|
||||
torrentStopTimeout: 604801
|
||||
});
|
||||
|
||||
@@ -862,6 +863,7 @@ describe('useDownloadStore', () => {
|
||||
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
|
||||
expect(normalized.torrentCheckIntegrity).toBeUndefined();
|
||||
expect(normalized.torrentTrackers).toBeUndefined();
|
||||
expect(normalized.torrentExcludeTrackers).toBeUndefined();
|
||||
expect(normalized.torrentStopTimeout).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1515,6 +1517,7 @@ describe('useDownloadStore', () => {
|
||||
isTorrent: true,
|
||||
torrentCheckIntegrity: true,
|
||||
torrentTrackers: 'https://tracker.example/announce',
|
||||
torrentExcludeTrackers: '*',
|
||||
torrentStopTimeout: 300
|
||||
}, { type: 'start-now' });
|
||||
|
||||
@@ -1528,6 +1531,7 @@ describe('useDownloadStore', () => {
|
||||
id: 'start-1',
|
||||
torrent_check_integrity: true,
|
||||
torrent_trackers: 'https://tracker.example/announce',
|
||||
torrent_exclude_trackers: '*',
|
||||
torrent_stop_timeout: 300
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, 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_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||
torrent_stop_timeout: item.torrentStopTimeout,
|
||||
lifecycle_generation: lifecycleGeneration.toString(),
|
||||
};
|
||||
@@ -634,9 +635,13 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
? rawCheckIntegrity
|
||||
: undefined;
|
||||
const rawTrackers = download.torrentTrackers as unknown;
|
||||
const normalizedTrackers = typeof rawTrackers === 'string' && rawTrackers.trim()
|
||||
const normalizedTrackers = typeof rawTrackers === 'string' && rawTrackers.trim() && isValidTorrentTrackerList(rawTrackers)
|
||||
? rawTrackers.trim()
|
||||
: undefined;
|
||||
const rawExcludeTrackers = download.torrentExcludeTrackers as unknown;
|
||||
const normalizedExcludeTrackers = typeof rawExcludeTrackers === 'string' && rawExcludeTrackers.trim() && isValidTorrentExcludeTrackerList(rawExcludeTrackers)
|
||||
? rawExcludeTrackers.trim()
|
||||
: undefined;
|
||||
const rawStopTimeout = download.torrentStopTimeout as unknown;
|
||||
const normalizedStopTimeout = typeof rawStopTimeout === 'number' &&
|
||||
Number.isInteger(rawStopTimeout) &&
|
||||
@@ -648,6 +653,7 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
|
||||
rawCheckIntegrity !== normalizedCheckIntegrity ||
|
||||
rawTrackers !== normalizedTrackers ||
|
||||
rawExcludeTrackers !== normalizedExcludeTrackers ||
|
||||
rawStopTimeout !== normalizedStopTimeout
|
||||
? {
|
||||
...download,
|
||||
@@ -655,6 +661,7 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
|
||||
torrentCheckIntegrity: normalizedCheckIntegrity,
|
||||
torrentTrackers: normalizedTrackers,
|
||||
torrentExcludeTrackers: normalizedExcludeTrackers,
|
||||
torrentStopTimeout: normalizedStopTimeout
|
||||
}
|
||||
: download;
|
||||
@@ -2188,6 +2195,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
torrent_check_integrity: item.torrentCheckIntegrity,
|
||||
torrent_trackers: item.torrentTrackers || undefined,
|
||||
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||
torrent_stop_timeout: item.torrentStopTimeout,
|
||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface AddDownloadDraftRow {
|
||||
torrentPeerSpeedLimit?: string;
|
||||
torrentCheckIntegrity?: boolean;
|
||||
torrentTrackers?: string;
|
||||
torrentExcludeTrackers?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
downloadMediaKindsMatch,
|
||||
MAX_DOWNLOAD_FILENAME_BYTES,
|
||||
canonicalizeDownloadFileName,
|
||||
isValidTorrentExcludeTrackerList,
|
||||
isValidTorrentTrackerList,
|
||||
redactDownloadForPersistence,
|
||||
resolveDownloadConnections
|
||||
@@ -66,6 +67,14 @@ describe('Torrent tracker input validation', () => {
|
||||
expect(isValidTorrentTrackerList('https://tracker.example/announce,')).toBe(false);
|
||||
expect(isValidTorrentTrackerList(Array.from({ length: 65 }, (_, index) => `https://tracker${index}.example/announce`).join('\n'))).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts wildcard exclusions but rejects ambiguous mixtures', () => {
|
||||
expect(isValidTorrentExcludeTrackerList('*')).toBe(true);
|
||||
expect(isValidTorrentExcludeTrackerList('https://tracker.example/announce\nudp://tracker.example:6969/announce')).toBe(true);
|
||||
expect(isValidTorrentExcludeTrackerList('*,https://tracker.example/announce')).toBe(false);
|
||||
expect(isValidTorrentExcludeTrackerList('https://tracker.example/announce,*')).toBe(false);
|
||||
expect(isValidTorrentExcludeTrackerList('https://user:pass@tracker.example/announce')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download connection resolution', () => {
|
||||
|
||||
+15
-2
@@ -127,12 +127,13 @@ export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60;
|
||||
* The Rust validator remains authoritative because persisted data can bypass
|
||||
* this helper and the browser URL parser is not the native URL parser.
|
||||
*/
|
||||
export const isValidTorrentTrackerList = (value: string): boolean => {
|
||||
const isValidTorrentTrackerListInternal = (value: string, allowWildcard: boolean): boolean => {
|
||||
const raw = value.trim();
|
||||
if (!raw) return true;
|
||||
if (utf8ByteLength(raw) > MAX_TORRENT_TRACKER_BYTES) return false;
|
||||
|
||||
const normalized = new Set<string>();
|
||||
let wildcard = false;
|
||||
let serializedBytes = 0;
|
||||
for (const line of raw.split(/[\r\n]/)) {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -142,6 +143,12 @@ export const isValidTorrentTrackerList = (value: string): boolean => {
|
||||
if (!token || [...token].some(character => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f)) {
|
||||
return false;
|
||||
}
|
||||
if (allowWildcard && token === '*') {
|
||||
if (normalized.size > 0) return false;
|
||||
wildcard = true;
|
||||
continue;
|
||||
}
|
||||
if (wildcard) return false;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(token);
|
||||
@@ -162,9 +169,15 @@ export const isValidTorrentTrackerList = (value: string): boolean => {
|
||||
if (serializedBytes > MAX_TORRENT_TRACKER_BYTES) return false;
|
||||
}
|
||||
}
|
||||
return normalized.size > 0;
|
||||
return normalized.size > 0 || wildcard;
|
||||
};
|
||||
|
||||
export const isValidTorrentTrackerList = (value: string): boolean =>
|
||||
isValidTorrentTrackerListInternal(value, false);
|
||||
|
||||
export const isValidTorrentExcludeTrackerList = (value: string): boolean =>
|
||||
isValidTorrentTrackerListInternal(value, true);
|
||||
|
||||
export const initMediaDomains = async () => {
|
||||
try {
|
||||
const domains = await invoke('get_supported_media_domains');
|
||||
|
||||
Reference in New Issue
Block a user