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 probeDir = path.join(tempRoot, 'probe');
const finalDir = path.join(tempRoot, 'final');
const integrityDir = path.join(tempRoot, 'integrity');
const cancelDir = path.join(tempRoot, 'cancel');
for (const directory of [seedRoot, probeDir, finalDir, 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 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');
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', [
savedTorrentBytes.toString('base64'),
[],
+3
View File
@@ -191,6 +191,9 @@ pub struct DownloadItem {
#[serde(default)]
#[ts(optional)]
pub torrent_peer_speed_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_check_integrity: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+100
View File
@@ -214,6 +214,7 @@ pub struct SpawnPayload {
pub torrent_upload_limit: Option<String>,
pub torrent_max_peers: Option<u32>,
pub torrent_peer_speed_limit: Option<String>,
pub torrent_check_integrity: bool,
}
/// 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),
);
}
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(())
}
@@ -3752,6 +3769,9 @@ pub struct EnqueueItem {
pub torrent_peer_speed_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_check_integrity: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
@@ -3799,6 +3819,7 @@ impl EnqueueItem {
torrent_upload_limit: self.torrent_upload_limit,
torrent_max_peers: self.torrent_max_peers,
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));
}
#[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]
fn torrent_options_reject_invalid_seed_values() {
let mut options = serde_json::Map::new();
+1 -1
View File
@@ -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, };
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.
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 [torrentMaxPeers, setTorrentMaxPeers] = useState('');
const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [freeSpace, setFreeSpace] = useState('Unknown');
const freeSpaceRequestRef = useRef(0);
@@ -372,6 +373,7 @@ export const AddDownloadsModal = () => {
setTorrentUploadLimit('1024');
setTorrentMaxPeers('');
setTorrentPeerSpeedLimit('');
setTorrentCheckIntegrity(false);
setUseAuth(false);
setUsername('');
setPassword('');
@@ -1448,6 +1450,7 @@ export const AddDownloadsModal = () => {
torrentPeerSpeedLimit: item.isTorrent
? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined
: undefined,
torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes
}, action);
@@ -2094,6 +2097,20 @@ export const AddDownloadsModal = () => {
<span className="text-text-muted">KiB/s</span>
</div>
) : 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">
<label htmlFor="torrent-max-peers" className="text-text-muted">
{t($ => $.addDownloads.torrentMaxPeers)}
+20
View File
@@ -77,6 +77,7 @@ export const PropertiesModal = () => {
const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState('');
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
@@ -168,6 +169,7 @@ export const PropertiesModal = () => {
activeItem.torrentMaxPeers === undefined ? '' : String(activeItem.torrentMaxPeers)
);
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
setErrorMessage('');
} else {
setSelectedPropertiesDownloadId(null);
@@ -279,6 +281,7 @@ export const PropertiesModal = () => {
? {
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined,
torrentCheckIntegrity,
}
: {}),
...(connectionsDirty
@@ -701,6 +704,23 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</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) && (
+4
View File
@@ -236,6 +236,8 @@ const common = {
liveTorrentPeerOptionsApply: 'Apply peer controls',
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.',
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',
torrentPeerSpeedLimit: 'Peer speed threshold',
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',
torrentSeedRatioInvalid: 'Torrent seed ratio must be zero or greater',
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',
torrentPeerSpeedLimit: 'Peer speed threshold',
torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 peers and 50K). 0 peers means unlimited.',
+4
View File
@@ -236,6 +236,8 @@ const fa = {
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال می‌شود. برای استفاده از پیش‌فرض آریا۲ خالی بگذارید.',
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره می‌شود. صفر یعنی نامحدود؛ خالی یعنی پیش‌فرض آریا۲.',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال می‌شود. ممکن است قطعه‌ها دوباره بررسی و داده‌های خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
@@ -482,6 +484,8 @@ const fa = {
torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد',
torrentSeedRatioInvalid: 'نسبت سید تورنت نمی‌تواند منفی باشد',
torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعه‌ها را بررسی می‌کند؛ قطعه‌های خراب ممکن است دوباره دانلود شوند.',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentPeerOptionsHint: 'برای استفاده از پیش‌فرض‌های آریا۲ خالی بگذارید (۵۵ همتا و 50K). صفر یعنی نامحدود.',
+4
View File
@@ -236,6 +236,8 @@ const he = {
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
@@ -482,6 +484,8 @@ const he = {
torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס',
torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר',
torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (55 עמיתים ו-50K). אפס עמיתים פירושו ללא הגבלה.',
+4
View File
@@ -236,6 +236,8 @@ const ru = {
liveTorrentPeerOptionsApply: 'Применить настройки пиров',
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
@@ -482,6 +484,8 @@ const ru = {
torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля',
torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным',
torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.',
torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (55 пиров и 50K). 0 пиров означает без ограничений.',
+4
View File
@@ -236,6 +236,8 @@ const uk = {
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
@@ -482,6 +484,8 @@ const uk = {
torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль',
torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним',
torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.',
torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (55 пірів і 50K). 0 пірів означає без обмежень.',
+4
View File
@@ -236,6 +236,8 @@ const zhCN = {
liveTorrentPeerOptionsApply: '应用节点控制',
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值',
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
@@ -482,6 +484,8 @@ const zhCN = {
torrentSeedTimeInvalid: '做种时间必须大于零',
torrentSeedRatioInvalid: '做种比率不能小于零',
torrentUploadLimitInvalid: '种子上传限速必须大于零',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。',
torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值',
torrentPeerOptionsHint: '留空以使用 Aria2 默认值(55 个节点和 50K)。0 个节点表示不限制。',
+10 -3
View File
@@ -852,11 +852,13 @@ describe('useDownloadStore', () => {
dateAdded: '',
isTorrent: true,
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.torrentPeerSpeedLimit).toBeUndefined();
expect(normalized.torrentCheckIntegrity).toBeUndefined();
});
it('normalizes proxy settings for download dispatch', async () => {
@@ -1505,7 +1507,9 @@ describe('useDownloadStore', () => {
url: 'https://example.com/start.bin',
fileName: 'start.bin',
category: 'Other',
dateAdded: ''
dateAdded: '',
isTorrent: true,
torrentCheckIntegrity: true
}, { type: 'start-now' });
const item = useDownloadStore.getState().downloads[0];
@@ -1514,7 +1518,10 @@ describe('useDownloadStore', () => {
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
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_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -626,12 +627,18 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
: undefined;
const rawCheckIntegrity = download.torrentCheckIntegrity as unknown;
const normalizedCheckIntegrity = typeof rawCheckIntegrity === 'boolean'
? rawCheckIntegrity
: undefined;
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity
? {
...download,
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
torrentCheckIntegrity: normalizedCheckIntegrity
}
: download;
@@ -2162,6 +2169,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
+1
View File
@@ -64,6 +64,7 @@ export interface AddDownloadDraftRow {
torrentUploadLimit?: string;
torrentMaxPeers?: number;
torrentPeerSpeedLimit?: string;
torrentCheckIntegrity?: boolean;
}
/**