feat(torrents): harden lifecycle and web-seed management

- Enforce generation-safe seed admission and budget tracking.
- Make web-seed RPC, persistence, rollback, and startup attachment lifecycle-safe.
- Keep Torrent progress, DHT, seed-capacity, and web-seed validation covered.
- Ignore local TORRENT_FEATURES.md roadmap notes.
This commit is contained in:
NimBold
2026-08-03 16:56:01 +03:30
parent 79c0e48c43
commit c4d3a2be51
31 changed files with 3398 additions and 258 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
import type { TorrentWebSeed } from "./TorrentWebSeed";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: 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, torrentSeedRemaining?: number, torrentWebSeeds?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: 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 DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, };
export type DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, torrentSeedRemaining?: number, };
+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 DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "paused" | "completed" | "failed" | "queued" | "retrying";
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying";
+2 -1
View File
@@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TorrentWebSeed } from "./TorrentWebSeed";
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: 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_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, lifecycle_generation?: string, };
+1 -1
View File
@@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
import type { WindowControlStyle } from "./WindowControlStyle";
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentFileProgress = { index: number, relativePath: string, length: number, completedLength: number, selected: boolean, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TorrentFileProgress } from "./TorrentFileProgress";
export type TorrentFileProgressSnapshot = { files: Array<TorrentFileProgress>, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentPieceProgressSnapshot = { pieceLength: number, numPieces: number, completedPieces: number, buckets: Array<number>, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentWebSeed = { fileIndex: number, uri: string, };
+399
View File
@@ -4,6 +4,9 @@ import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { useShallow } from 'zustand/react/shallow';
import { useSettingsStore } from '../store/useSettingsStore';
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
import type { TorrentPieceProgressSnapshot } from '../bindings/TorrentPieceProgressSnapshot';
import type { TorrentWebSeed } from '../bindings/TorrentWebSeed';
import { invokeCommand as invoke } from '../ipc';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
@@ -42,6 +45,9 @@ const formatLastTry = (
const isPeerDiagnosticsStatus = (status: string): boolean =>
['downloading', 'seeding', 'retrying'].includes(status);
const isTorrentFileProgressStatus = (status: string): boolean =>
['downloading', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
const formatPeerSpeed = (bytesPerSecond: number): string =>
`${formatDownloadBytes(bytesPerSecond)}/s`;
@@ -99,9 +105,18 @@ export const PropertiesModal = () => {
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
const [torrentFileProgress, setTorrentFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
const [torrentFileProgressError, setTorrentFileProgressError] = useState(false);
const [isTorrentFileProgressPending, setIsTorrentFileProgressPending] = useState(false);
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false);
const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = useState(false);
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
const [torrentWebSeedsText, setTorrentWebSeedsText] = useState('');
const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false);
const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false);
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
const [username, setUsername] = useState('');
@@ -119,6 +134,9 @@ export const PropertiesModal = () => {
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
const actionRequestRef = useRef(0);
const peerDiagnosticsRequestRef = useRef(0);
const torrentFileProgressRequestRef = useRef(0);
const torrentPieceProgressRequestRef = useRef(0);
const torrentWebSeedsRequestRef = useRef(0);
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
useEffect(() => {
@@ -132,6 +150,17 @@ export const PropertiesModal = () => {
setTorrentPeerDiagnostics(null);
setTorrentPeerDiagnosticsError(false);
setIsTorrentPeerDiagnosticsPending(false);
torrentFileProgressRequestRef.current += 1;
setTorrentFileProgress(null);
setTorrentFileProgressError(false);
setIsTorrentFileProgressPending(false);
torrentPieceProgressRequestRef.current += 1;
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
setIsTorrentPieceProgressPending(false);
torrentWebSeedsRequestRef.current += 1;
setTorrentWebSeedsError(false);
setIsTorrentWebSeedsPending(false);
}, [selectedPropertiesDownloadId]);
useEffect(() => {
@@ -205,6 +234,9 @@ export const PropertiesModal = () => {
setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval));
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || '');
setTorrentWebSeedsText((activeItem.torrentWebSeeds || [])
.map(seed => `${seed.fileIndex}|${seed.uri}`)
.join('\n'));
setErrorMessage('');
} else {
setSelectedPropertiesDownloadId(null);
@@ -229,6 +261,120 @@ export const PropertiesModal = () => {
setIsTorrentPeerDiagnosticsPending(false);
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status]);
useEffect(() => {
torrentFileProgressRequestRef.current += 1;
setTorrentFileProgress(null);
setTorrentFileProgressError(false);
setIsTorrentFileProgressPending(false);
if (
!selectedPropertiesDownloadId
|| !item?.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = torrentFileProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentFileProgressPending(true);
void invoke('get_torrent_file_progress', { id: propertiesDownloadId })
.then(snapshot => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentFileProgress(snapshot);
}
})
.catch(() => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentFileProgressError(true);
}
})
.finally(() => {
if (requestId === torrentFileProgressRequestRef.current) {
setIsTorrentFileProgressPending(false);
}
});
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]);
useEffect(() => {
torrentWebSeedsRequestRef.current += 1;
setTorrentWebSeedsError(false);
setIsTorrentWebSeedsPending(false);
if (!selectedPropertiesDownloadId || !item?.isTorrent || !isTorrentFileProgressStatus(item.status)) return;
const requestId = torrentWebSeedsRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentWebSeedsPending(true);
void invoke('get_torrent_web_seeds', { id: propertiesDownloadId })
.then(seeds => {
if (
requestId === torrentWebSeedsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentWebSeedsText(seeds.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: seeds });
}
})
.catch(() => {
if (requestId === torrentWebSeedsRequestRef.current) setTorrentWebSeedsError(true);
})
.finally(() => {
if (requestId === torrentWebSeedsRequestRef.current) setIsTorrentWebSeedsPending(false);
});
}, [item?.id, item?.isTorrent, item?.status, selectedPropertiesDownloadId]);
useEffect(() => {
torrentPieceProgressRequestRef.current += 1;
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
setIsTorrentPieceProgressPending(false);
if (
!selectedPropertiesDownloadId
|| !item?.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = torrentPieceProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentPieceProgressPending(true);
void invoke('get_torrent_piece_progress', { id: propertiesDownloadId })
.then(snapshot => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentPieceProgress(snapshot);
}
})
.catch(() => {
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentPieceProgressError(true);
}
})
.finally(() => {
if (requestId === torrentPieceProgressRequestRef.current) {
setIsTorrentPieceProgressPending(false);
}
});
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status, selectedPropertiesDownloadId]);
useEffect(() => {
setLiveTorrentMaxPeersValue(
item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers)
@@ -321,6 +467,108 @@ export const PropertiesModal = () => {
}
};
const handleRefreshTorrentFileProgress = async () => {
if (
isTorrentFileProgressPending
|| !item.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = ++torrentFileProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentFileProgressPending(true);
setTorrentFileProgressError(false);
try {
const snapshot = await invoke('get_torrent_file_progress', { id: propertiesDownloadId });
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentFileProgress(snapshot);
}
} catch {
if (
requestId === torrentFileProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentFileProgressError(true);
setTorrentFileProgress(null);
}
} finally {
if (requestId === torrentFileProgressRequestRef.current) {
setIsTorrentFileProgressPending(false);
}
}
};
const handleRefreshTorrentPieceProgress = async () => {
if (
isTorrentPieceProgressPending
|| !item.isTorrent
|| !isTorrentFileProgressStatus(item.status)
) return;
const requestId = ++torrentPieceProgressRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentPieceProgressPending(true);
setTorrentPieceProgressError(false);
try {
const snapshot = await invoke('get_torrent_piece_progress', { id: propertiesDownloadId });
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentFileProgressStatus(currentItem.status)
) {
setTorrentPieceProgress(snapshot);
}
} catch {
if (
requestId === torrentPieceProgressRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentPieceProgressError(true);
setTorrentPieceProgress(null);
}
} finally {
if (requestId === torrentPieceProgressRequestRef.current) {
setIsTorrentPieceProgressPending(false);
}
}
};
const handleTorrentWebSeedsSave = async () => {
if (!item?.isTorrent || isTorrentWebSeedsPending) return;
const seeds: TorrentWebSeed[] = [];
for (const line of torrentWebSeedsText.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf('|');
const fileIndex = Number(separator >= 0 ? trimmed.slice(0, separator).trim() : '');
const uri = separator >= 0 ? trimmed.slice(separator + 1).trim() : '';
if (!Number.isInteger(fileIndex) || fileIndex < 0 || !uri) {
setTorrentWebSeedsError(true);
return;
}
seeds.push({ fileIndex, uri });
}
setIsTorrentWebSeedsPending(true);
setTorrentWebSeedsError(false);
try {
const normalized = await invoke('set_torrent_web_seeds', { id: item.id, seeds });
setTorrentWebSeedsText(normalized.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized });
} catch {
setTorrentWebSeedsError(true);
} finally {
setIsTorrentWebSeedsPending(false);
}
};
const handleSave = async () => {
if (!url.trim()) {
setErrorMessage(t($ => $.properties.enterValidUrl));
@@ -857,6 +1105,127 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentPieceProgress)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentPieceProgress()}
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentPieceProgressPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentPieceProgressPending
? t($ => $.properties.torrentPieceProgressLoading)
: t($ => $.properties.torrentPieceProgressRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPieceProgressHint)}
</p>
{!isTorrentFileProgressStatus(item.status) && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPieceProgressUnavailable)}
</p>
)}
{torrentPieceProgressError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentPieceProgressFailed)}
</p>
)}
{torrentPieceProgress && (
<>
<div className="text-[11px] text-text-secondary">
{t($ => $.properties.torrentPieceProgressSummary, {
completed: torrentPieceProgress.completedPieces,
total: torrentPieceProgress.numPieces,
size: formatDownloadBytes(torrentPieceProgress.pieceLength),
})}
</div>
<div
className="grid gap-0.5 rounded border border-border-modal/60 bg-bg-input p-1"
style={{ gridTemplateColumns: `repeat(${Math.min(16, Math.max(1, torrentPieceProgress.buckets.length))}, minmax(0, 1fr))` }}
role="img"
aria-label={t($ => $.properties.torrentPieceProgressMap)}
>
{torrentPieceProgress.buckets.map((percentage, index) => (
<span
key={index}
className="aspect-square min-w-1 rounded-sm bg-blue-500 motion-safe:transition-opacity motion-reduce:transition-none"
style={{ opacity: Math.max(0.15, percentage / 100) }}
title={`${percentage}%`}
/>
))}
</div>
</>
)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentFileProgress)}
</div>
<button
type="button"
onClick={() => void handleRefreshTorrentFileProgress()}
disabled={!isTorrentFileProgressStatus(item.status) || isTorrentFileProgressPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentFileProgressPending
? t($ => $.properties.torrentFileProgressLoading)
: t($ => $.properties.torrentFileProgressRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileProgressHint)}
</p>
{!isTorrentFileProgressStatus(item.status) && (
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileProgressUnavailable)}
</p>
)}
{torrentFileProgressError && (
<p className="text-[11px] text-red-400">
{t($ => $.properties.torrentFileProgressFailed)}
</p>
)}
{torrentFileProgress && (
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
<table className="w-full text-[10px]">
<thead className="sticky top-0 bg-bg-input text-text-muted">
<tr>
<th className="px-2 py-1 text-start">#</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressPath)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressCompleted)}</th>
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentFileProgressSelected)}</th>
</tr>
</thead>
<tbody>
{torrentFileProgress.files.map(file => {
const percentage = file.length === 0
? 100
: Math.round((file.completedLength / file.length) * 100);
return (
<tr key={file.index} className="border-t border-border-modal/40 text-text-primary">
<td className="px-2 py-1 font-mono">{file.index}</td>
<td className="px-2 py-1 max-w-[220px] truncate" title={file.relativePath} dir="auto">{file.relativePath}</td>
<td className="px-2 py-1 font-mono whitespace-nowrap">
{formatDownloadBytes(file.completedLength)} / {formatDownloadBytes(file.length)} ({percentage}%)
</td>
<td className="px-2 py-1">
{file.selected
? t($ => $.properties.torrentFileProgressSelected)
: t($ => $.properties.torrentFileProgressUnselected)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
@@ -1313,6 +1682,36 @@ export const PropertiesModal = () => {
</div>
</section>
{item.isTorrent && (
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{t($ => $.properties.torrentWebSeeds)}
</h3>
<p className="text-xs text-text-muted mb-2">{t($ => $.properties.torrentWebSeedsHint)}</p>
<textarea
value={torrentWebSeedsText}
onChange={event => setTorrentWebSeedsText(event.target.value)}
placeholder={t($ => $.properties.torrentWebSeedsPlaceholder)}
disabled={isTorrentWebSeedsPending}
aria-label={t($ => $.properties.torrentWebSeeds)}
className="w-full h-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"
/>
<div className="flex items-center justify-between mt-2 gap-2">
<span className="text-xs text-red-500">
{torrentWebSeedsError ? t($ => $.properties.torrentWebSeedsFailed) : ''}
</span>
<button
type="button"
onClick={() => void handleTorrentWebSeedsSave()}
disabled={isTorrentWebSeedsPending}
className="app-button px-3 text-xs"
>
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
</button>
</div>
</section>
)}
{/* Advanced Transfer Section */}
<section>
<button
+89
View File
@@ -35,9 +35,17 @@ import { usePlatformInfo } from '../utils/platform';
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
import { normalizeCustomProxy } from '../store/useDownloadStore';
import {
DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
MAX_TORRENT_DHT_MESSAGE_TIMEOUT,
MIN_TORRENT_DHT_MESSAGE_TIMEOUT,
DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
MAX_TORRENT_MAX_CONCURRENT_SEEDS,
MIN_TORRENT_MAX_CONCURRENT_SEEDS,
MAX_TORRENT_MAX_OPEN_FILES,
MIN_TORRENT_MAX_OPEN_FILES,
normalizeSpeedLimitForBackend,
normalizeTorrentDhtMessageTimeout,
normalizeTorrentMaxConcurrentSeeds,
normalizeTorrentMaxOpenFiles
} from '../utils/downloads';
import { useTranslation } from 'react-i18next';
@@ -327,6 +335,12 @@ const engineRunId = useRef(0);
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
() => String(settings.torrentMaxOpenFiles)
);
const [torrentDhtMessageTimeoutInput, setTorrentDhtMessageTimeoutInput] = useState(
() => String(settings.torrentDhtMessageTimeout)
);
const [torrentMaxConcurrentSeedsInput, setTorrentMaxConcurrentSeedsInput] = useState(
() => String(settings.torrentMaxConcurrentSeeds)
);
const [torrentOverallUploadLimitInput, setTorrentOverallUploadLimitInput] = useState(
() => settings.torrentOverallUploadLimit
);
@@ -349,6 +363,14 @@ const engineRunId = useRef(0);
setTorrentMaxOpenFilesInput(String(settings.torrentMaxOpenFiles));
}, [settings.torrentMaxOpenFiles]);
useEffect(() => {
setTorrentDhtMessageTimeoutInput(String(settings.torrentDhtMessageTimeout));
}, [settings.torrentDhtMessageTimeout]);
useEffect(() => {
setTorrentMaxConcurrentSeedsInput(String(settings.torrentMaxConcurrentSeeds));
}, [settings.torrentMaxConcurrentSeeds]);
useEffect(() => {
setTorrentOverallUploadLimitInput(settings.torrentOverallUploadLimit);
}, [settings.torrentOverallUploadLimit]);
@@ -384,6 +406,20 @@ const engineRunId = useRef(0);
});
});
};
const commitTorrentDhtMessageTimeout = (raw: string) => {
const next = normalizeTorrentDhtMessageTimeout(raw)
?? settings.torrentDhtMessageTimeout
?? DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT;
setTorrentDhtMessageTimeoutInput(String(next));
settings.setTorrentDhtMessageTimeout(next);
};
const commitTorrentMaxConcurrentSeeds = (raw: string) => {
const next = normalizeTorrentMaxConcurrentSeeds(raw)
?? settings.torrentMaxConcurrentSeeds
?? DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS;
setTorrentMaxConcurrentSeedsInput(String(next));
settings.setTorrentMaxConcurrentSeeds(next);
};
const commitTorrentOverallUploadLimit = (raw: string) => {
const trimmed = raw.trim();
const normalized = trimmed ? (normalizeSpeedLimitForBackend(trimmed) ?? '') : '';
@@ -1406,6 +1442,59 @@ runEngineChecks(false);
aria-label={t($ => $.settings.network.torrentPeerAgent)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentSeparateSeedSlots)}</span>
<small>{t($ => $.settings.network.torrentSeparateSeedSlotsDescription)}</small>
</div>
<input
type="checkbox"
checked={settings.torrentSeparateSeedSlots}
onChange={(event) => settings.setTorrentSeparateSeedSlots(event.target.checked)}
aria-label={t($ => $.settings.network.torrentSeparateSeedSlots)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentMaxConcurrentSeeds)}</span>
<small>{t($ => $.settings.network.torrentMaxConcurrentSeedsDescription)}</small>
</div>
<input
type="number"
min={MIN_TORRENT_MAX_CONCURRENT_SEEDS}
max={MAX_TORRENT_MAX_CONCURRENT_SEEDS}
step={1}
value={torrentMaxConcurrentSeedsInput}
onChange={(event) => setTorrentMaxConcurrentSeedsInput(event.target.value)}
onBlur={(event) => commitTorrentMaxConcurrentSeeds(event.target.value)}
className="app-control settings-port-input text-center"
aria-label={t($ => $.settings.network.torrentMaxConcurrentSeeds)}
/>
</div>
<p className="settings-group-footer">
{t($ => $.settings.network.torrentNetworkRestartNote)}
</p>
</div>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentAdvanced)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtMessageTimeout)}</span>
<small>{t($ => $.settings.network.torrentDhtMessageTimeoutDescription)}</small>
</div>
<input
type="number"
min={MIN_TORRENT_DHT_MESSAGE_TIMEOUT}
max={MAX_TORRENT_DHT_MESSAGE_TIMEOUT}
step={1}
value={torrentDhtMessageTimeoutInput}
onChange={(event) => setTorrentDhtMessageTimeoutInput(event.target.value)}
onBlur={(event) => commitTorrentDhtMessageTimeout(event.target.value)}
className="app-control settings-port-input text-center"
aria-label={t($ => $.settings.network.torrentDhtMessageTimeout)}
/>
</div>
<p className="settings-group-footer">
{t($ => $.settings.network.torrentNetworkRestartNote)}
</p>
+32
View File
@@ -90,6 +90,7 @@ const common = {
downloading: 'Downloading',
processing: 'Processing',
seeding: 'Seeding',
waitingToSeed: 'Waiting to seed',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
@@ -260,6 +261,30 @@ const common = {
torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active.',
torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.',
torrentPeerDiagnosticsHint: 'Speeds and connection flags only are shown; peer IPs, ports, IDs, and bitfields are not retained.',
torrentFileProgress: 'Torrent file progress',
torrentFileProgressRefresh: 'Refresh',
torrentFileProgressLoading: 'Loading file progress…',
torrentFileProgressUnavailable: 'File progress is available while this Torrent is active or paused.',
torrentFileProgressFailed: 'Could not read Torrent file progress.',
torrentFileProgressHint: 'Validated relative paths and completed bytes are shown; daemon paths and URIs are not exposed.',
torrentFileProgressPath: 'File',
torrentFileProgressCompleted: 'Completed',
torrentFileProgressSelected: 'Selected',
torrentFileProgressUnselected: 'Not selected',
torrentPieceProgress: 'Torrent piece progress',
torrentPieceProgressRefresh: 'Refresh',
torrentPieceProgressLoading: 'Loading piece progress…',
torrentPieceProgressUnavailable: 'Piece progress is available while this Torrent is active or paused.',
torrentPieceProgressFailed: 'Could not read Torrent piece progress.',
torrentPieceProgressHint: 'Each cell summarizes adjacent pieces. Raw bitfields are never exposed.',
torrentPieceProgressSummary: '{{completed}} of {{total}} pieces complete · {{size}} each',
torrentPieceProgressMap: 'Torrent piece completion map',
torrentWebSeeds: 'Torrent web seeds',
torrentWebSeedsHint: 'One line per seed in the form file index|HTTP(S) URI. Firelink expands multi-file paths natively.',
torrentWebSeedsPlaceholder: '0|https://mirror.example/torrent/',
torrentWebSeedsApply: 'Apply web seeds',
torrentWebSeedsLoading: 'Applying…',
torrentWebSeedsFailed: 'Could not validate or apply the Torrent web seeds.',
torrentPeerCount: '{{total}} peers · {{seeders}} seeders',
torrentPeerDownload: 'Download',
torrentPeerUpload: 'Upload',
@@ -800,6 +825,13 @@ const common = {
torrentLpdDescription: 'Discover compatible peers on the local network. This increases local network visibility.',
torrentPeerDiscoveryRestartNote: 'These options are global to Aria2 and take effect after Firelink restarts. Aria2 still disables peer discovery for private torrents.',
torrentNetwork: 'BitTorrent network binding',
torrentAdvanced: 'Advanced Torrent network',
torrentDhtMessageTimeout: 'DHT message timeout',
torrentDhtMessageTimeoutDescription: 'Whole seconds for DHT and UDP message waits. This does not affect remote .torrent HTTP fetches or HTTP tracker requests. Applies after Firelink restarts.',
torrentSeparateSeedSlots: 'Separate seeding capacity',
torrentSeparateSeedSlotsDescription: 'Keep seeding outside the download limit and cap it with a Firelink-managed pool.',
torrentMaxConcurrentSeeds: 'Maximum concurrent seeds',
torrentMaxConcurrentSeedsDescription: 'Maximum number of Torrents Firelink lets seed at once when separate capacity is enabled.',
torrentListenPort: 'TCP peer ports',
torrentListenPortDescription: 'TCP ports for incoming BitTorrent peer connections. Leave blank for Aria2s default range.',
torrentDhtListenPort: 'UDP/DHT ports',
+32
View File
@@ -90,6 +90,7 @@ const fa = {
downloading: 'در حال دانلود',
processing: 'در حال پردازش',
seeding: 'در حال اشتراک‌گذاری',
waitingToSeed: 'در انتظار اشتراک‌گذاری',
paused: 'متوقف‌شده',
completed: 'تکمیل‌شده',
failed: 'ناموفق',
@@ -260,6 +261,30 @@ const fa = {
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال بودن تورنت در دسترس است.',
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده می‌شود؛ IP، پورت، شناسه و بیت‌فیلد همتاها ذخیره نمی‌شود.',
torrentFileProgress: 'پیشرفت فایل‌های تورنت',
torrentFileProgressRefresh: 'تازه‌سازی',
torrentFileProgressLoading: 'در حال دریافت پیشرفت فایل‌ها…',
torrentFileProgressUnavailable: 'پیشرفت فایل هنگام فعال یا متوقف‌بودن تورنت در دسترس است.',
torrentFileProgressFailed: 'خواندن پیشرفت فایل‌های تورنت ممکن نیست.',
torrentFileProgressHint: 'مسیرهای نسبی معتبر و حجم تکمیل‌شده نمایش داده می‌شود؛ مسیرهای داخلی و URIهای daemon نمایش داده نمی‌شوند.',
torrentFileProgressPath: 'فایل',
torrentFileProgressCompleted: 'تکمیل‌شده',
torrentFileProgressSelected: 'انتخاب‌شده',
torrentFileProgressUnselected: 'انتخاب‌نشده',
torrentPieceProgress: 'پیشرفت قطعه‌های تورنت',
torrentPieceProgressRefresh: 'تازه‌سازی',
torrentPieceProgressLoading: 'در حال دریافت پیشرفت قطعه‌ها…',
torrentPieceProgressUnavailable: 'پیشرفت قطعه‌ها هنگام فعال یا متوقف‌بودن تورنت در دسترس است.',
torrentPieceProgressFailed: 'خواندن پیشرفت قطعه‌های تورنت ممکن نیست.',
torrentPieceProgressHint: 'هر خانه خلاصه‌ای از قطعه‌های مجاور است؛ bitfield خام نمایش داده نمی‌شود.',
torrentPieceProgressSummary: '{{completed}} از {{total}} قطعه کامل شده · هرکدام {{size}}',
torrentPieceProgressMap: 'نقشه تکمیل قطعه‌های تورنت',
torrentWebSeeds: 'وب‌سیدهای تورنت',
torrentWebSeedsHint: 'هر خط به‌شکل شماره فایل|نشانی HTTP(S). مسیر فایل‌های چندفایلی را Firelink در بخش native می‌سازد.',
torrentWebSeedsPlaceholder: '۰|https://mirror.example/torrent/',
torrentWebSeedsApply: 'اعمال وب‌سیدها',
torrentWebSeedsLoading: 'در حال اعمال…',
torrentWebSeedsFailed: 'اعتبارسنجی یا اعمال وب‌سیدهای تورنت انجام نشد.',
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
torrentPeerDownload: 'دریافت',
torrentPeerUpload: 'آپلود',
@@ -800,6 +825,13 @@ const fa = {
torrentLpdDescription: 'همتاهای سازگار در شبکه محلی را پیدا می‌کند و دیده‌شدن ترافیک در شبکه محلی را افزایش می‌دهد.',
torrentPeerDiscoveryRestartNote: 'این گزینه‌ها سراسری و مربوط به Aria2 هستند و پس از راه‌اندازی مجدد Firelink اعمال می‌شوند. Aria2 همچنان کشف همتا را برای تورنت‌های خصوصی خاموش می‌کند.',
torrentNetwork: 'اتصال شبکه بیت‌تورنت',
torrentAdvanced: 'شبکه پیشرفته تورنت',
torrentDhtMessageTimeout: 'مهلت پیام DHT',
torrentDhtMessageTimeoutDescription: 'مدت انتظار پیام‌های DHT و UDP برحسب ثانیه. روی دریافت HTTP فایل .torrent یا درخواست‌های tracker از نوع HTTP اثر ندارد و پس از راه‌اندازی دوباره اعمال می‌شود.',
torrentSeparateSeedSlots: 'ظرفیت جداگانهٔ سید کردن',
torrentSeparateSeedSlotsDescription: 'سید کردن را از سقف دانلود جدا می‌کند و آن را با ظرفیت مدیریت‌شدهٔ Firelink محدود می‌کند.',
torrentMaxConcurrentSeeds: 'حداکثر سید هم‌زمان',
torrentMaxConcurrentSeedsDescription: 'وقتی ظرفیت جداگانه فعال است، حداکثر تعداد تورنت‌هایی که Firelink هم‌زمان سید می‌کند.',
torrentListenPort: 'پورت‌های همتای TCP',
torrentListenPortDescription: 'پورت‌های TCP برای اتصال‌های ورودی همتاهای بیت‌تورنت. برای محدوده پیش‌فرض Aria2 خالی بگذارید.',
torrentDhtListenPort: 'پورت‌های UDP/DHT',
+32
View File
@@ -90,6 +90,7 @@ const he = {
downloading: 'מוריד',
processing: 'מעבד',
seeding: 'משתף',
waitingToSeed: 'ממתין לשיתוף',
paused: 'מושהה',
completed: 'הושלם',
failed: 'נכשל',
@@ -260,6 +261,30 @@ const he = {
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל.',
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.',
torrentFileProgress: 'התקדמות קובצי הטורנט',
torrentFileProgressRefresh: 'רענון',
torrentFileProgressLoading: 'טוען את התקדמות הקבצים…',
torrentFileProgressUnavailable: 'התקדמות הקבצים זמינה כשהטורנט פעיל או מושהה.',
torrentFileProgressFailed: 'לא ניתן לקרוא את התקדמות קובצי הטורנט.',
torrentFileProgressHint: 'מוצגים נתיבים יחסיים מאומתים ובייטים שהושלמו; נתיבי daemon וכתובות URI אינם נחשפים.',
torrentFileProgressPath: 'קובץ',
torrentFileProgressCompleted: 'הושלם',
torrentFileProgressSelected: 'נבחר',
torrentFileProgressUnselected: 'לא נבחר',
torrentPieceProgress: 'התקדמות חלקי הטורנט',
torrentPieceProgressRefresh: 'רענון',
torrentPieceProgressLoading: 'טוען את התקדמות החלקים…',
torrentPieceProgressUnavailable: 'התקדמות החלקים זמינה כשהטורנט פעיל או מושהה.',
torrentPieceProgressFailed: 'לא ניתן לקרוא את התקדמות חלקי הטורנט.',
torrentPieceProgressHint: 'כל תא מסכם חלקים סמוכים; מפת הסיביות הגולמית אינה נחשפת.',
torrentPieceProgressSummary: '{{completed}} מתוך {{total}} חלקים הושלמו · {{size}} לכל חלק',
torrentPieceProgressMap: 'מפת השלמת חלקי הטורנט',
torrentWebSeeds: 'זריעות Web של טורנט',
torrentWebSeedsHint: 'שורה אחת לכל זרע בפורמט file index|כתובת HTTP(S). Firelink מרחיב נתיבי קבצים מרובי-קבצים באופן מקורי.',
torrentWebSeedsPlaceholder: '0|https://מראה.example/torrent/',
torrentWebSeedsApply: 'החל זריעות Web',
torrentWebSeedsLoading: 'מיישם…',
torrentWebSeedsFailed: 'לא ניתן לאמת או להחיל את זריעות ה-Web של הטורנט.',
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
torrentPeerDownload: 'הורדה',
torrentPeerUpload: 'העלאה',
@@ -800,6 +825,13 @@ const he = {
torrentLpdDescription: 'מאתר עמיתים תואמים ברשת המקומית ומגדיל את החשיפה המקומית של התעבורה.',
torrentPeerDiscoveryRestartNote: 'האפשרויות האלה הן כלליות ל-Aria2 ונכנסות לתוקף לאחר הפעלה מחדש של Firelink. Aria2 עדיין משבית גילוי עמיתים בטורנטים פרטיים.',
torrentNetwork: 'קישור רשת BitTorrent',
torrentAdvanced: 'רשת Torrent מתקדמת',
torrentDhtMessageTimeout: 'זמן קצוב להודעות DHT',
torrentDhtMessageTimeoutDescription: 'משך ההמתנה להודעות DHT ו-UDP בשניות. אינו משפיע על הורדת קובצי ‎.torrent‎ ב-HTTP או על בקשות HTTP למעקבים. חל לאחר הפעלה מחדש של Firelink.',
torrentSeparateSeedSlots: 'קיבולת זריעה נפרדת',
torrentSeparateSeedSlotsDescription: 'הפרד זריעה ממגבלת ההורדות והגבל אותה למאגר בניהול Firelink.',
torrentMaxConcurrentSeeds: 'מקסימום זריעות במקביל',
torrentMaxConcurrentSeedsDescription: 'מספר הטורנטים המרבי ש-Firelink יזריע בו-זמנית כשהקיבולת הנפרדת פעילה.',
torrentListenPort: 'יציאות עמיתי TCP',
torrentListenPortDescription: 'יציאות TCP לחיבורי עמיתים נכנסים של BitTorrent. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.',
torrentDhtListenPort: 'יציאות UDP/DHT',
+32
View File
@@ -90,6 +90,7 @@ const ru = {
downloading: 'Загрузка',
processing: 'Обработка',
seeding: 'Раздача',
waitingToSeed: 'Ожидание раздачи',
paused: 'Приостановлено',
completed: 'Завершено',
failed: 'Ошибка',
@@ -260,6 +261,30 @@ const ru = {
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен.',
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.',
torrentFileProgress: 'Прогресс файлов торрента',
torrentFileProgressRefresh: 'Обновить',
torrentFileProgressLoading: 'Загрузка прогресса файлов…',
torrentFileProgressUnavailable: 'Прогресс файлов доступен, пока торрент активен или приостановлен.',
torrentFileProgressFailed: 'Не удалось получить прогресс файлов торрента.',
torrentFileProgressHint: 'Показываются проверенные относительные пути и загруженные байты; пути демона и URI не раскрываются.',
torrentFileProgressPath: 'Файл',
torrentFileProgressCompleted: 'Завершено',
torrentFileProgressSelected: 'Выбран',
torrentFileProgressUnselected: 'Не выбран',
torrentPieceProgress: 'Прогресс частей торрента',
torrentPieceProgressRefresh: 'Обновить',
torrentPieceProgressLoading: 'Загрузка прогресса частей…',
torrentPieceProgressUnavailable: 'Прогресс частей доступен, пока торрент активен или приостановлен.',
torrentPieceProgressFailed: 'Не удалось получить прогресс частей торрента.',
torrentPieceProgressHint: 'Каждая ячейка объединяет соседние части; исходная битовая карта не раскрывается.',
torrentPieceProgressSummary: '{{completed}} из {{total}} частей завершено · по {{size}} на часть',
torrentPieceProgressMap: 'Карта завершения частей торрента',
torrentWebSeeds: 'Веб-сиды торрента',
torrentWebSeedsHint: 'Одна строка на сид в формате индекс файла|HTTP(S)-URI. Firelink сам расширяет пути многофайловых торрентов.',
torrentWebSeedsPlaceholder: '0|https://зеркало.example/torrent/',
torrentWebSeedsApply: 'Применить веб-сиды',
torrentWebSeedsLoading: 'Применение…',
torrentWebSeedsFailed: 'Не удалось проверить или применить веб-сиды торрента.',
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
torrentPeerDownload: 'Загрузка',
torrentPeerUpload: 'Отдача',
@@ -800,6 +825,13 @@ const ru = {
torrentLpdDescription: 'Ищет подходящие пиры в локальной сети и увеличивает видимость трафика в ней.',
torrentPeerDiscoveryRestartNote: 'Эти параметры являются глобальными для Aria2 и применяются после перезапуска Firelink. Aria2 по-прежнему отключает обнаружение пиров для приватных торрентов.',
torrentNetwork: 'Сетевые параметры BitTorrent',
torrentAdvanced: 'Расширенные параметры Torrent',
torrentDhtMessageTimeout: 'Тайм-аут сообщений DHT',
torrentDhtMessageTimeoutDescription: 'Время ожидания сообщений DHT и UDP в секундах. Не влияет на загрузку .torrent по HTTP или HTTP-запросы к трекерам. Применяется после перезапуска Firelink.',
torrentSeparateSeedSlots: 'Отдельная ёмкость раздачи',
torrentSeparateSeedSlotsDescription: 'Вынести раздачу за пределы лимита загрузок и ограничить её пулом Firelink.',
torrentMaxConcurrentSeeds: 'Максимум одновременных раздач',
torrentMaxConcurrentSeedsDescription: 'Максимальное число торрентов, которые Firelink раздаёт одновременно при включённой отдельной ёмкости.',
torrentListenPort: 'TCP-порты пиров',
torrentListenPortDescription: 'TCP-порты для входящих соединений BitTorrent. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.',
torrentDhtListenPort: 'Порты UDP/DHT',
+32
View File
@@ -90,6 +90,7 @@ const uk = {
downloading: 'Завантаження',
processing: 'Обробка',
seeding: 'Роздача',
waitingToSeed: 'Очікування роздачі',
paused: 'Призупинено',
completed: 'Завершено',
failed: 'Помилка',
@@ -260,6 +261,30 @@ const uk = {
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний.',
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.',
torrentFileProgress: 'Прогрес файлів торрента',
torrentFileProgressRefresh: 'Оновити',
torrentFileProgressLoading: 'Завантаження прогресу файлів…',
torrentFileProgressUnavailable: 'Прогрес файлів доступний, коли торрент активний або призупинений.',
torrentFileProgressFailed: 'Не вдалося отримати прогрес файлів торрента.',
torrentFileProgressHint: 'Показуються перевірені відносні шляхи та завантажені байти; шляхи демона й URI не розкриваються.',
torrentFileProgressPath: 'Файл',
torrentFileProgressCompleted: 'Завершено',
torrentFileProgressSelected: 'Вибрано',
torrentFileProgressUnselected: 'Не вибрано',
torrentPieceProgress: 'Прогрес частин торрента',
torrentPieceProgressRefresh: 'Оновити',
torrentPieceProgressLoading: 'Завантаження прогресу частин…',
torrentPieceProgressUnavailable: 'Прогрес частин доступний, коли торрент активний або призупинений.',
torrentPieceProgressFailed: 'Не вдалося отримати прогрес частин торрента.',
torrentPieceProgressHint: 'Кожна клітинка узагальнює сусідні частини; необроблена бітова карта не розкривається.',
torrentPieceProgressSummary: '{{completed}} із {{total}} частин завершено · по {{size}} на частину',
torrentPieceProgressMap: 'Карта завершення частин торрента',
torrentWebSeeds: 'Вебсіди торента',
torrentWebSeedsHint: 'Один рядок на сід у форматі індекс файлу|HTTP(S)-URI. Firelink сам розгортає шляхи багатофайлових торентів.',
torrentWebSeedsPlaceholder: '0|https://дзеркало.example/torrent/',
torrentWebSeedsApply: 'Застосувати вебсіди',
torrentWebSeedsLoading: 'Застосування…',
torrentWebSeedsFailed: 'Не вдалося перевірити або застосувати вебсіди торента.',
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
torrentPeerDownload: 'Завантаження',
torrentPeerUpload: 'Віддача',
@@ -800,6 +825,13 @@ const uk = {
torrentLpdDescription: 'Шукає сумісних пірів у локальній мережі та збільшує видимість трафіку в ній.',
torrentPeerDiscoveryRestartNote: 'Ці параметри є глобальними для Aria2 і застосовуються після перезапуску Firelink. Aria2 і надалі вимикає пошук пірів для приватних торрентів.',
torrentNetwork: 'Мережеві параметри BitTorrent',
torrentAdvanced: 'Розширені параметри Torrent',
torrentDhtMessageTimeout: 'Час очікування повідомлень DHT',
torrentDhtMessageTimeoutDescription: 'Час очікування повідомлень DHT і UDP у секундах. Не впливає на завантаження .torrent через HTTP або HTTP-запити до трекерів. Застосовується після перезапуску Firelink.',
torrentSeparateSeedSlots: 'Окрема місткість роздачі',
torrentSeparateSeedSlotsDescription: 'Винести роздачу за межі ліміту завантажень і обмежити її пулом Firelink.',
torrentMaxConcurrentSeeds: 'Максимум одночасних роздач',
torrentMaxConcurrentSeedsDescription: 'Максимальна кількість торентів, які Firelink роздає одночасно за ввімкненої окремої місткості.',
torrentListenPort: 'TCP-порти пірів',
torrentListenPortDescription: 'TCP-порти для вхідних з’єднань BitTorrent. Залиште порожнім, щоб використати типовий діапазон Aria2.',
torrentDhtListenPort: 'Порти UDP/DHT',
+32
View File
@@ -90,6 +90,7 @@ const zhCN = {
downloading: '下载中',
processing: '处理中',
seeding: '做种中',
waitingToSeed: '等待做种',
paused: '已暂停',
completed: '已完成',
failed: '失败',
@@ -260,6 +261,30 @@ const zhCN = {
torrentPeerDiagnosticsUnavailable: 'Torrent 活跃时可查看对等节点诊断。',
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
torrentPeerDiagnosticsHint: '仅显示速度和连接状态;不会保留对等节点 IP、端口、ID 或位域。',
torrentFileProgress: 'Torrent 文件进度',
torrentFileProgressRefresh: '刷新',
torrentFileProgressLoading: '正在加载文件进度…',
torrentFileProgressUnavailable: 'Torrent 活跃或暂停时可查看文件进度。',
torrentFileProgressFailed: '无法读取 Torrent 文件进度。',
torrentFileProgressHint: '仅显示已验证的相对路径和已完成字节数;不会暴露守护进程路径或 URI。',
torrentFileProgressPath: '文件',
torrentFileProgressCompleted: '已完成',
torrentFileProgressSelected: '已选择',
torrentFileProgressUnselected: '未选择',
torrentPieceProgress: 'Torrent 分片进度',
torrentPieceProgressRefresh: '刷新',
torrentPieceProgressLoading: '正在加载分片进度…',
torrentPieceProgressUnavailable: 'Torrent 活跃或暂停时可查看分片进度。',
torrentPieceProgressFailed: '无法读取 Torrent 分片进度。',
torrentPieceProgressHint: '每个单元格汇总相邻分片;不会暴露原始位图。',
torrentPieceProgressSummary: '{{completed}}/{{total}} 个分片已完成 · 每片 {{size}}',
torrentPieceProgressMap: 'Torrent 分片完成度地图',
torrentWebSeeds: 'Torrent Web 做种',
torrentWebSeedsHint: '每行一个做种,格式为文件索引|HTTP(S) URI。多文件路径由 Firelink 原生展开。',
torrentWebSeedsPlaceholder: '0|https://镜像.example/torrent/',
torrentWebSeedsApply: '应用 Web 做种',
torrentWebSeedsLoading: '正在应用…',
torrentWebSeedsFailed: '无法验证或应用 Torrent Web 做种。',
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
torrentPeerDownload: '下载',
torrentPeerUpload: '上传',
@@ -800,6 +825,13 @@ const zhCN = {
torrentLpdDescription: '在本地网络中发现兼容节点,这会增加本地网络中的流量可见性。',
torrentPeerDiscoveryRestartNote: '这些选项是 Aria2 的全局设置,需要重启 Firelink 后生效。Aria2 仍会对私有 Torrent 禁用节点发现。',
torrentNetwork: 'BitTorrent 网络绑定',
torrentAdvanced: '高级 Torrent 网络设置',
torrentDhtMessageTimeout: 'DHT 消息超时',
torrentDhtMessageTimeoutDescription: 'DHT 和 UDP 消息的等待时间(秒)。不影响通过 HTTP 获取 .torrent 文件,也不影响 HTTP tracker 请求。Firelink 重启后生效。',
torrentSeparateSeedSlots: '独立做种容量',
torrentSeparateSeedSlotsDescription: '将做种从下载上限中分离,并使用 Firelink 管理的容量池限制做种。',
torrentMaxConcurrentSeeds: '最大同时做种数',
torrentMaxConcurrentSeedsDescription: '启用独立容量后,Firelink 同时做种的 Torrent 数量上限。',
torrentListenPort: 'TCP 节点端口',
torrentListenPortDescription: '用于传入 BitTorrent 节点连接的 TCP 端口。留空以使用 Aria2 的默认范围。',
torrentDhtListenPort: 'UDP/DHT 端口',
+7
View File
@@ -20,6 +20,9 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
import type { TorrentMetadata } from './bindings/TorrentMetadata';
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
type CommandMap = {
fetch_metadata: {
@@ -79,6 +82,10 @@ type CommandMap = {
result: void;
};
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
get_torrent_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot };
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
get_torrent_web_seeds: { args: { id: string }; result: TorrentWebSeed[] };
set_torrent_web_seeds: { args: { id: string; seeds: TorrentWebSeed[] }; result: TorrentWebSeed[] };
set_torrent_max_open_files: { args: { max_open_files: number }; result: void };
set_torrent_overall_upload_limit: { args: { limit: string | null }; result: void };
set_global_speed_limit: { args: { limit: string | null }; result: void };
+11 -4
View File
@@ -121,7 +121,8 @@ const startDownloadListeners = async () => {
return;
}
if (status === 'downloading' || status === 'processing' ||
status === 'seeding' || status === 'completed' || status === 'failed') {
status === 'seeding' || status === 'waitingToSeed' ||
status === 'completed' || status === 'failed') {
clearDownloadControlIntent(payload.id, 'resume');
}
if (status === 'paused') {
@@ -146,6 +147,7 @@ const startDownloadListeners = async () => {
}
if (current.status === 'seeding' &&
status !== 'seeding' &&
status !== 'waitingToSeed' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
@@ -153,7 +155,7 @@ const startDownloadListeners = async () => {
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
if (['queued', 'retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
const updates: Partial<DownloadItem> = {
@@ -175,6 +177,11 @@ const startDownloadListeners = async () => {
? { lastTry: new Date().toISOString() }
: {})
};
if (payload.torrentSeedRemaining != null) {
updates.torrentSeedRemaining = payload.torrentSeedRemaining;
} else if (status === 'seeding' || status === 'completed' || status === 'failed') {
updates.torrentSeedRemaining = undefined;
}
if (!payload.error && status !== 'failed' && status !== 'retrying') {
updates.lastError = undefined;
}
@@ -188,7 +195,7 @@ const startDownloadListeners = async () => {
}
mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding') {
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding' || status === 'waitingToSeed') {
useDownloadStore.setState(state => ({
pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
}));
@@ -198,7 +205,7 @@ const startDownloadListeners = async () => {
: { pendingOrder: [...state.pendingOrder, payload.id] });
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying') {
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
+40 -1
View File
@@ -347,6 +347,8 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_info_hash: item.torrentInfoHash || undefined,
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_seed_remaining: item.torrentSeedRemaining,
torrent_web_seeds: item.torrentWebSeeds,
torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
@@ -625,6 +627,12 @@ export const hasStaleTemporaryMediaEstimate = (
};
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => {
const rawSeedRemaining = download.torrentSeedRemaining as unknown;
const normalizedSeedRemaining = typeof rawSeedRemaining === 'number' &&
Number.isFinite(rawSeedRemaining) &&
rawSeedRemaining >= 0
? rawSeedRemaining
: undefined;
const rawMaxPeers = download.torrentMaxPeers as unknown;
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
Number.isInteger(rawMaxPeers) &&
@@ -632,6 +640,17 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawMaxPeers <= 1000
? rawMaxPeers
: undefined;
const rawWebSeeds = download.torrentWebSeeds as unknown;
const normalizedWebSeeds = Array.isArray(rawWebSeeds)
? rawWebSeeds.filter((seed): seed is { fileIndex: number; uri: string } =>
!!seed && typeof seed === 'object' &&
typeof (seed as { fileIndex?: unknown }).fileIndex === 'number' &&
Number.isInteger((seed as { fileIndex: number }).fileIndex) &&
(seed as { fileIndex: number }).fileIndex >= 0 &&
typeof (seed as { uri?: unknown }).uri === 'string' &&
(seed as { uri: string }).uri.length <= 2048
).slice(0, 256)
: undefined;
const rawPeerSpeedLimit = download.torrentPeerSpeedLimit as unknown;
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
@@ -671,7 +690,9 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
: undefined;
const rawEncryptionPolicy = download.torrentEncryptionPolicy as unknown;
const normalizedEncryptionPolicy = normalizeTorrentEncryptionPolicy(rawEncryptionPolicy);
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
rawWebSeeds !== normalizedWebSeeds ||
rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity ||
rawTrackers !== normalizedTrackers ||
@@ -685,6 +706,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawEncryptionPolicy !== normalizedEncryptionPolicy
? {
...download,
torrentSeedRemaining: normalizedSeedRemaining,
torrentWebSeeds: normalizedWebSeeds,
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
torrentCheckIntegrity: normalizedCheckIntegrity,
@@ -2171,6 +2194,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
if (pendingStartupResume) return pendingStartupResume;
const operation = (async () => {
// WaitingToSeed is a paused Aria2 GID owned by the previous process;
// that GID cannot survive an app restart. Reconstruct the row as a
// queued Torrent using its persisted remaining seed budget, then let
// the normal backend admission path assign a fresh lifecycle/GID.
const waitingToSeedIds = get().downloads
.filter(download => download.status === 'waitingToSeed')
.map(download => download.id);
if (waitingToSeedIds.length > 0) {
set(state => ({
downloads: state.downloads.map(download => waitingToSeedIds.includes(download.id)
? { ...download, status: 'queued' }
: download)
}));
}
const active = get().downloads
.filter(d => d.status === 'queued')
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
@@ -2236,6 +2273,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_info_hash: item.torrentInfoHash || undefined,
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_seed_remaining: item.torrentSeedRemaining,
torrent_web_seeds: item.torrentWebSeeds,
torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
+40
View File
@@ -24,7 +24,10 @@ import {
DEFAULT_TORRENT_MAX_OPEN_FILES,
MAX_TORRENT_MAX_OPEN_FILES,
MIN_TORRENT_MAX_OPEN_FILES,
DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
normalizeSpeedLimitForBackend,
normalizeTorrentDhtMessageTimeout,
normalizeTorrentMaxOpenFiles
} from '../utils/downloads';
import i18n from '../i18n';
@@ -248,6 +251,9 @@ export interface SettingsState {
torrentEnablePex: boolean;
torrentEnableLpd: boolean;
torrentMaxOpenFiles: number;
torrentDhtMessageTimeout: number;
torrentSeparateSeedSlots: boolean;
torrentMaxConcurrentSeeds: number;
torrentListenPort: string;
torrentDhtListenPort: string;
torrentExternalIp: string;
@@ -313,6 +319,9 @@ export interface SettingsState {
setTorrentEnablePex: (enabled: boolean) => void;
setTorrentEnableLpd: (enabled: boolean) => void;
setTorrentMaxOpenFiles: (value: number) => Promise<void>;
setTorrentDhtMessageTimeout: (value: number) => void;
setTorrentSeparateSeedSlots: (enabled: boolean) => void;
setTorrentMaxConcurrentSeeds: (value: number) => void;
setTorrentListenPort: (value: string) => void;
setTorrentDhtListenPort: (value: string) => void;
setTorrentExternalIp: (value: string) => void;
@@ -404,6 +413,9 @@ export const useSettingsStore = create<SettingsState>()(
torrentEnablePex: true,
torrentEnableLpd: false,
torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES,
torrentDhtMessageTimeout: DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
torrentSeparateSeedSlots: false,
torrentMaxConcurrentSeeds: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
torrentListenPort: '',
torrentDhtListenPort: '',
torrentExternalIp: '',
@@ -553,6 +565,19 @@ export const useSettingsStore = create<SettingsState>()(
torrentMaxOpenFilesQueue = result.then(() => undefined, () => undefined);
return result;
},
setTorrentDhtMessageTimeout: (value) => {
const normalized = normalizeTorrentDhtMessageTimeout(value);
set({
torrentDhtMessageTimeout: normalized
?? DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
});
},
setTorrentSeparateSeedSlots: (torrentSeparateSeedSlots) => set({ torrentSeparateSeedSlots }),
setTorrentMaxConcurrentSeeds: (value) => set({
torrentMaxConcurrentSeeds: Number.isInteger(value) && value >= 1 && value <= 64
? value
: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
}),
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
@@ -740,6 +765,9 @@ export const useSettingsStore = create<SettingsState>()(
torrentEnablePex: state.torrentEnablePex,
torrentEnableLpd: state.torrentEnableLpd,
torrentMaxOpenFiles: state.torrentMaxOpenFiles,
torrentDhtMessageTimeout: state.torrentDhtMessageTimeout,
torrentSeparateSeedSlots: state.torrentSeparateSeedSlots,
torrentMaxConcurrentSeeds: state.torrentMaxConcurrentSeeds,
torrentListenPort: state.torrentListenPort,
torrentDhtListenPort: state.torrentDhtListenPort,
torrentExternalIp: state.torrentExternalIp,
@@ -800,6 +828,18 @@ export const useSettingsStore = create<SettingsState>()(
torrentEnableLpd: persistedBoolean(persisted.torrentEnableLpd, currentState.torrentEnableLpd),
torrentMaxOpenFiles: normalizeTorrentMaxOpenFiles(persisted.torrentMaxOpenFiles)
?? currentState.torrentMaxOpenFiles,
torrentDhtMessageTimeout: normalizeTorrentDhtMessageTimeout(persisted.torrentDhtMessageTimeout)
?? currentState.torrentDhtMessageTimeout,
torrentSeparateSeedSlots: persistedBoolean(
persisted.torrentSeparateSeedSlots,
currentState.torrentSeparateSeedSlots
),
torrentMaxConcurrentSeeds: typeof persisted.torrentMaxConcurrentSeeds === 'number'
&& Number.isInteger(persisted.torrentMaxConcurrentSeeds)
&& persisted.torrentMaxConcurrentSeeds >= 1
&& persisted.torrentMaxConcurrentSeeds <= 64
? persisted.torrentMaxConcurrentSeeds
: currentState.torrentMaxConcurrentSeeds,
torrentListenPort: typeof persisted.torrentListenPort === 'string'
? persisted.torrentListenPort
: currentState.torrentListenPort,
+3 -1
View File
@@ -4,6 +4,7 @@ const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'ready',
'staged',
'paused',
'waitingToSeed',
'failed',
]);
@@ -12,6 +13,7 @@ const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'queued',
'downloading',
'seeding',
'waitingToSeed',
'processing',
'retrying',
]);
@@ -64,7 +66,7 @@ export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
export const isTransferLocked = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying';
status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying';
export const isIdentityLocked = (status: DownloadStatus): boolean =>
isTransferLocked(status) || status === 'completed';
+25
View File
@@ -31,6 +31,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'downloading',
'processing',
'seeding',
'waitingToSeed',
'retrying',
]);
@@ -70,6 +71,12 @@ export const MAX_TORRENT_TRACKER_INTERVAL = 604800;
export const DEFAULT_TORRENT_MAX_OPEN_FILES = 100;
export const MIN_TORRENT_MAX_OPEN_FILES = 1;
export const MAX_TORRENT_MAX_OPEN_FILES = 4096;
export const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT = 10;
export const MIN_TORRENT_DHT_MESSAGE_TIMEOUT = 1;
export const MAX_TORRENT_DHT_MESSAGE_TIMEOUT = 600;
export const DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS = 2;
export const MIN_TORRENT_MAX_CONCURRENT_SEEDS = 1;
export const MAX_TORRENT_MAX_CONCURRENT_SEEDS = 64;
const parseIntegerOption = (value: unknown): number | undefined => {
if (typeof value === 'number') {
@@ -105,6 +112,24 @@ export const normalizeTorrentMaxOpenFiles = (value: unknown): number | undefined
: undefined;
};
export const normalizeTorrentDhtMessageTimeout = (value: unknown): number | undefined => {
const parsed = parseIntegerOption(value);
return parsed !== undefined
&& parsed >= MIN_TORRENT_DHT_MESSAGE_TIMEOUT
&& parsed <= MAX_TORRENT_DHT_MESSAGE_TIMEOUT
? parsed
: undefined;
};
export const normalizeTorrentMaxConcurrentSeeds = (value: unknown): number | undefined => {
const parsed = parseIntegerOption(value);
return parsed !== undefined
&& parsed >= MIN_TORRENT_MAX_CONCURRENT_SEEDS
&& parsed <= MAX_TORRENT_MAX_CONCURRENT_SEEDS
? parsed
: undefined;
};
// Keep every filename component within the common cross-platform filesystem
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
// bound is also conservative for Windows filename components.