mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-02 07:30:18 +00:00
feat(torrents): support piece priority
This commit is contained in:
+12
-7
@@ -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
|
||||
explicit `*` value for excluding all announce URLs. Exclusions are persisted,
|
||||
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
|
||||
output, pause/resume, ownership, cancellation/removal, unavailable trackers,
|
||||
daemon failure, and `bt-stop-timeout` terminal behavior; RPC-boundary
|
||||
coverage is separate.
|
||||
output, piece priority, pause/resume, ownership, cancellation/removal,
|
||||
unavailable trackers, daemon failure, and `bt-stop-timeout` terminal behavior;
|
||||
RPC-boundary coverage is separate.
|
||||
|
||||
## Priority tiers for remaining work
|
||||
|
||||
@@ -47,9 +50,11 @@ No remaining Tier 0 items.
|
||||
|
||||
### Tier 1 — transfer policy and storage behavior
|
||||
|
||||
1. **Piece/file priority** — expose `bt-prioritize-piece` for head/tail
|
||||
previewing and a deliberate file-priority model beyond the current binary
|
||||
selected/unselected state.
|
||||
1. **File priority beyond selection** — Aria2 exposes only the binary
|
||||
`selected` file state through its Torrent file API and has no supported
|
||||
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
|
||||
`bt-remove-unselected-file` only as an explicit destructive choice, with
|
||||
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;
|
||||
follow-up implementations add stall-timeout control, bounded peer diagnostics,
|
||||
and persisted tracker exclusion.
|
||||
persisted tracker exclusion, and piece-preview priority.
|
||||
|
||||
@@ -715,6 +715,7 @@ async function main() {
|
||||
dir: finalDir,
|
||||
'bt-tracker': `http://127.0.0.1:${trackerPort}/announce`,
|
||||
'select-file': '1',
|
||||
'bt-prioritize-piece': 'head=32K,tail=16K',
|
||||
'index-out': indexOut,
|
||||
'max-download-limit': '32K',
|
||||
'seed-time': '0',
|
||||
@@ -722,6 +723,11 @@ async function main() {
|
||||
},
|
||||
]);
|
||||
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 waitForStatus(client, finalGid, 'paused', 10000);
|
||||
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'));
|
||||
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');
|
||||
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');
|
||||
fs.mkdirSync(path.dirname(integrityPath), { recursive: true });
|
||||
|
||||
@@ -203,6 +203,9 @@ pub struct DownloadItem {
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_prioritize_piece: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
|
||||
@@ -5803,6 +5803,9 @@ async fn validate_torrent_enqueue(
|
||||
item.torrent_exclude_trackers =
|
||||
queue::normalize_torrent_exclude_trackers(item.torrent_exclude_trackers.as_deref())?;
|
||||
item.torrent_stop_timeout = queue::normalize_torrent_stop_timeout(item.torrent_stop_timeout)?;
|
||||
item.torrent_prioritize_piece = queue::normalize_torrent_prioritize_piece(
|
||||
item.torrent_prioritize_piece.as_deref(),
|
||||
)?;
|
||||
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)?;
|
||||
|
||||
@@ -21,6 +21,7 @@ pub const MAX_QUEUE_CONCURRENT: usize = 12;
|
||||
pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__";
|
||||
pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1;
|
||||
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 {
|
||||
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
|
||||
@@ -218,6 +219,7 @@ pub struct SpawnPayload {
|
||||
pub torrent_trackers: Option<String>,
|
||||
pub torrent_exclude_trackers: Option<String>,
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
pub torrent_prioritize_piece: Option<String>,
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
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 {
|
||||
match value {
|
||||
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()),
|
||||
);
|
||||
}
|
||||
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 {
|
||||
options.insert(
|
||||
"check-integrity".to_string(),
|
||||
@@ -4053,6 +4150,9 @@ pub struct EnqueueItem {
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_prioritize_piece: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub lifecycle_generation: Option<String>,
|
||||
}
|
||||
|
||||
@@ -4104,6 +4204,7 @@ impl EnqueueItem {
|
||||
torrent_trackers: self.torrent_trackers,
|
||||
torrent_exclude_trackers: self.torrent_exclude_trackers,
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn torrent_peer_diagnostics_are_redacted_and_bounded() {
|
||||
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]
|
||||
fn torrent_options_reject_invalid_seed_values() {
|
||||
let mut options = serde_json::Map::new();
|
||||
|
||||
@@ -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, 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,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_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, };
|
||||
|
||||
@@ -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, 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 {
|
||||
expandTilde,
|
||||
@@ -236,6 +236,7 @@ export const AddDownloadsModal = () => {
|
||||
const [torrentTrackers, setTorrentTrackers] = useState('');
|
||||
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
|
||||
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
||||
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
|
||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||
const freeSpaceRequestRef = useRef(0);
|
||||
|
||||
@@ -984,6 +985,10 @@ export const AddDownloadsModal = () => {
|
||||
addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
|
||||
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
hasSelectedTorrent
|
||||
&& torrentStopTimeout.trim()
|
||||
@@ -1474,6 +1479,7 @@ export const AddDownloadsModal = () => {
|
||||
torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined,
|
||||
torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : 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),
|
||||
sizeBytes: item.sizeBytes
|
||||
}, action);
|
||||
@@ -2223,6 +2229,23 @@ export const AddDownloadsModal = () => {
|
||||
{t($ => $.addDownloads.torrentStopTimeoutHint)}
|
||||
</p>
|
||||
</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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} 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 { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||
@@ -90,6 +90,7 @@ export const PropertiesModal = () => {
|
||||
const [torrentTrackers, setTorrentTrackers] = useState('');
|
||||
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
|
||||
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
||||
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
|
||||
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
|
||||
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
|
||||
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
|
||||
@@ -193,6 +194,7 @@ export const PropertiesModal = () => {
|
||||
setTorrentTrackers(activeItem.torrentTrackers || '');
|
||||
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
|
||||
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
|
||||
setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || '');
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
@@ -343,6 +345,10 @@ export const PropertiesModal = () => {
|
||||
setErrorMessage(t($ => $.properties.torrentExcludeTrackersInvalid));
|
||||
return;
|
||||
}
|
||||
if (item.isTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
|
||||
setErrorMessage(t($ => $.properties.torrentPrioritizePieceInvalid));
|
||||
return;
|
||||
}
|
||||
const normalizedStopTimeout = torrentStopTimeout.trim()
|
||||
? Number(torrentStopTimeout)
|
||||
: undefined;
|
||||
@@ -374,6 +380,7 @@ export const PropertiesModal = () => {
|
||||
torrentTrackers: torrentTrackers.trim() || undefined,
|
||||
torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined,
|
||||
torrentStopTimeout: normalizedStopTimeout,
|
||||
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
|
||||
}
|
||||
: {}),
|
||||
...(connectionsDirty
|
||||
@@ -930,6 +937,24 @@ export const PropertiesModal = () => {
|
||||
{t($ => $.properties.torrentStopTimeoutHint)}
|
||||
</p>
|
||||
</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">
|
||||
{t($ => $.properties.torrentVerifyIntegrity)}
|
||||
</label>
|
||||
|
||||
@@ -265,6 +265,9 @@ const common = {
|
||||
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',
|
||||
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}}',
|
||||
category: 'Category',
|
||||
lastTry: 'Last try',
|
||||
@@ -524,6 +527,9 @@ const common = {
|
||||
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',
|
||||
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',
|
||||
free: 'Free',
|
||||
preview: 'Preview',
|
||||
|
||||
@@ -265,6 +265,9 @@ const fa = {
|
||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
||||
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
||||
torrentPrioritizePieceHint: 'سیاست اختیاری پیشنمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام میتوان اندازهای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||
liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت بهروزرسانی نشد: {{detail}}',
|
||||
category: 'دسته',
|
||||
lastTry: 'آخرین تلاش',
|
||||
@@ -524,6 +527,9 @@ const fa = {
|
||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند.',
|
||||
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
||||
torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
|
||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||
required: 'الزامی',
|
||||
free: 'فضای آزاد',
|
||||
preview: 'پیشنمایش',
|
||||
|
||||
@@ -265,6 +265,9 @@ const he = {
|
||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
|
||||
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
||||
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
|
||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||
liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}',
|
||||
category: 'קטגוריה',
|
||||
lastTry: 'ניסיון אחרון',
|
||||
@@ -524,6 +527,9 @@ const he = {
|
||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.',
|
||||
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
||||
torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
|
||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||
required: 'נדרש',
|
||||
free: 'פנוי',
|
||||
preview: 'תצוגה מקדימה',
|
||||
|
||||
@@ -265,6 +265,9 @@ const ru = {
|
||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Приоритет частей торрента',
|
||||
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
|
||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||
liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}',
|
||||
category: 'Категория',
|
||||
lastTry: 'Последняя попытка',
|
||||
@@ -524,6 +527,9 @@ const ru = {
|
||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Приоритет частей торрента',
|
||||
torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
|
||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||
required: 'Требуется',
|
||||
free: 'Свободно',
|
||||
preview: 'Предпросмотр',
|
||||
|
||||
@@ -265,6 +265,9 @@ const uk = {
|
||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
||||
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
|
||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||
liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}',
|
||||
category: 'Категорія',
|
||||
lastTry: 'Остання спроба',
|
||||
@@ -524,6 +527,9 @@ const uk = {
|
||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
||||
torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
|
||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||
required: 'Обов\'язково',
|
||||
free: 'Вільно',
|
||||
preview: 'Попередній перегляд',
|
||||
|
||||
@@ -265,6 +265,9 @@ const zhCN = {
|
||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
|
||||
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
||||
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
||||
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
|
||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||
liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}',
|
||||
category: '类别',
|
||||
lastTry: '上次尝试',
|
||||
@@ -524,6 +527,9 @@ const zhCN = {
|
||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。',
|
||||
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
||||
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
||||
torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
|
||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||
required: '必需',
|
||||
free: '可用空间',
|
||||
preview: '预览',
|
||||
|
||||
@@ -856,7 +856,8 @@ describe('useDownloadStore', () => {
|
||||
torrentCheckIntegrity: 'yes' as unknown as boolean,
|
||||
torrentTrackers: 123 as unknown as string,
|
||||
torrentExcludeTrackers: 123 as unknown as string,
|
||||
torrentStopTimeout: 604801
|
||||
torrentStopTimeout: 604801,
|
||||
torrentPrioritizePiece: 'head=1G'
|
||||
});
|
||||
|
||||
expect(normalized.torrentMaxPeers).toBeUndefined();
|
||||
@@ -865,6 +866,7 @@ describe('useDownloadStore', () => {
|
||||
expect(normalized.torrentTrackers).toBeUndefined();
|
||||
expect(normalized.torrentExcludeTrackers).toBeUndefined();
|
||||
expect(normalized.torrentStopTimeout).toBeUndefined();
|
||||
expect(normalized.torrentPrioritizePiece).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes proxy settings for download dispatch', async () => {
|
||||
@@ -1518,7 +1520,8 @@ describe('useDownloadStore', () => {
|
||||
torrentCheckIntegrity: true,
|
||||
torrentTrackers: 'https://tracker.example/announce',
|
||||
torrentExcludeTrackers: '*',
|
||||
torrentStopTimeout: 300
|
||||
torrentStopTimeout: 300,
|
||||
torrentPrioritizePiece: 'head=1M,tail=1M'
|
||||
}, { type: 'start-now' });
|
||||
|
||||
const item = useDownloadStore.getState().downloads[0];
|
||||
@@ -1532,7 +1535,8 @@ describe('useDownloadStore', () => {
|
||||
torrent_check_integrity: true,
|
||||
torrent_trackers: 'https://tracker.example/announce',
|
||||
torrent_exclude_trackers: '*',
|
||||
torrent_stop_timeout: 300
|
||||
torrent_stop_timeout: 300,
|
||||
torrent_prioritize_piece: 'head=1M,tail=1M'
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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, 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 {
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
@@ -354,6 +354,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
torrent_trackers: item.torrentTrackers || undefined,
|
||||
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||
torrent_stop_timeout: item.torrentStopTimeout,
|
||||
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
|
||||
lifecycle_generation: lifecycleGeneration.toString(),
|
||||
};
|
||||
|
||||
@@ -649,12 +650,17 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
rawStopTimeout <= MAX_TORRENT_STOP_TIMEOUT
|
||||
? rawStopTimeout
|
||||
: undefined;
|
||||
const rawPrioritizePiece = download.torrentPrioritizePiece as unknown;
|
||||
const normalizedPrioritizePiece = typeof rawPrioritizePiece === 'string'
|
||||
? normalizeTorrentPrioritizePiece(rawPrioritizePiece) || undefined
|
||||
: undefined;
|
||||
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
|
||||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
|
||||
rawCheckIntegrity !== normalizedCheckIntegrity ||
|
||||
rawTrackers !== normalizedTrackers ||
|
||||
rawExcludeTrackers !== normalizedExcludeTrackers ||
|
||||
rawStopTimeout !== normalizedStopTimeout
|
||||
rawStopTimeout !== normalizedStopTimeout ||
|
||||
rawPrioritizePiece !== normalizedPrioritizePiece
|
||||
? {
|
||||
...download,
|
||||
torrentMaxPeers: normalizedMaxPeers,
|
||||
@@ -662,7 +668,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
torrentCheckIntegrity: normalizedCheckIntegrity,
|
||||
torrentTrackers: normalizedTrackers,
|
||||
torrentExcludeTrackers: normalizedExcludeTrackers,
|
||||
torrentStopTimeout: normalizedStopTimeout
|
||||
torrentStopTimeout: normalizedStopTimeout,
|
||||
torrentPrioritizePiece: normalizedPrioritizePiece
|
||||
}
|
||||
: download;
|
||||
|
||||
@@ -2197,6 +2204,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
torrent_trackers: item.torrentTrackers || undefined,
|
||||
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||
torrent_stop_timeout: item.torrentStopTimeout,
|
||||
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
|
||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
canonicalizeDownloadFileName,
|
||||
isValidTorrentExcludeTrackerList,
|
||||
isValidTorrentTrackerList,
|
||||
normalizeTorrentPrioritizePiece,
|
||||
redactDownloadForPersistence,
|
||||
resolveDownloadConnections
|
||||
} 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', () => {
|
||||
it('uses a clamped fallback for legacy rows without a saved value', () => {
|
||||
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
|
||||
|
||||
@@ -121,6 +121,59 @@ 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;
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user