From 67023d3f0d0bbca6eec2907cd767d3efdea80a7e Mon Sep 17 00:00:00 2001 From: NimBold Date: Sun, 2 Aug 2026 09:22:15 +0330 Subject: [PATCH] feat(torrents): support piece priority --- TORRENT_FEATURES.md | 19 +-- scripts/smoke-torrent.js | 8 +- src-tauri/src/ipc.rs | 3 + src-tauri/src/lib.rs | 3 + src-tauri/src/queue.rs | 180 +++++++++++++++++++++++++++ src/bindings/DownloadItem.ts | 2 +- src/bindings/EnqueueItem.ts | 2 +- src/components/AddDownloadsModal.tsx | 25 +++- src/components/PropertiesModal.tsx | 27 +++- src/i18n/catalogs/en.ts | 6 + src/i18n/catalogs/fa.ts | 6 + src/i18n/catalogs/he.ts | 6 + src/i18n/catalogs/ru.ts | 6 + src/i18n/catalogs/uk.ts | 6 + src/i18n/catalogs/zh-CN.ts | 6 + src/store/useDownloadStore.test.ts | 10 +- src/store/useDownloadStore.ts | 14 ++- src/utils/downloads.test.ts | 15 +++ src/utils/downloads.ts | 53 ++++++++ 19 files changed, 379 insertions(+), 18 deletions(-) diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index c96fe55..da313a1 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -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. diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index bb1710d..c14f68e 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -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 }); diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index a6561fb..45c86aa 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -203,6 +203,9 @@ pub struct DownloadItem { #[serde(default)] #[ts(optional)] pub torrent_stop_timeout: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_prioritize_piece: Option, } #[derive(Clone, Debug, Serialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 40dd567..14dfdfb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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)?; diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 9ea2919..47ef403 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -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, pub torrent_exclude_trackers: Option, pub torrent_stop_timeout: Option, + pub torrent_prioritize_piece: Option, } /// 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) -> Result Result { + 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::() + .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, 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, #[serde(default)] #[ts(optional)] + pub torrent_prioritize_piece: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -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(); diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index c4573a0..4b2e4e7 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, 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, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, torrentPrioritizePiece?: string, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 6081408..3e71407 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_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, 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, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index d96f99d..6afd326 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, 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)}

+
+ + 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" + /> +

+ {t($ => $.addDownloads.torrentPrioritizePieceHint)} +

+
)} diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 3722756..405f721 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -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(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)}

+ +
+ 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" + /> +

+ {t($ => $.properties.torrentPrioritizePieceHint)} +

+
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index eb692ee..ce889b2 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -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', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index ffbacfe..802c7f1 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -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: 'پیش‌نمایش', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 0a9db5c..8ee1b2f 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -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: 'תצוגה מקדימה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 7afd595..1dea1c6 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -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: 'Предпросмотр', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 321acf7..cd08d93 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -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: 'Попередній перегляд', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index e185426..bf1d8d8 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -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: '预览', diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 6d5e94a..d0063fc 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -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' }) }) ); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 03831cf..c493423 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -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((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(), }); } diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index ee14c81..5c30056 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -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); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 165b416..98cdadd 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -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.