feat(torrents): support piece priority

This commit is contained in:
NimBold
2026-08-02 09:22:15 +03:30
parent a46a64994d
commit 67023d3f0d
19 changed files with 379 additions and 18 deletions
+12 -7
View File
@@ -34,10 +34,13 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
- Per-Torrent tracker exclusion through `bt-exclude-tracker`, including Aria2's - Per-Torrent tracker exclusion through `bt-exclude-tracker`, including Aria2's
explicit `*` value for excluding all announce URLs. Exclusions are persisted, explicit `*` value for excluding all announce URLs. Exclusions are persisted,
normalized, reapplied on retries, and do not change DHT or PEX settings. normalized, reapplied on retries, and do not change DHT or PEX settings.
- Optional `bt-prioritize-piece` preview policy for the head, tail, or both
ends of every selected file. The constrained policy is validated, persisted,
normalized, and reapplied when a Torrent starts or retries.
- Deterministic local Aria2 smoke coverage for metadata resolution, selected - Deterministic local Aria2 smoke coverage for metadata resolution, selected
output, pause/resume, ownership, cancellation/removal, unavailable trackers, output, piece priority, pause/resume, ownership, cancellation/removal,
daemon failure, and `bt-stop-timeout` terminal behavior; RPC-boundary unavailable trackers, daemon failure, and `bt-stop-timeout` terminal behavior;
coverage is separate. RPC-boundary coverage is separate.
## Priority tiers for remaining work ## Priority tiers for remaining work
@@ -47,9 +50,11 @@ No remaining Tier 0 items.
### Tier 1 — transfer policy and storage behavior ### Tier 1 — transfer policy and storage behavior
1. **Piece/file priority** — expose `bt-prioritize-piece` for head/tail 1. **File priority beyond selection** — Aria2 exposes only the binary
previewing and a deliberate file-priority model beyond the current binary `selected` file state through its Torrent file API and has no supported
selected/unselected state. per-file priority option. Firelink therefore does not pretend that
`select-file` is file priority; this remains pending an engine capability or
a safe product-level model.
2. **Safe removal of unselected files** — expose 2. **Safe removal of unselected files** — expose
`bt-remove-unselected-file` only as an explicit destructive choice, with `bt-remove-unselected-file` only as an explicit destructive choice, with
ownership-aware confirmation and tests for cancellation, retry, and ownership-aware confirmation and tests for cancellation, retry, and
@@ -72,4 +77,4 @@ No remaining Tier 0 items.
The first implementation in this task was remote `.torrent` metadata intake; The first implementation in this task was remote `.torrent` metadata intake;
follow-up implementations add stall-timeout control, bounded peer diagnostics, follow-up implementations add stall-timeout control, bounded peer diagnostics,
and persisted tracker exclusion. persisted tracker exclusion, and piece-preview priority.
+7 -1
View File
@@ -715,6 +715,7 @@ async function main() {
dir: finalDir, dir: finalDir,
'bt-tracker': `http://127.0.0.1:${trackerPort}/announce`, 'bt-tracker': `http://127.0.0.1:${trackerPort}/announce`,
'select-file': '1', 'select-file': '1',
'bt-prioritize-piece': 'head=32K,tail=16K',
'index-out': indexOut, 'index-out': indexOut,
'max-download-limit': '32K', 'max-download-limit': '32K',
'seed-time': '0', 'seed-time': '0',
@@ -722,6 +723,11 @@ async function main() {
}, },
]); ]);
await waitForStatus(client, finalGid, 'active', 10000); await waitForStatus(client, finalGid, 'active', 10000);
const finalOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [finalGid]);
assert(
finalOptions['bt-prioritize-piece'] === 'head=32K,tail=16K',
`Aria2 did not retain the piece-priority option: ${JSON.stringify(finalOptions['bt-prioritize-piece'])}`,
);
await rpc(client.rpcPort, client.secret, 'aria2.forcePause', [finalGid]); await rpc(client.rpcPort, client.secret, 'aria2.forcePause', [finalGid]);
await waitForStatus(client, finalGid, 'paused', 10000); await waitForStatus(client, finalGid, 'paused', 10000);
await rpc(client.rpcPort, client.secret, 'aria2.unpause', [finalGid]); await rpc(client.rpcPort, client.secret, 'aria2.unpause', [finalGid]);
@@ -735,7 +741,7 @@ async function main() {
const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin')); const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin'));
assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`); assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`);
assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output'); assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output');
console.log('[OK] additional tracker injection, selected output, pause/resume, and Aria2 file ownership passed'); console.log('[OK] additional tracker injection, piece priority, selected output, pause/resume, and Aria2 file ownership passed');
const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin'); const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin');
fs.mkdirSync(path.dirname(integrityPath), { recursive: true }); fs.mkdirSync(path.dirname(integrityPath), { recursive: true });
+3
View File
@@ -203,6 +203,9 @@ pub struct DownloadItem {
#[serde(default)] #[serde(default)]
#[ts(optional)] #[ts(optional)]
pub torrent_stop_timeout: Option<u32>, pub torrent_stop_timeout: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_prioritize_piece: Option<String>,
} }
#[derive(Clone, Debug, Serialize, TS)] #[derive(Clone, Debug, Serialize, TS)]
+3
View File
@@ -5803,6 +5803,9 @@ async fn validate_torrent_enqueue(
item.torrent_exclude_trackers = item.torrent_exclude_trackers =
queue::normalize_torrent_exclude_trackers(item.torrent_exclude_trackers.as_deref())?; queue::normalize_torrent_exclude_trackers(item.torrent_exclude_trackers.as_deref())?;
item.torrent_stop_timeout = queue::normalize_torrent_stop_timeout(item.torrent_stop_timeout)?; item.torrent_stop_timeout = queue::normalize_torrent_stop_timeout(item.torrent_stop_timeout)?;
item.torrent_prioritize_piece = queue::normalize_torrent_prioritize_piece(
item.torrent_prioritize_piece.as_deref(),
)?;
validate_enqueue_uris("", item.mirrors.as_deref()).await?; validate_enqueue_uris("", item.mirrors.as_deref()).await?;
if let Some(path) = item.torrent_path.as_deref() { if let Some(path) = item.torrent_path.as_deref() {
let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?; let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?;
+180
View File
@@ -21,6 +21,7 @@ pub const MAX_QUEUE_CONCURRENT: usize = 12;
pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__"; pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__";
pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1; pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1;
pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16; pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16;
pub const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB: u64 = 1024;
pub fn clamp_download_connections(connections: i32) -> i32 { pub fn clamp_download_connections(connections: i32) -> i32 {
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX) connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
@@ -218,6 +219,7 @@ pub struct SpawnPayload {
pub torrent_trackers: Option<String>, pub torrent_trackers: Option<String>,
pub torrent_exclude_trackers: Option<String>, pub torrent_exclude_trackers: Option<String>,
pub torrent_stop_timeout: Option<u32>, pub torrent_stop_timeout: Option<u32>,
pub torrent_prioritize_piece: Option<String>,
} }
/// A sidecar spawner. In production this calls the real aria2/yt-dlp /// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -3192,6 +3194,93 @@ pub(crate) fn normalize_torrent_stop_timeout(value: Option<u32>) -> Result<Optio
Ok(Some(value)) Ok(Some(value))
} }
fn normalize_torrent_piece_priority_size(value: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
return Err("torrent piece priority size must use a positive K or M value".to_string());
}
let Some((unit_start, unit)) = value.char_indices().next_back() else {
return Err("torrent piece priority size must use a positive K or M value".to_string());
};
let amount = &value[..unit_start];
let unit = unit.to_ascii_uppercase();
if !matches!(unit, 'K' | 'M') || amount.is_empty() {
return Err("torrent piece priority size must use a positive K or M value".to_string());
}
let amount = amount
.parse::<u64>()
.map_err(|_| "torrent piece priority size must use a positive K or M value".to_string())?;
if amount == 0
|| (unit == 'M' && amount > MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB)
|| (unit == 'K' && amount > MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB * 1024)
{
return Err(format!(
"torrent piece priority size must be between 1K and {}M",
MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB
));
}
Ok(format!("{amount}{unit}"))
}
pub(crate) fn normalize_torrent_prioritize_piece(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if raw.len() > 64 {
return Err("torrent piece priority is too long".to_string());
}
let mut head = None;
let mut tail = None;
for token in raw.split(',') {
let token = token.trim();
if token.is_empty() {
return Err("torrent piece priority contains an empty entry".to_string());
}
let (keyword, size) = token
.split_once('=')
.map_or((token, None), |(keyword, size)| {
(keyword.trim(), Some(size))
});
let keyword = keyword.to_ascii_lowercase();
let normalized_size = size
.map(normalize_torrent_piece_priority_size)
.transpose()?;
let normalized = normalized_size
.map(|size| format!("{keyword}={size}"))
.unwrap_or_else(|| keyword.clone());
match keyword.as_str() {
"head" => {
if head.is_some() {
return Err("torrent piece priority cannot repeat head".to_string());
}
head = Some(normalized);
}
"tail" => {
if tail.is_some() {
return Err("torrent piece priority cannot repeat tail".to_string());
}
tail = Some(normalized);
}
_ => return Err("torrent piece priority must use head and/or tail".to_string()),
}
}
let mut normalized = Vec::with_capacity(2);
if let Some(head) = head {
normalized.push(head);
}
if let Some(tail) = tail {
normalized.push(tail);
}
if normalized.is_empty() {
return Ok(None);
}
Ok(Some(normalized.join(",")))
}
fn aria2_peer_number(value: Option<&serde_json::Value>) -> u64 { fn aria2_peer_number(value: Option<&serde_json::Value>) -> u64 {
match value { match value {
Some(serde_json::Value::String(value)) => value.parse().unwrap_or_default(), Some(serde_json::Value::String(value)) => value.parse().unwrap_or_default(),
@@ -3436,6 +3525,14 @@ fn apply_aria2_torrent_options(
serde_json::json!(stop_timeout.to_string()), serde_json::json!(stop_timeout.to_string()),
); );
} }
if let Some(piece_priority) =
normalize_torrent_prioritize_piece(payload.torrent_prioritize_piece.as_deref())?
{
options.insert(
"bt-prioritize-piece".to_string(),
serde_json::json!(piece_priority),
);
}
if payload.torrent_check_integrity { if payload.torrent_check_integrity {
options.insert( options.insert(
"check-integrity".to_string(), "check-integrity".to_string(),
@@ -4053,6 +4150,9 @@ pub struct EnqueueItem {
pub torrent_stop_timeout: Option<u32>, pub torrent_stop_timeout: Option<u32>,
#[serde(default)] #[serde(default)]
#[ts(optional)] #[ts(optional)]
pub torrent_prioritize_piece: Option<String>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>, pub lifecycle_generation: Option<String>,
} }
@@ -4104,6 +4204,7 @@ impl EnqueueItem {
torrent_trackers: self.torrent_trackers, torrent_trackers: self.torrent_trackers,
torrent_exclude_trackers: self.torrent_exclude_trackers, torrent_exclude_trackers: self.torrent_exclude_trackers,
torrent_stop_timeout: self.torrent_stop_timeout, torrent_stop_timeout: self.torrent_stop_timeout,
torrent_prioritize_piece: self.torrent_prioritize_piece,
}, },
} }
} }
@@ -4436,6 +4537,65 @@ mod tests {
assert!(!options.contains_key("bt-stop-timeout")); assert!(!options.contains_key("bt-stop-timeout"));
} }
#[test]
fn torrent_piece_priority_is_normalized_and_bounded() {
assert_eq!(
normalize_torrent_prioritize_piece(Some(" tail = 64k, HEAD ")).unwrap(),
Some("head,tail=64K".to_string())
);
assert_eq!(
normalize_torrent_prioritize_piece(Some("head=1m,tail=1024M")).unwrap(),
Some("head=1M,tail=1024M".to_string())
);
assert_eq!(normalize_torrent_prioritize_piece(Some(" ")).unwrap(), None);
for value in [
"head,head",
"tail,tail",
"middle",
"head=0K",
"tail=1G",
"head=1🙂",
"head=1K,",
] {
assert!(
normalize_torrent_prioritize_piece(Some(value)).is_err(),
"{value}"
);
}
assert!(normalize_torrent_prioritize_piece(Some("head=1025M")).is_err());
}
#[test]
fn torrent_piece_priority_is_emitted_as_the_aria2_option() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_prioritize_piece: Some("tail=2m,head".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-prioritize-piece"),
Some(&serde_json::json!("head,tail=2M"))
);
}
#[test]
fn torrent_piece_priority_is_not_applied_to_non_torrent_payloads() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
torrent_prioritize_piece: Some("head".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert!(!options.contains_key("bt-prioritize-piece"));
}
#[test] #[test]
fn torrent_peer_diagnostics_are_redacted_and_bounded() { fn torrent_peer_diagnostics_are_redacted_and_bounded() {
let mut result = vec![serde_json::json!({ let mut result = vec![serde_json::json!({
@@ -4530,6 +4690,26 @@ mod tests {
); );
} }
#[test]
fn enqueue_item_carries_torrent_piece_priority_into_the_spawn_payload() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-piece-priority",
"queue_id": "main",
"url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_prioritize_piece": "head=2M,tail=1M"
}))
.expect("frontend enqueue payload should deserialize");
assert_eq!(
item.into_task().payload.torrent_prioritize_piece.as_deref(),
Some("head=2M,tail=1M")
);
}
#[test] #[test]
fn torrent_options_reject_invalid_seed_values() { fn torrent_options_reject_invalid_seed_values() {
let mut options = serde_json::Map::new(); let mut options = serde_json::Map::new();
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus"; 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, torrentExcludeTrackers?: 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, torrentPrioritizePiece?: string, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // 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_exclude_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, torrent_prioritize_piece?: string, lifecycle_generation?: string, };
+24 -1
View File
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc'; import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import { import {
expandTilde, expandTilde,
@@ -236,6 +236,7 @@ export const AddDownloadsModal = () => {
const [torrentTrackers, setTorrentTrackers] = useState(''); const [torrentTrackers, setTorrentTrackers] = useState('');
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
const [freeSpace, setFreeSpace] = useState('Unknown'); const [freeSpace, setFreeSpace] = useState('Unknown');
const freeSpaceRequestRef = useRef(0); const freeSpaceRequestRef = useRef(0);
@@ -984,6 +985,10 @@ export const AddDownloadsModal = () => {
addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true }); addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true });
return; return;
} }
if (hasSelectedTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
return;
}
if ( if (
hasSelectedTorrent hasSelectedTorrent
&& torrentStopTimeout.trim() && torrentStopTimeout.trim()
@@ -1474,6 +1479,7 @@ export const AddDownloadsModal = () => {
torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined, torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined,
torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined, torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined,
torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined, torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined,
torrentPrioritizePiece: item.isTorrent ? normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined : undefined,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes sizeBytes: item.sizeBytes
}, action); }, action);
@@ -2223,6 +2229,23 @@ export const AddDownloadsModal = () => {
{t($ => $.addDownloads.torrentStopTimeoutHint)} {t($ => $.addDownloads.torrentStopTimeoutHint)}
</p> </p>
</div> </div>
<div className="pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-prioritize-piece" className="block text-text-muted">
{t($ => $.addDownloads.torrentPrioritizePiece)}
</label>
<input
id="torrent-prioritize-piece"
type="text"
value={torrentPrioritizePiece}
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
placeholder="head=1M,tail=1M"
aria-describedby="torrent-prioritize-piece-hint"
className="app-control mt-1 w-full px-2.5 py-1.5 text-xs font-mono"
/>
<p id="torrent-prioritize-piece-hint" className="mt-1 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentPrioritizePieceHint)}
</p>
</div>
</div> </div>
</section> </section>
)} )}
+26 -1
View File
@@ -19,7 +19,7 @@ import {
formatDownloadTotal, formatDownloadTotal,
resolveDownloadSizeDisplay resolveDownloadSizeDisplay
} from '../utils/downloadProgress'; } from '../utils/downloadProgress';
import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads'; import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime'; import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
@@ -90,6 +90,7 @@ export const PropertiesModal = () => {
const [torrentTrackers, setTorrentTrackers] = useState(''); const [torrentTrackers, setTorrentTrackers] = useState('');
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null); const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false); const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false); const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
@@ -193,6 +194,7 @@ export const PropertiesModal = () => {
setTorrentTrackers(activeItem.torrentTrackers || ''); setTorrentTrackers(activeItem.torrentTrackers || '');
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || ''); setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout)); setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || '');
setErrorMessage(''); setErrorMessage('');
} else { } else {
setSelectedPropertiesDownloadId(null); setSelectedPropertiesDownloadId(null);
@@ -343,6 +345,10 @@ export const PropertiesModal = () => {
setErrorMessage(t($ => $.properties.torrentExcludeTrackersInvalid)); setErrorMessage(t($ => $.properties.torrentExcludeTrackersInvalid));
return; return;
} }
if (item.isTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
setErrorMessage(t($ => $.properties.torrentPrioritizePieceInvalid));
return;
}
const normalizedStopTimeout = torrentStopTimeout.trim() const normalizedStopTimeout = torrentStopTimeout.trim()
? Number(torrentStopTimeout) ? Number(torrentStopTimeout)
: undefined; : undefined;
@@ -374,6 +380,7 @@ export const PropertiesModal = () => {
torrentTrackers: torrentTrackers.trim() || undefined, torrentTrackers: torrentTrackers.trim() || undefined,
torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined, torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined,
torrentStopTimeout: normalizedStopTimeout, torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
} }
: {}), : {}),
...(connectionsDirty ...(connectionsDirty
@@ -930,6 +937,24 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentStopTimeoutHint)} {t($ => $.properties.torrentStopTimeoutHint)}
</p> </p>
</div> </div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-prioritize-piece-properties">
{t($ => $.properties.torrentPrioritizePiece)}
</label>
<div>
<input
id="torrent-prioritize-piece-properties"
type="text"
value={torrentPrioritizePiece}
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
placeholder="head=1M,tail=1M"
disabled={transferLocked}
aria-describedby="torrent-prioritize-piece-properties-hint"
className="app-control w-full px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<p id="torrent-prioritize-piece-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentPrioritizePieceHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity"> <label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity">
{t($ => $.properties.torrentVerifyIntegrity)} {t($ => $.properties.torrentVerifyIntegrity)}
</label> </label>
+6
View File
@@ -265,6 +265,9 @@ const common = {
torrentStopTimeout: 'Stop stalled Torrent after', 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.', 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', torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
torrentPrioritizePiece: 'Prioritize Torrent pieces',
torrentPrioritizePieceHint: 'Optional Aria2 preview policy: head, tail, or both; each may use a size such as 1M. Changes apply when the Torrent starts or retries.',
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}', liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}',
category: 'Category', category: 'Category',
lastTry: 'Last try', lastTry: 'Last try',
@@ -524,6 +527,9 @@ const common = {
torrentStopTimeout: 'Stop stalled Torrent after', torrentStopTimeout: 'Stop stalled Torrent after',
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy.', 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', torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
torrentPrioritizePiece: 'Prioritize Torrent pieces',
torrentPrioritizePieceHint: 'Saved with this Torrent and applied on its next start or retry. Use head, tail, or both with optional K or M sizes.',
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
required: 'Required', required: 'Required',
free: 'Free', free: 'Free',
preview: 'Preview', preview: 'Preview',
+6
View File
@@ -265,6 +265,9 @@ const fa = {
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از', torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.', torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد', torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت',
torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام می‌توان اندازه‌ای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت به‌روزرسانی نشد: {{detail}}', liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت به‌روزرسانی نشد: {{detail}}',
category: 'دسته', category: 'دسته',
lastTry: 'آخرین تلاش', lastTry: 'آخرین تلاش',
@@ -524,6 +527,9 @@ const fa = {
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از', torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند.', torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند.',
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد', torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت',
torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
required: 'الزامی', required: 'الزامی',
free: 'فضای آزاد', free: 'فضای آزاد',
preview: 'پیش‌نمایش', preview: 'پیش‌نمایش',
+6
View File
@@ -265,6 +265,9 @@ const he = {
torrentStopTimeout: 'עצירת טורנט תקוע לאחר', torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.', torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות', torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}', liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}',
category: 'קטגוריה', category: 'קטגוריה',
lastTry: 'ניסיון אחרון', lastTry: 'ניסיון אחרון',
@@ -524,6 +527,9 @@ const he = {
torrentStopTimeout: 'עצירת טורנט תקוע לאחר', torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.', torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.',
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות', torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
required: 'נדרש', required: 'נדרש',
free: 'פנוי', free: 'פנוי',
preview: 'תצוגה מקדימה', preview: 'תצוגה מקדימה',
+6
View File
@@ -265,6 +265,9 @@ const ru = {
torrentStopTimeout: 'Останавливать неактивный торрент через', torrentStopTimeout: 'Останавливать неактивный торрент через',
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.', torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд', torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
torrentPrioritizePiece: 'Приоритет частей торрента',
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}', liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}',
category: 'Категория', category: 'Категория',
lastTry: 'Последняя попытка', lastTry: 'Последняя попытка',
@@ -524,6 +527,9 @@ const ru = {
torrentStopTimeout: 'Останавливать неактивный торрент через', torrentStopTimeout: 'Останавливать неактивный торрент через',
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.', torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.',
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд', torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
torrentPrioritizePiece: 'Приоритет частей торрента',
torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
required: 'Требуется', required: 'Требуется',
free: 'Свободно', free: 'Свободно',
preview: 'Предпросмотр', preview: 'Предпросмотр',
+6
View File
@@ -265,6 +265,9 @@ const uk = {
torrentStopTimeout: 'Зупиняти торрент без швидкості через', torrentStopTimeout: 'Зупиняти торрент без швидкості через',
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.', torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд', torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
torrentPrioritizePiece: 'Пріоритет частин торрента',
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}', liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}',
category: 'Категорія', category: 'Категорія',
lastTry: 'Остання спроба', lastTry: 'Остання спроба',
@@ -524,6 +527,9 @@ const uk = {
torrentStopTimeout: 'Зупиняти торрент без швидкості через', torrentStopTimeout: 'Зупиняти торрент без швидкості через',
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.', torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.',
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд', torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
torrentPrioritizePiece: 'Пріоритет частин торрента',
torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
required: 'Обов\'язково', required: 'Обов\'язково',
free: 'Вільно', free: 'Вільно',
preview: 'Попередній перегляд', preview: 'Попередній перегляд',
+6
View File
@@ -265,6 +265,9 @@ const zhCN = {
torrentStopTimeout: '在此时间后停止无速度 Torrent', torrentStopTimeout: '在此时间后停止无速度 Torrent',
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。', torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数', torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
torrentPrioritizePiece: '优先下载 Torrent 片段',
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}', liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}',
category: '类别', category: '类别',
lastTry: '上次尝试', lastTry: '上次尝试',
@@ -524,6 +527,9 @@ const zhCN = {
torrentStopTimeout: '在此时间后停止无速度 Torrent', torrentStopTimeout: '在此时间后停止无速度 Torrent',
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。', torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。',
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数', torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
torrentPrioritizePiece: '优先下载 Torrent 片段',
torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
required: '必需', required: '必需',
free: '可用空间', free: '可用空间',
preview: '预览', preview: '预览',
+7 -3
View File
@@ -856,7 +856,8 @@ describe('useDownloadStore', () => {
torrentCheckIntegrity: 'yes' as unknown as boolean, torrentCheckIntegrity: 'yes' as unknown as boolean,
torrentTrackers: 123 as unknown as string, torrentTrackers: 123 as unknown as string,
torrentExcludeTrackers: 123 as unknown as string, torrentExcludeTrackers: 123 as unknown as string,
torrentStopTimeout: 604801 torrentStopTimeout: 604801,
torrentPrioritizePiece: 'head=1G'
}); });
expect(normalized.torrentMaxPeers).toBeUndefined(); expect(normalized.torrentMaxPeers).toBeUndefined();
@@ -865,6 +866,7 @@ describe('useDownloadStore', () => {
expect(normalized.torrentTrackers).toBeUndefined(); expect(normalized.torrentTrackers).toBeUndefined();
expect(normalized.torrentExcludeTrackers).toBeUndefined(); expect(normalized.torrentExcludeTrackers).toBeUndefined();
expect(normalized.torrentStopTimeout).toBeUndefined(); expect(normalized.torrentStopTimeout).toBeUndefined();
expect(normalized.torrentPrioritizePiece).toBeUndefined();
}); });
it('normalizes proxy settings for download dispatch', async () => { it('normalizes proxy settings for download dispatch', async () => {
@@ -1518,7 +1520,8 @@ describe('useDownloadStore', () => {
torrentCheckIntegrity: true, torrentCheckIntegrity: true,
torrentTrackers: 'https://tracker.example/announce', torrentTrackers: 'https://tracker.example/announce',
torrentExcludeTrackers: '*', torrentExcludeTrackers: '*',
torrentStopTimeout: 300 torrentStopTimeout: 300,
torrentPrioritizePiece: 'head=1M,tail=1M'
}, { type: 'start-now' }); }, { type: 'start-now' });
const item = useDownloadStore.getState().downloads[0]; const item = useDownloadStore.getState().downloads[0];
@@ -1532,7 +1535,8 @@ describe('useDownloadStore', () => {
torrent_check_integrity: true, torrent_check_integrity: true,
torrent_trackers: 'https://tracker.example/announce', torrent_trackers: 'https://tracker.example/announce',
torrent_exclude_trackers: '*', torrent_exclude_trackers: '*',
torrent_stop_timeout: 300 torrent_stop_timeout: 300,
torrent_prioritize_piece: 'head=1M,tail=1M'
}) })
}) })
); );
+11 -3
View File
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue'; import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore'; import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore'; import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { import {
resolveCategoryDestination resolveCategoryDestination
} from '../utils/downloadLocations'; } from '../utils/downloadLocations';
@@ -354,6 +354,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_trackers: item.torrentTrackers || undefined, torrent_trackers: item.torrentTrackers || undefined,
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined, torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_stop_timeout: item.torrentStopTimeout, torrent_stop_timeout: item.torrentStopTimeout,
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
lifecycle_generation: lifecycleGeneration.toString(), lifecycle_generation: lifecycleGeneration.toString(),
}; };
@@ -649,12 +650,17 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawStopTimeout <= MAX_TORRENT_STOP_TIMEOUT rawStopTimeout <= MAX_TORRENT_STOP_TIMEOUT
? rawStopTimeout ? rawStopTimeout
: undefined; : undefined;
const rawPrioritizePiece = download.torrentPrioritizePiece as unknown;
const normalizedPrioritizePiece = typeof rawPrioritizePiece === 'string'
? normalizeTorrentPrioritizePiece(rawPrioritizePiece) || undefined
: undefined;
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers || const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit || rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity || rawCheckIntegrity !== normalizedCheckIntegrity ||
rawTrackers !== normalizedTrackers || rawTrackers !== normalizedTrackers ||
rawExcludeTrackers !== normalizedExcludeTrackers || rawExcludeTrackers !== normalizedExcludeTrackers ||
rawStopTimeout !== normalizedStopTimeout rawStopTimeout !== normalizedStopTimeout ||
rawPrioritizePiece !== normalizedPrioritizePiece
? { ? {
...download, ...download,
torrentMaxPeers: normalizedMaxPeers, torrentMaxPeers: normalizedMaxPeers,
@@ -662,7 +668,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
torrentCheckIntegrity: normalizedCheckIntegrity, torrentCheckIntegrity: normalizedCheckIntegrity,
torrentTrackers: normalizedTrackers, torrentTrackers: normalizedTrackers,
torrentExcludeTrackers: normalizedExcludeTrackers, torrentExcludeTrackers: normalizedExcludeTrackers,
torrentStopTimeout: normalizedStopTimeout torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizedPrioritizePiece
} }
: download; : download;
@@ -2197,6 +2204,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_trackers: item.torrentTrackers || undefined, torrent_trackers: item.torrentTrackers || undefined,
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined, torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_stop_timeout: item.torrentStopTimeout, torrent_stop_timeout: item.torrentStopTimeout,
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(), lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
}); });
} }
+15
View File
@@ -8,6 +8,7 @@ import {
canonicalizeDownloadFileName, canonicalizeDownloadFileName,
isValidTorrentExcludeTrackerList, isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList, isValidTorrentTrackerList,
normalizeTorrentPrioritizePiece,
redactDownloadForPersistence, redactDownloadForPersistence,
resolveDownloadConnections resolveDownloadConnections
} from './downloads'; } from './downloads';
@@ -77,6 +78,20 @@ describe('Torrent tracker input validation', () => {
}); });
}); });
describe('Torrent piece priority validation', () => {
it('normalizes head and tail preview policies', () => {
expect(normalizeTorrentPrioritizePiece(' tail = 64k, HEAD ')).toBe('head,tail=64K');
expect(normalizeTorrentPrioritizePiece('head=1m,tail=1024M')).toBe('head=1M,tail=1024M');
expect(normalizeTorrentPrioritizePiece('')).toBeNull();
});
it('rejects duplicate, unsupported, malformed, and oversized policies', () => {
for (const value of ['head,head', 'middle', 'head=0K', 'tail=1G', 'head=1K,', 'head=1025M']) {
expect(normalizeTorrentPrioritizePiece(value)).toBeNull();
}
});
});
describe('download connection resolution', () => { describe('download connection resolution', () => {
it('uses a clamped fallback for legacy rows without a saved value', () => { it('uses a clamped fallback for legacy rows without a saved value', () => {
expect(resolveDownloadConnections(undefined, 8)).toBe(8); expect(resolveDownloadConnections(undefined, 8)).toBe(8);
+53
View File
@@ -121,6 +121,59 @@ export const normalizeSpeedLimitForBackend = (value?: string | null): string | n
const MAX_TORRENT_TRACKERS = 64; const MAX_TORRENT_TRACKERS = 64;
const MAX_TORRENT_TRACKER_BYTES = 16 * 1024; const MAX_TORRENT_TRACKER_BYTES = 16 * 1024;
export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60; export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60;
const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB = 1024;
const normalizeTorrentPiecePrioritySize = (value: string): string | null => {
const match = value.trim().match(/^(\d+)\s*([km])$/i);
if (!match) return null;
const amount = Number(match[1]);
const unit = match[2].toUpperCase();
const maximum = unit === 'M'
? MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB
: MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB * 1024;
if (!Number.isSafeInteger(amount) || amount <= 0 || amount > maximum) return null;
return `${amount}${unit}`;
};
/**
* Normalize the constrained subset of Aria2's bt-prioritize-piece syntax
* that Firelink persists. The native validator remains authoritative because
* persisted state and older clients can bypass this helper.
*/
export const normalizeTorrentPrioritizePiece = (value?: string | null): string | null => {
const raw = value?.trim();
if (!raw) return null;
if (utf8ByteLength(raw) > 64) return null;
let head: string | undefined;
let tail: string | undefined;
for (const rawToken of raw.split(',')) {
const token = rawToken.trim();
if (!token) return null;
const separator = token.indexOf('=');
const keyword = (separator === -1 ? token : token.slice(0, separator)).trim().toLowerCase();
const size = separator === -1 ? undefined : token.slice(separator + 1);
if (!['head', 'tail'].includes(keyword) || (separator !== -1 && token.indexOf('=', separator + 1) !== -1)) {
return null;
}
const normalized = size === undefined
? keyword
: (() => {
const normalizedSize = normalizeTorrentPiecePrioritySize(size);
return normalizedSize ? `${keyword}=${normalizedSize}` : null;
})();
if (!normalized) return null;
if (keyword === 'head') {
if (head) return null;
head = normalized;
} else {
if (tail) return null;
tail = normalized;
}
}
return [head, tail].filter((part): part is string => Boolean(part)).join(',') || null;
};
/** /**
* Performs the same user-facing safety checks as the native tracker boundary. * Performs the same user-facing safety checks as the native tracker boundary.