feat(torrents): add live peer controls

This commit is contained in:
NimBold
2026-08-01 22:27:32 +03:30
parent 52082c1e1d
commit ab6c3c372e
18 changed files with 970 additions and 11 deletions
+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, };
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, };
+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, 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, lifecycle_generation?: string, };
+54 -1
View File
@@ -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 } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, normalizeSpeedLimitForBackend } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
expandTilde,
@@ -230,6 +230,8 @@ export const AddDownloadsModal = () => {
const [torrentSeedRatio, setTorrentSeedRatio] = useState('1.0');
const [torrentUploadLimitEnabled, setTorrentUploadLimitEnabled] = useState(false);
const [torrentUploadLimit, setTorrentUploadLimit] = useState('1024');
const [torrentMaxPeers, setTorrentMaxPeers] = useState('');
const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState('');
const [freeSpace, setFreeSpace] = useState('Unknown');
const freeSpaceRequestRef = useRef(0);
@@ -368,6 +370,8 @@ export const AddDownloadsModal = () => {
setTorrentSeedRatio('1.0');
setTorrentUploadLimitEnabled(false);
setTorrentUploadLimit('1024');
setTorrentMaxPeers('');
setTorrentPeerSpeedLimit('');
setUseAuth(false);
setUsername('');
setPassword('');
@@ -954,6 +958,18 @@ export const AddDownloadsModal = () => {
addToast({ message: t($ => $.addDownloads.torrentUploadLimitInvalid), variant: 'error', isActionable: true });
return;
}
if (
hasSelectedTorrent
&& torrentMaxPeers.trim()
&& (!Number.isInteger(Number(torrentMaxPeers)) || Number(torrentMaxPeers) < 0 || Number(torrentMaxPeers) > 1000)
) {
addToast({ message: t($ => $.addDownloads.torrentMaxPeersInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentPeerSpeedLimit.trim() && !normalizeSpeedLimitForBackend(torrentPeerSpeedLimit)) {
addToast({ message: t($ => $.addDownloads.torrentPeerSpeedLimitInvalid), variant: 'error', isActionable: true });
return;
}
if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) {
addToast({
message: t($ => $.addDownloads.dedicatedFolderNameRequired),
@@ -1428,6 +1444,10 @@ export const AddDownloadsModal = () => {
torrentSeedTime: item.isTorrent && torrentSeedingEnabled ? Number(torrentSeedTime) : undefined,
torrentSeedRatio: item.isTorrent && torrentSeedingEnabled ? Number(torrentSeedRatio) : undefined,
torrentUploadLimit: item.isTorrent && torrentUploadLimitEnabled ? `${torrentUploadLimit}K` : undefined,
torrentMaxPeers: item.isTorrent && torrentMaxPeers.trim() ? Number(torrentMaxPeers) : undefined,
torrentPeerSpeedLimit: item.isTorrent
? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined
: undefined,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes
}, action);
@@ -2074,6 +2094,39 @@ export const AddDownloadsModal = () => {
<span className="text-text-muted">KiB/s</span>
</div>
) : null}
<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)}
</label>
<input
id="torrent-max-peers"
type="number"
min={0}
max={1000}
step={1}
value={torrentMaxPeers}
onChange={event => setTorrentMaxPeers(event.target.value)}
placeholder="55"
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-describedby="torrent-peer-options-hint"
/>
<label htmlFor="torrent-peer-speed-limit" className="text-text-muted">
{t($ => $.addDownloads.torrentPeerSpeedLimit)}
</label>
<input
id="torrent-peer-speed-limit"
type="text"
inputMode="decimal"
value={torrentPeerSpeedLimit}
onChange={event => setTorrentPeerSpeedLimit(event.target.value)}
placeholder="50K"
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-describedby="torrent-peer-options-hint"
/>
<p id="torrent-peer-options-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentPeerOptionsHint)}
</p>
</div>
</div>
</section>
)}
+148 -1
View File
@@ -16,7 +16,7 @@ import {
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads';
import { normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
@@ -75,8 +75,11 @@ export const PropertiesModal = () => {
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState('');
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
const [username, setUsername] = useState('');
@@ -101,6 +104,7 @@ export const PropertiesModal = () => {
actionRequestRef.current += 1;
setIsLiveSpeedLimitPending(false);
setIsLiveTorrentUploadLimitPending(false);
setIsLiveTorrentPeerOptionsPending(false);
}, [selectedPropertiesDownloadId]);
useEffect(() => {
@@ -160,6 +164,10 @@ export const PropertiesModal = () => {
}
setCookies(activeItem.cookies || '');
setMirrors(activeItem.mirrors || '');
setLiveTorrentMaxPeersValue(
activeItem.torrentMaxPeers === undefined ? '' : String(activeItem.torrentMaxPeers)
);
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
setErrorMessage('');
} else {
setSelectedPropertiesDownloadId(null);
@@ -177,6 +185,13 @@ export const PropertiesModal = () => {
setLiveTorrentUploadLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
}, [item?.torrentUploadLimit, selectedPropertiesDownloadId]);
useEffect(() => {
setLiveTorrentMaxPeersValue(
item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers)
);
setLiveTorrentPeerSpeedLimitValue(item?.torrentPeerSpeedLimit || '');
}, [item?.torrentMaxPeers, item?.torrentPeerSpeedLimit, selectedPropertiesDownloadId]);
useEffect(() => {
if (!selectedPropertiesDownloadId || connectionsDirty) return;
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
@@ -232,6 +247,23 @@ export const PropertiesModal = () => {
return;
}
const normalizedMaxPeers = liveTorrentMaxPeersValue.trim()
? Number(liveTorrentMaxPeersValue)
: undefined;
if (
item.isTorrent
&& normalizedMaxPeers !== undefined
&& (!Number.isInteger(normalizedMaxPeers) || normalizedMaxPeers < 0 || normalizedMaxPeers > 1000)
) {
setErrorMessage(t($ => $.properties.torrentMaxPeersInvalid));
return;
}
const normalizedPeerSpeedLimit = normalizeSpeedLimitForBackend(liveTorrentPeerSpeedLimitValue);
if (item.isTorrent && liveTorrentPeerSpeedLimitValue.trim() && !normalizedPeerSpeedLimit) {
setErrorMessage(t($ => $.properties.torrentPeerSpeedLimitInvalid));
return;
}
const updates: Partial<DownloadItem> = {
url,
fileName,
@@ -243,6 +275,12 @@ export const PropertiesModal = () => {
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
...(item.isTorrent
? {
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined,
}
: {}),
...(connectionsDirty
? { connections: resolveDownloadConnections(connections, perServerConnections) }
: {}),
@@ -362,11 +400,41 @@ export const PropertiesModal = () => {
}
};
const handleLiveTorrentPeerOptions = async () => {
if (
isLiveTorrentPeerOptionsPending
|| !item.isTorrent
|| !['downloading', 'seeding', 'retrying'].includes(item.status)
) return;
setErrorMessage('');
const requestId = ++actionRequestRef.current;
setIsLiveTorrentPeerOptionsPending(true);
try {
await useDownloadStore.getState().setTorrentPeerOptions(
item.id,
liveTorrentMaxPeersValue,
liveTorrentPeerSpeedLimitValue
);
} catch (error) {
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
setErrorMessage(t($ => $.properties.liveTorrentPeerOptionsFailed, {
detail: error instanceof Error ? error.message : String(error)
}));
}
} finally {
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
setIsLiveTorrentPeerOptionsPending(false);
}
}
};
const identityLocked = getIdentityLocked(item.status);
const transferLocked = getTransferLocked(item.status);
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status);
const liveTorrentUploadLimitAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
const liveTorrentPeerOptionsAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
const observedConnectionTotal = Math.max(
1,
@@ -606,6 +674,35 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.savedPerDownload)}
</div>
{item.isTorrent && (
<>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.torrentMaxPeers)}</label>
<input
type="number"
min={0}
max={1000}
step={1}
value={liveTorrentMaxPeersValue}
onChange={event => setLiveTorrentMaxPeersValue(event.currentTarget.value)}
placeholder="55"
disabled={transferLocked}
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.torrentPeerSpeedLimit)}</label>
<input
type="text"
inputMode="decimal"
value={liveTorrentPeerSpeedLimitValue}
onChange={event => setLiveTorrentPeerSpeedLimitValue(event.currentTarget.value)}
placeholder="50K"
disabled={transferLocked}
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</div>
</>
)}
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
{liveSpeedLimitAvailable ? (
@@ -692,6 +789,56 @@ export const PropertiesModal = () => {
</p>
</div>
)}
{liveTorrentPeerOptionsAvailable && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.liveTorrentPeerOptions)}
</div>
<div className="grid grid-cols-[1fr_auto] items-center gap-2">
<label htmlFor="live-torrent-max-peers" className="text-[11px] text-text-muted">
{t($ => $.properties.torrentMaxPeers)}
</label>
<input
id="live-torrent-max-peers"
type="number"
min={0}
max={1000}
step={1}
value={liveTorrentMaxPeersValue}
onChange={event => setLiveTorrentMaxPeersValue(event.currentTarget.value)}
placeholder="55"
disabled={isLiveTorrentPeerOptionsPending}
aria-describedby="live-torrent-peer-options-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
<label htmlFor="live-torrent-peer-speed-limit" className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerSpeedLimit)}
</label>
<input
id="live-torrent-peer-speed-limit"
type="text"
inputMode="decimal"
value={liveTorrentPeerSpeedLimitValue}
onChange={event => setLiveTorrentPeerSpeedLimitValue(event.currentTarget.value)}
placeholder="50K"
disabled={isLiveTorrentPeerOptionsPending}
aria-describedby="live-torrent-peer-options-hint"
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
/>
</div>
<button
type="button"
onClick={() => void handleLiveTorrentPeerOptions()}
disabled={isLiveTorrentPeerOptionsPending}
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveTorrentPeerOptionsApply)}
</button>
<p id="live-torrent-peer-options-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.liveTorrentPeerOptionsHint)}
</p>
</div>
)}
</div>
</section>
+14
View File
@@ -232,6 +232,15 @@ const common = {
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
liveTorrentUploadLimitFailed: 'Could not update the live Torrent upload limit: {{detail}}',
liveTorrentPeerOptions: 'Live Torrent peer controls',
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.',
torrentMaxPeers: 'Maximum Torrent peers',
torrentPeerSpeedLimit: 'Peer speed threshold',
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}',
category: 'Category',
lastTry: 'Last try',
dateAdded: 'Date added',
@@ -473,6 +482,11 @@ 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',
torrentMaxPeers: 'Maximum Torrent peers',
torrentPeerSpeedLimit: 'Peer speed threshold',
torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 peers and 50K). 0 peers means unlimited.',
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
required: 'Required',
free: 'Free',
preview: 'Preview',
+14
View File
@@ -232,6 +232,15 @@ const fa = {
liveTorrentUploadLimitHint: 'برای تورنت‌های فعال و در حال سید اعمال می‌شود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
liveTorrentUploadLimitFailed: 'به‌روزرسانی محدودیت زنده آپلود تورنت ممکن نیست: {{detail}}',
liveTorrentPeerOptions: 'کنترل زنده همتاهای تورنت',
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال می‌شود. برای استفاده از پیش‌فرض آریا۲ خالی بگذارید.',
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره می‌شود. صفر یعنی نامحدود؛ خالی یعنی پیش‌فرض آریا۲.',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت به‌روزرسانی نشد: {{detail}}',
category: 'دسته',
lastTry: 'آخرین تلاش',
dateAdded: 'تاریخ افزودن',
@@ -473,6 +482,11 @@ const fa = {
torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد',
torrentSeedRatioInvalid: 'نسبت سید تورنت نمی‌تواند منفی باشد',
torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentPeerOptionsHint: 'برای استفاده از پیش‌فرض‌های آریا۲ خالی بگذارید (۵۵ همتا و 50K). صفر یعنی نامحدود.',
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
required: 'الزامی',
free: 'فضای آزاد',
preview: 'پیش‌نمایش',
+14
View File
@@ -232,6 +232,15 @@ const he = {
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
liveTorrentUploadLimitFailed: 'לא ניתן לעדכן את הגבלת העלאת הטורנט בזמן אמת: {{detail}}',
liveTorrentPeerOptions: 'בקרות עמיתי טורנט בזמן אמת',
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}',
category: 'קטגוריה',
lastTry: 'ניסיון אחרון',
dateAdded: 'תאריך הוספה',
@@ -473,6 +482,11 @@ const he = {
torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס',
torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר',
torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (55 עמיתים ו-50K). אפס עמיתים פירושו ללא הגבלה.',
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
required: 'נדרש',
free: 'פנוי',
preview: 'תצוגה מקדימה',
+14
View File
@@ -232,6 +232,15 @@ const ru = {
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
liveTorrentUploadLimitFailed: 'Не удалось обновить текущий лимит отдачи торрента: {{detail}}',
liveTorrentPeerOptions: 'Текущие настройки пиров торрента',
liveTorrentPeerOptionsApply: 'Применить настройки пиров',
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.',
torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}',
category: 'Категория',
lastTry: 'Последняя попытка',
dateAdded: 'Дата добавления',
@@ -473,6 +482,11 @@ const ru = {
torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля',
torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным',
torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля',
torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (55 пиров и 50K). 0 пиров означает без ограничений.',
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
required: 'Требуется',
free: 'Свободно',
preview: 'Предпросмотр',
+14
View File
@@ -232,6 +232,15 @@ const uk = {
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
liveTorrentUploadLimitFailed: 'Не вдалося оновити поточний ліміт віддачі торрента: {{detail}}',
liveTorrentPeerOptions: 'Поточні налаштування пірів торрента',
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.',
torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}',
category: 'Категорія',
lastTry: 'Остання спроба',
dateAdded: 'Дата додавання',
@@ -473,6 +482,11 @@ const uk = {
torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль',
torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним',
torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль',
torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (55 пірів і 50K). 0 пірів означає без обмежень.',
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
required: 'Обов\'язково',
free: 'Вільно',
preview: 'Попередній перегляд',
+14
View File
@@ -232,6 +232,15 @@ const zhCN = {
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
liveTorrentUploadLimitPlaceholder: '例如 1024K',
liveTorrentUploadLimitFailed: '无法更新实时种子上传限速:{{detail}}',
liveTorrentPeerOptions: 'Torrent 实时对等节点控制',
liveTorrentPeerOptionsApply: '应用节点控制',
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。',
torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值',
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}',
category: '类别',
lastTry: '上次尝试',
dateAdded: '添加日期',
@@ -473,6 +482,11 @@ const zhCN = {
torrentSeedTimeInvalid: '做种时间必须大于零',
torrentSeedRatioInvalid: '做种比率不能小于零',
torrentUploadLimitInvalid: '种子上传限速必须大于零',
torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值',
torrentPeerOptionsHint: '留空以使用 Aria2 默认值(55 个节点和 50K)。0 个节点表示不限制。',
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
required: '必需',
free: '可用空间',
preview: '预览',
+4
View File
@@ -71,6 +71,10 @@ type CommandMap = {
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
set_torrent_upload_limit: { args: { id: string; limit: string | null }; result: void };
set_torrent_peer_options: {
args: { id: string; max_peers: number | null; peer_speed_limit: string | null };
result: void;
};
set_global_speed_limit: { args: { limit: string | null }; result: void };
request_automation_permission: { args: undefined; result: void };
check_automation_permission: { args: undefined; result: void };
+129
View File
@@ -461,6 +461,91 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads[0].torrentUploadLimit).toBe('512K');
});
it('updates active Torrent peer options and clears them to Aria2 defaults', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-torrent-peers',
status: 'seeding',
isMedia: false,
isTorrent: true,
torrentMaxPeers: 120,
torrentPeerSpeedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers', '240', '2M');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_peer_options', {
id: 'live-torrent-peers',
max_peers: 240,
peer_speed_limit: '2M'
});
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
torrentMaxPeers: 240,
torrentPeerSpeedLimit: '2M'
});
await useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers', null, null);
const peerOptionCalls = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'set_torrent_peer_options');
expect(peerOptionCalls[peerOptionCalls.length - 1]).toEqual(['set_torrent_peer_options', {
id: 'live-torrent-peers',
max_peers: null,
peer_speed_limit: null
}]);
expect(useDownloadStore.getState().downloads[0].torrentMaxPeers).toBeUndefined();
expect(useDownloadStore.getState().downloads[0].torrentPeerSpeedLimit).toBeUndefined();
});
it('rejects invalid or inactive live Torrent peer options', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'ordinary-peers', status: 'downloading', isMedia: false, isTorrent: false },
{ id: 'paused-peers', status: 'paused', isMedia: false, isTorrent: true }
] as any[]
});
await expect(useDownloadStore.getState().setTorrentPeerOptions('ordinary-peers', '100', '2M'))
.rejects.toThrow('only for Torrent');
await expect(useDownloadStore.getState().setTorrentPeerOptions('paused-peers', '100', '2M'))
.rejects.toThrow('active Torrent');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_peer_options', expect.anything());
useDownloadStore.setState({
downloads: [{ id: 'invalid-peers', status: 'downloading', isMedia: false, isTorrent: true }] as any[]
});
await expect(useDownloadStore.getState().setTorrentPeerOptions('invalid-peers', '1001', '2M'))
.rejects.toThrow('between 0 and 1000');
await expect(useDownloadStore.getState().setTorrentPeerOptions('invalid-peers', '100', 'not-a-rate'))
.rejects.toThrow('valid Torrent peer speed');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_peer_options', expect.anything());
});
it('keeps prior Torrent peer options when the backend rejects the update', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-torrent-peers-failure',
status: 'downloading',
isMedia: false,
isTorrent: true,
torrentMaxPeers: 120,
torrentPeerSpeedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'set_torrent_peer_options') throw new Error('aria2 unavailable');
return undefined;
});
await expect(useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers-failure', '240', '2M'))
.rejects.toThrow('aria2 unavailable');
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
torrentMaxPeers: 120,
torrentPeerSpeedLimit: '512K'
});
});
it('rejects live speed changes for media and inactive downloads', async () => {
useDownloadStore.setState({
downloads: [
@@ -610,6 +695,33 @@ describe('useDownloadStore', () => {
.toEqual(['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001']);
});
it('skips malformed persisted download records without blocking startup', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
if (cmd === 'db_get_all_downloads') {
return [
'{not-json',
JSON.stringify(null),
JSON.stringify([]),
JSON.stringify({
id: 'valid-after-corruption',
url: 'https://example.com/valid.bin',
fileName: 'valid.bin',
status: 'ready',
category: 'Other',
dateAdded: ''
})
];
}
return undefined;
});
await useDownloadStore.getState().initDB();
expect(useDownloadStore.getState().downloads.map(download => download.id))
.toEqual(['valid-after-corruption']);
});
it('moves persisted paused rows behind runnable rows and assigns contiguous positions', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') {
@@ -730,6 +842,23 @@ describe('useDownloadStore', () => {
})).toBe(true);
});
it('clears malformed persisted Torrent peer options', () => {
const normalized = normalizePersistedDownloadProgress({
id: 'malformed-torrent-options',
url: 'magnet:?xt=urn:btih:bad',
fileName: 'payload',
status: 'queued',
category: 'Other',
dateAdded: '',
isTorrent: true,
torrentMaxPeers: 'not-a-number' as unknown as number,
torrentPeerSpeedLimit: 0 as unknown as string
});
expect(normalized.torrentMaxPeers).toBeUndefined();
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
});
it('normalizes proxy settings for download dispatch', async () => {
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
+89 -6
View File
@@ -348,6 +348,8 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -612,10 +614,30 @@ export const hasStaleTemporaryMediaEstimate = (
return hasImpossibleNumericEstimate || hasImpossibleVisibleEstimate;
};
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem =>
hasStaleTemporaryMediaEstimate(download)
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => {
const rawMaxPeers = download.torrentMaxPeers as unknown;
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
Number.isInteger(rawMaxPeers) &&
rawMaxPeers >= 0 &&
rawMaxPeers <= 1000
? rawMaxPeers
: undefined;
const rawPeerSpeedLimit = download.torrentPeerSpeedLimit as unknown;
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
: undefined;
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit
? {
...download,
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit
}
: download;
return hasStaleTemporaryMediaEstimate(normalizedOptions)
? {
...normalizedOptions,
// The old lifecycle could persist yt-dlp's temporary HLS estimate as
// both the numeric denominator and the visible size. Neither value is
// recoverable after the fact, so remove the false claim on startup.
@@ -623,7 +645,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
totalBytes: undefined,
totalIsEstimate: undefined
}
: download;
: normalizedOptions;
};
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -801,6 +824,11 @@ interface DownloadState {
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
setTorrentUploadLimit: (id: string, limit: string | null) => Promise<void>;
setTorrentPeerOptions: (
id: string,
maxPeers: string | null,
peerSpeedLimit: string | null
) => Promise<void>;
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
addQueue: (name: string) => boolean;
renameQueue: (id: string, name: string) => boolean;
@@ -1920,6 +1948,50 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
true,
preemptDispatch
),
setTorrentPeerOptions: (id, maxPeers, peerSpeedLimit) => runDownloadLifecycleOperation(
id,
'torrent-peer-options',
async () => {
await waitForPendingStartupResume();
const item = get().downloads.find(download => download.id === id);
if (!item) throw new Error('Download no longer exists.');
if (!item.isTorrent) {
throw new Error('Live peer control is available only for Torrent downloads.');
}
if (!['downloading', 'seeding', 'retrying'].includes(item.status)) {
throw new Error('Live peer control requires an active Torrent.');
}
const trimmedMaxPeers = maxPeers?.trim() || '';
const parsedMaxPeers = trimmedMaxPeers ? Number(trimmedMaxPeers) : null;
if (
parsedMaxPeers !== null
&& (!Number.isInteger(parsedMaxPeers) || parsedMaxPeers < 0 || parsedMaxPeers > 1000)
) {
throw new Error('Torrent maximum peers must be an integer between 0 and 1000.');
}
const normalizedPeerSpeedLimit = peerSpeedLimit?.trim()
? normalizeSpeedLimitForBackend(peerSpeedLimit)
: null;
if (peerSpeedLimit?.trim() && normalizedPeerSpeedLimit === null) {
throw new Error('Enter a valid Torrent peer speed limit.');
}
await invoke('set_torrent_peer_options', {
id,
max_peers: parsedMaxPeers,
peer_speed_limit: normalizedPeerSpeedLimit
});
if (get().downloads.some(download => download.id === id)) {
get().updateDownload(id, {
torrentMaxPeers: parsedMaxPeers === null ? undefined : parsedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit ?? undefined
});
}
},
true,
preemptDispatch
),
setQueueConcurrency: (id, maxConcurrent) => {
const operation = queueConfigurationQueue.then(async () => {
if (
@@ -2088,6 +2160,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
@@ -2212,9 +2286,18 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const normalizedQueueState = normalizePersistedQueueState(persistedQueues);
const queues = normalizedQueueState.queues;
const knownQueueIds = new Set(queues.map(queue => queue.id));
const downloads = (await invoke('db_get_all_downloads')).map(
value => JSON.parse(value) as DownloadItem
).map(download => {
const downloads = (await invoke('db_get_all_downloads')).flatMap(value => {
try {
const parsed: unknown = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('persisted download is not an object');
}
return [parsed as DownloadItem];
} catch {
console.warn('Skipping malformed persisted download record during startup');
return [];
}
}).map(download => {
const persistedQueueId = download.queueId || MAIN_QUEUE_ID;
const queueId = normalizedQueueState.queueIdRemap.get(persistedQueueId)
|| (knownQueueIds.has(persistedQueueId) ? persistedQueueId : MAIN_QUEUE_ID);
+2
View File
@@ -62,6 +62,8 @@ export interface AddDownloadDraftRow {
torrentSeedTime?: number;
torrentSeedRatio?: number;
torrentUploadLimit?: string;
torrentMaxPeers?: number;
torrentPeerSpeedLimit?: string;
}
/**