feat(torrents): add integrity verification policy

This commit is contained in:
NimBold
2026-08-01 22:53:37 +03:30
parent 87d682709e
commit e31a3fcc90
16 changed files with 210 additions and 8 deletions
+23 -1
View File
@@ -627,8 +627,9 @@ async function main() {
const seedRoot = path.join(seedParent, 'firelink-torrent-runtime'); const seedRoot = path.join(seedParent, 'firelink-torrent-runtime');
const probeDir = path.join(tempRoot, 'probe'); const probeDir = path.join(tempRoot, 'probe');
const finalDir = path.join(tempRoot, 'final'); const finalDir = path.join(tempRoot, 'final');
const integrityDir = path.join(tempRoot, 'integrity');
const cancelDir = path.join(tempRoot, 'cancel'); const cancelDir = path.join(tempRoot, 'cancel');
for (const directory of [seedRoot, probeDir, finalDir, cancelDir]) fs.mkdirSync(directory, { recursive: true }); for (const directory of [seedRoot, probeDir, finalDir, integrityDir, cancelDir]) fs.mkdirSync(directory, { recursive: true });
const seederListenPort = await findAvailablePort(); const seederListenPort = await findAvailablePort();
const clientListenPort = await findAvailablePort(); const clientListenPort = await findAvailablePort();
@@ -730,6 +731,27 @@ async function main() {
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] selected addTorrent output, pause/resume, and Aria2 file ownership passed'); console.log('[OK] selected addTorrent output, pause/resume, and Aria2 file ownership passed');
const integrityPath = path.join(integrityDir, torrent.name, 'selected.bin');
fs.mkdirSync(path.dirname(integrityPath), { recursive: true });
fs.writeFileSync(integrityPath, Buffer.alloc(torrent.files[0].data.length, 0x00));
const integrityGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
savedTorrentBytes.toString('base64'),
[],
{
dir: integrityDir,
'select-file': '1',
'index-out': indexOut,
'check-integrity': 'true',
'bt-hash-check-seed': 'false',
'seed-time': '0',
'auto-file-renaming': 'false',
},
]);
const integrityStatus = await waitForTerminal(client, integrityGid, 30000);
assert(integrityStatus.status === 'complete', 'integrity-check torrent did not complete');
assert(fs.readFileSync(integrityPath).equals(torrent.files[0].data), 'integrity check did not replace corrupted torrent data');
console.log('[OK] check-integrity detected and repaired corrupted Torrent data');
const cancelGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ const cancelGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
savedTorrentBytes.toString('base64'), savedTorrentBytes.toString('base64'),
[], [],
+3
View File
@@ -191,6 +191,9 @@ pub struct DownloadItem {
#[serde(default)] #[serde(default)]
#[ts(optional)] #[ts(optional)]
pub torrent_peer_speed_limit: Option<String>, pub torrent_peer_speed_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_check_integrity: Option<bool>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Serialize, Deserialize, TS)]
+100
View File
@@ -214,6 +214,7 @@ pub struct SpawnPayload {
pub torrent_upload_limit: Option<String>, pub torrent_upload_limit: Option<String>,
pub torrent_max_peers: Option<u32>, pub torrent_max_peers: Option<u32>,
pub torrent_peer_speed_limit: Option<String>, pub torrent_peer_speed_limit: Option<String>,
pub torrent_check_integrity: bool,
} }
/// A sidecar spawner. In production this calls the real aria2/yt-dlp /// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -3163,6 +3164,22 @@ fn apply_aria2_torrent_options(
serde_json::json!(normalized), serde_json::json!(normalized),
); );
} }
if payload.torrent_check_integrity {
options.insert(
"check-integrity".to_string(),
serde_json::json!("true"),
);
options.insert(
"bt-hash-check-seed".to_string(),
serde_json::json!(torrent_seeding_requested(payload).to_string()),
);
// Do not let a daemon-wide bt-seed-unverified setting bypass the
// explicit per-download integrity request.
options.insert(
"bt-seed-unverified".to_string(),
serde_json::json!("false"),
);
}
Ok(()) Ok(())
} }
@@ -3752,6 +3769,9 @@ pub struct EnqueueItem {
pub torrent_peer_speed_limit: Option<String>, pub torrent_peer_speed_limit: Option<String>,
#[serde(default)] #[serde(default)]
#[ts(optional)] #[ts(optional)]
pub torrent_check_integrity: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>, pub lifecycle_generation: Option<String>,
} }
@@ -3799,6 +3819,7 @@ impl EnqueueItem {
torrent_upload_limit: self.torrent_upload_limit, torrent_upload_limit: self.torrent_upload_limit,
torrent_max_peers: self.torrent_max_peers, torrent_max_peers: self.torrent_max_peers,
torrent_peer_speed_limit: self.torrent_peer_speed_limit, torrent_peer_speed_limit: self.torrent_peer_speed_limit,
torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false),
}, },
} }
} }
@@ -3913,6 +3934,85 @@ mod tests {
assert!(torrent_seeding_requested(&payload)); assert!(torrent_seeding_requested(&payload));
} }
#[test]
fn torrent_integrity_check_does_not_enter_seeding_without_a_seed_policy() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_check_integrity: true,
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true")));
assert_eq!(
options.get("bt-hash-check-seed"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("bt-seed-unverified"),
Some(&serde_json::json!("false"))
);
}
#[test]
fn torrent_integrity_check_preserves_an_explicit_seeding_policy() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_check_integrity: true,
torrent_seed_ratio: Some(1.0),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true")));
assert_eq!(
options.get("bt-hash-check-seed"),
Some(&serde_json::json!("true"))
);
assert_eq!(
options.get("bt-seed-unverified"),
Some(&serde_json::json!("false"))
);
}
#[test]
fn torrent_integrity_options_are_not_emitted_when_disabled_or_non_torrent() {
for payload in [
SpawnPayload::default(),
SpawnPayload {
is_torrent: true,
..Default::default()
},
] {
let mut options = serde_json::Map::new();
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert!(!options.contains_key("check-integrity"));
assert!(!options.contains_key("bt-hash-check-seed"));
assert!(!options.contains_key("bt-seed-unverified"));
}
}
#[test]
fn enqueue_item_deserializes_integrity_policy_from_frontend_payload() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-integrity",
"queue_id": "main",
"url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_check_integrity": true
}))
.expect("frontend enqueue payload should deserialize");
assert!(item.into_task().payload.torrent_check_integrity);
}
#[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, }; 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, };
+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, 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, lifecycle_generation?: string, };
+17
View File
@@ -232,6 +232,7 @@ export const AddDownloadsModal = () => {
const [torrentUploadLimit, setTorrentUploadLimit] = useState('1024'); const [torrentUploadLimit, setTorrentUploadLimit] = useState('1024');
const [torrentMaxPeers, setTorrentMaxPeers] = useState(''); const [torrentMaxPeers, setTorrentMaxPeers] = useState('');
const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState(''); const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [freeSpace, setFreeSpace] = useState('Unknown'); const [freeSpace, setFreeSpace] = useState('Unknown');
const freeSpaceRequestRef = useRef(0); const freeSpaceRequestRef = useRef(0);
@@ -372,6 +373,7 @@ export const AddDownloadsModal = () => {
setTorrentUploadLimit('1024'); setTorrentUploadLimit('1024');
setTorrentMaxPeers(''); setTorrentMaxPeers('');
setTorrentPeerSpeedLimit(''); setTorrentPeerSpeedLimit('');
setTorrentCheckIntegrity(false);
setUseAuth(false); setUseAuth(false);
setUsername(''); setUsername('');
setPassword(''); setPassword('');
@@ -1448,6 +1450,7 @@ export const AddDownloadsModal = () => {
torrentPeerSpeedLimit: item.isTorrent torrentPeerSpeedLimit: item.isTorrent
? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined ? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined
: undefined, : undefined,
torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : 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);
@@ -2094,6 +2097,20 @@ export const AddDownloadsModal = () => {
<span className="text-text-muted">KiB/s</span> <span className="text-text-muted">KiB/s</span>
</div> </div>
) : null} ) : null}
<label className="flex items-start gap-2 text-text-primary pt-2 border-t border-border-modal/50">
<input
type="checkbox"
checked={torrentCheckIntegrity}
onChange={event => setTorrentCheckIntegrity(event.target.checked)}
className="accent-blue-500 mt-0.5"
/>
<span>
<span className="block">{t($ => $.addDownloads.torrentVerifyIntegrity)}</span>
<span className="block text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentVerifyIntegrityHint)}
</span>
</span>
</label>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50"> <div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-max-peers" className="text-text-muted"> <label htmlFor="torrent-max-peers" className="text-text-muted">
{t($ => $.addDownloads.torrentMaxPeers)} {t($ => $.addDownloads.torrentMaxPeers)}
+20
View File
@@ -77,6 +77,7 @@ export const PropertiesModal = () => {
const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState(''); const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState('');
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState(''); const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState(''); const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false); const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false); const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false); const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
@@ -168,6 +169,7 @@ export const PropertiesModal = () => {
activeItem.torrentMaxPeers === undefined ? '' : String(activeItem.torrentMaxPeers) activeItem.torrentMaxPeers === undefined ? '' : String(activeItem.torrentMaxPeers)
); );
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || ''); setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
setErrorMessage(''); setErrorMessage('');
} else { } else {
setSelectedPropertiesDownloadId(null); setSelectedPropertiesDownloadId(null);
@@ -279,6 +281,7 @@ export const PropertiesModal = () => {
? { ? {
torrentMaxPeers: normalizedMaxPeers, torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined, torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined,
torrentCheckIntegrity,
} }
: {}), : {}),
...(connectionsDirty ...(connectionsDirty
@@ -701,6 +704,23 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted"> <div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)} {t($ => $.properties.torrentPeerOptionsSavedHint)}
</div> </div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity">
{t($ => $.properties.torrentVerifyIntegrity)}
</label>
<label className="flex items-start gap-2 text-xs text-text-primary">
<input
id="torrent-check-integrity"
type="checkbox"
checked={torrentCheckIntegrity}
onChange={event => setTorrentCheckIntegrity(event.currentTarget.checked)}
disabled={transferLocked}
className="accent-accent mt-0.5 disabled:opacity-50"
aria-describedby="torrent-check-integrity-hint"
/>
<span id="torrent-check-integrity-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.torrentVerifyIntegrityHint)}
</span>
</label>
</> </>
)} )}
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && ( {(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
+4
View File
@@ -236,6 +236,8 @@ const common = {
liveTorrentPeerOptionsApply: 'Apply peer controls', liveTorrentPeerOptionsApply: 'Apply peer controls',
liveTorrentPeerOptionsHint: 'Changes apply without replacing the active Torrent. Leave blank to use Aria2 defaults.', liveTorrentPeerOptionsHint: 'Changes apply without replacing the active Torrent. Leave blank to use Aria2 defaults.',
torrentPeerOptionsSavedHint: 'Saved per Torrent. 0 peers means unlimited; blank uses Aria2 defaults.', torrentPeerOptionsSavedHint: 'Saved per Torrent. 0 peers means unlimited; blank uses Aria2 defaults.',
torrentVerifyIntegrity: 'Verify Torrent integrity',
torrentVerifyIntegrityHint: 'Applied when this Torrent starts or retries. It may recheck pieces and download damaged data; active transfers cannot change it.',
torrentMaxPeers: 'Maximum Torrent peers', torrentMaxPeers: 'Maximum Torrent peers',
torrentPeerSpeedLimit: 'Peer speed threshold', torrentPeerSpeedLimit: 'Peer speed threshold',
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000', torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
@@ -482,6 +484,8 @@ const common = {
torrentSeedTimeInvalid: 'Torrent seed time must be greater than zero', torrentSeedTimeInvalid: 'Torrent seed time must be greater than zero',
torrentSeedRatioInvalid: 'Torrent seed ratio must be zero or greater', torrentSeedRatioInvalid: 'Torrent seed ratio must be zero or greater',
torrentUploadLimitInvalid: 'Torrent upload limit must be greater than zero', torrentUploadLimitInvalid: 'Torrent upload limit must be greater than zero',
torrentVerifyIntegrity: 'Verify Torrent integrity',
torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.',
torrentMaxPeers: 'Maximum Torrent peers', torrentMaxPeers: 'Maximum Torrent peers',
torrentPeerSpeedLimit: 'Peer speed threshold', torrentPeerSpeedLimit: 'Peer speed threshold',
torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 peers and 50K). 0 peers means unlimited.', torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 peers and 50K). 0 peers means unlimited.',
+4
View File
@@ -236,6 +236,8 @@ const fa = {
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا', liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال می‌شود. برای استفاده از پیش‌فرض آریا۲ خالی بگذارید.', liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال می‌شود. برای استفاده از پیش‌فرض آریا۲ خالی بگذارید.',
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره می‌شود. صفر یعنی نامحدود؛ خالی یعنی پیش‌فرض آریا۲.', torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره می‌شود. صفر یعنی نامحدود؛ خالی یعنی پیش‌فرض آریا۲.',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال می‌شود. ممکن است قطعه‌ها دوباره بررسی و داده‌های خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
torrentMaxPeers: 'حداکثر همتاهای تورنت', torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا', torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد', torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
@@ -482,6 +484,8 @@ const fa = {
torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد', torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد',
torrentSeedRatioInvalid: 'نسبت سید تورنت نمی‌تواند منفی باشد', torrentSeedRatioInvalid: 'نسبت سید تورنت نمی‌تواند منفی باشد',
torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد', torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعه‌ها را بررسی می‌کند؛ قطعه‌های خراب ممکن است دوباره دانلود شوند.',
torrentMaxPeers: 'حداکثر همتاهای تورنت', torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا', torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentPeerOptionsHint: 'برای استفاده از پیش‌فرض‌های آریا۲ خالی بگذارید (۵۵ همتا و 50K). صفر یعنی نامحدود.', torrentPeerOptionsHint: 'برای استفاده از پیش‌فرض‌های آریا۲ خالی بگذارید (۵۵ همتا و 50K). صفر یعنی نامحدود.',
+4
View File
@@ -236,6 +236,8 @@ const he = {
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים', liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.', liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.', torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט', torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים', torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000', torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
@@ -482,6 +484,8 @@ const he = {
torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס', torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס',
torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר', torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר',
torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס', torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט', torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים', torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (55 עמיתים ו-50K). אפס עמיתים פירושו ללא הגבלה.', torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (55 עמיתים ו-50K). אפס עמיתים פירושו ללא הגבלה.',
+4
View File
@@ -236,6 +236,8 @@ const ru = {
liveTorrentPeerOptionsApply: 'Применить настройки пиров', liveTorrentPeerOptionsApply: 'Применить настройки пиров',
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.', liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.', torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
torrentMaxPeers: 'Максимум пиров торрента', torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров', torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000', torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
@@ -482,6 +484,8 @@ const ru = {
torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля', torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля',
torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным', torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным',
torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля', torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
torrentMaxPeers: 'Максимум пиров торрента', torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров', torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (55 пиров и 50K). 0 пиров означает без ограничений.', torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (55 пиров и 50K). 0 пиров означает без ограничений.',
+4
View File
@@ -236,6 +236,8 @@ const uk = {
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів', liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.', liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.', torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
torrentMaxPeers: 'Максимум пірів торрента', torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів', torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000', torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
@@ -482,6 +484,8 @@ const uk = {
torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль', torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль',
torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним', torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним',
torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль', torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
torrentMaxPeers: 'Максимум пірів торрента', torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів', torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (55 пірів і 50K). 0 пірів означає без обмежень.', torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (55 пірів і 50K). 0 пірів означає без обмежень.',
+4
View File
@@ -236,6 +236,8 @@ const zhCN = {
liveTorrentPeerOptionsApply: '应用节点控制', liveTorrentPeerOptionsApply: '应用节点控制',
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。', liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。', torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
torrentMaxPeers: 'Torrent 最大对等节点数', torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值', torrentPeerSpeedLimit: '对等节点速度阈值',
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数', torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
@@ -482,6 +484,8 @@ const zhCN = {
torrentSeedTimeInvalid: '做种时间必须大于零', torrentSeedTimeInvalid: '做种时间必须大于零',
torrentSeedRatioInvalid: '做种比率不能小于零', torrentSeedRatioInvalid: '做种比率不能小于零',
torrentUploadLimitInvalid: '种子上传限速必须大于零', torrentUploadLimitInvalid: '种子上传限速必须大于零',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
torrentMaxPeers: 'Torrent 最大对等节点数', torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值', torrentPeerSpeedLimit: '对等节点速度阈值',
torrentPeerOptionsHint: '留空以使用 Aria2 默认值(55 个节点和 50K)。0 个节点表示不限制。', torrentPeerOptionsHint: '留空以使用 Aria2 默认值(55 个节点和 50K)。0 个节点表示不限制。',
+10 -3
View File
@@ -852,11 +852,13 @@ describe('useDownloadStore', () => {
dateAdded: '', dateAdded: '',
isTorrent: true, isTorrent: true,
torrentMaxPeers: 'not-a-number' as unknown as number, torrentMaxPeers: 'not-a-number' as unknown as number,
torrentPeerSpeedLimit: 0 as unknown as string torrentPeerSpeedLimit: 0 as unknown as string,
torrentCheckIntegrity: 'yes' as unknown as boolean
}); });
expect(normalized.torrentMaxPeers).toBeUndefined(); expect(normalized.torrentMaxPeers).toBeUndefined();
expect(normalized.torrentPeerSpeedLimit).toBeUndefined(); expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
expect(normalized.torrentCheckIntegrity).toBeUndefined();
}); });
it('normalizes proxy settings for download dispatch', async () => { it('normalizes proxy settings for download dispatch', async () => {
@@ -1505,7 +1507,9 @@ describe('useDownloadStore', () => {
url: 'https://example.com/start.bin', url: 'https://example.com/start.bin',
fileName: 'start.bin', fileName: 'start.bin',
category: 'Other', category: 'Other',
dateAdded: '' dateAdded: '',
isTorrent: true,
torrentCheckIntegrity: true
}, { type: 'start-now' }); }, { type: 'start-now' });
const item = useDownloadStore.getState().downloads[0]; const item = useDownloadStore.getState().downloads[0];
@@ -1514,7 +1518,10 @@ describe('useDownloadStore', () => {
expect(ipc.invokeCommand).toHaveBeenCalledWith( expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download', 'enqueue_download',
expect.objectContaining({ expect.objectContaining({
item: expect.objectContaining({ id: 'start-1' }) item: expect.objectContaining({
id: 'start-1',
torrent_check_integrity: true
})
}) })
); );
}); });
+10 -2
View File
@@ -350,6 +350,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_upload_limit: item.torrentUploadLimit || undefined, torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers, torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined, torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
lifecycle_generation: lifecycleGeneration.toString(), lifecycle_generation: lifecycleGeneration.toString(),
}; };
@@ -626,12 +627,18 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string' const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined ? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
: undefined; : undefined;
const rawCheckIntegrity = download.torrentCheckIntegrity as unknown;
const normalizedCheckIntegrity = typeof rawCheckIntegrity === 'boolean'
? rawCheckIntegrity
: undefined;
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers || const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity
? { ? {
...download, ...download,
torrentMaxPeers: normalizedMaxPeers, torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
torrentCheckIntegrity: normalizedCheckIntegrity
} }
: download; : download;
@@ -2162,6 +2169,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_upload_limit: item.torrentUploadLimit || undefined, torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers, torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined, torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(), lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
}); });
} }
+1
View File
@@ -64,6 +64,7 @@ export interface AddDownloadDraftRow {
torrentUploadLimit?: string; torrentUploadLimit?: string;
torrentMaxPeers?: number; torrentMaxPeers?: number;
torrentPeerSpeedLimit?: string; torrentPeerSpeedLimit?: string;
torrentCheckIntegrity?: boolean;
} }
/** /**