mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-02 15:39:37 +00:00
feat(torrents): add stall timeout control
This commit is contained in:
+10
-10
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -197,6 +197,9 @@ pub struct DownloadItem {
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_trackers: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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_stop_timeout: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<Option<Stri
|
||||
.ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_torrent_stop_timeout(value: Option<u32>) -> Result<Option<u32>, 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<Option<String>, 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<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub lifecycle_generation: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, };
|
||||
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, };
|
||||
|
||||
@@ -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, 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_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, 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)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
|
||||
<label htmlFor="torrent-stop-timeout" className="text-text-muted">
|
||||
{t($ => $.addDownloads.torrentStopTimeout)}
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
id="torrent-stop-timeout"
|
||||
type="number"
|
||||
min={0}
|
||||
max={MAX_TORRENT_STOP_TIMEOUT}
|
||||
step={1}
|
||||
value={torrentStopTimeout}
|
||||
onChange={event => setTorrentStopTimeout(event.currentTarget.value)}
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-stop-timeout-hint"
|
||||
/>
|
||||
<span className="text-[10px] text-text-muted">{t($ => $.addDownloads.seconds)}</span>
|
||||
</div>
|
||||
<p id="torrent-stop-timeout-hint" className="col-span-2 text-[10px] text-text-muted">
|
||||
{t($ => $.addDownloads.torrentStopTimeoutHint)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -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<DownloadItem> = {
|
||||
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)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="text-xs text-text-muted text-right" htmlFor="torrent-stop-timeout-properties">
|
||||
{t($ => $.properties.torrentStopTimeout)}
|
||||
</label>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="torrent-stop-timeout-properties"
|
||||
type="number"
|
||||
min={0}
|
||||
max={MAX_TORRENT_STOP_TIMEOUT}
|
||||
step={1}
|
||||
value={torrentStopTimeout}
|
||||
onChange={event => 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"
|
||||
/>
|
||||
<span className="text-[11px] text-text-muted">{t($ => $.properties.seconds)}</span>
|
||||
</div>
|
||||
<p id="torrent-stop-timeout-properties-hint" className="mt-1 text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentStopTimeoutHint)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity">
|
||||
{t($ => $.properties.torrentVerifyIntegrity)}
|
||||
</label>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'پیشنمایش',
|
||||
|
||||
@@ -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: 'תצוגה מקדימה',
|
||||
|
||||
@@ -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: 'Предпросмотр',
|
||||
|
||||
@@ -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: 'Попередній перегляд',
|
||||
|
||||
@@ -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: '预览',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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<DownloadState>((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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user