feat(torrent): complete lifecycle controls

This commit is contained in:
NimBold
2026-08-03 21:47:02 +03:30
parent 819a48bd4b
commit 55a905df14
32 changed files with 2390 additions and 136 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ 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, 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, };
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, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: 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 DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying";
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying";
+1 -1
View File
@@ -1,4 +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_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, };
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, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: 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, 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, };
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, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: 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 TorrentDetails = { infoHash: string, displayName: string, totalBytes: number, fileCount: number, pieceLength: number, pieceCount: number, private: boolean, creationDate: string | null, creator: string | null, comment: string | null, trackers: Array<string>, webSeeds: Array<string>, };
@@ -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 TorrentFileSelectionEntry = { index: number, relativePath: string, length: number, selected: boolean, completedLength?: number, };
@@ -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 { TorrentFileSelectionEntry } from "./TorrentFileSelectionEntry";
export type TorrentFileSelectionSnapshot = { files: Array<TorrentFileSelectionEntry>, };
+7 -5
View File
@@ -178,20 +178,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
};
}, [isActionVisible, updateActionPosition]);
const displayFraction = download.status === 'downloading' || download.status === 'seeding'
const displayFraction = download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
? liveProgress?.fraction ?? download.fraction ?? 0
: download.fraction ?? 0;
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
const displaySpeed = download.status === 'seeding'
? liveProgress?.upload_speed ?? '-'
: download.status === 'downloading'
: download.status === 'downloading' || download.status === 'verifying'
? liveProgress?.speed ?? download.speed
: download.status === 'processing'
? t($ => $.downloads.values.processing)
: '-';
const displayEta = download.status === 'seeding'
? '-'
: download.status === 'downloading'
: download.status === 'downloading' || download.status === 'verifying'
? liveProgress?.eta ?? download.eta
: download.status === 'processing'
? t($ => $.downloads.values.muxing)
@@ -297,6 +297,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'paused' ? 'paused' :
download.status === 'seeding' ? 'seeding' :
download.status === 'processing' ? 'processing' :
download.status === 'verifying' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
}`}
@@ -319,7 +320,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'paused' ? 'download-status-paused' :
download.status === 'seeding' ? 'download-status-seeding' :
download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'verifying' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : ''
@@ -332,7 +334,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
{downloadStatusLabel} #{queueIndex + 1}
</span>
</>
) : download.status === 'downloading' ? (
) : download.status === 'downloading' || download.status === 'verifying' ? (
displayPercent
) : download.status === 'seeding' ? (
displayPercent
+291 -7
View File
@@ -6,6 +6,8 @@ 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 { TorrentFileSelectionSnapshot } from '../bindings/TorrentFileSelectionSnapshot';
import type { TorrentDetails } from '../bindings/TorrentDetails';
import type { TorrentWebSeed } from '../bindings/TorrentWebSeed';
import { invokeCommand as invoke } from '../ipc';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
@@ -22,7 +24,7 @@ import {
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads';
import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
@@ -43,10 +45,10 @@ const formatLastTry = (
};
const isPeerDiagnosticsStatus = (status: string): boolean =>
['downloading', 'seeding', 'retrying'].includes(status);
['downloading', 'verifying', 'seeding', 'retrying'].includes(status);
const isTorrentFileProgressStatus = (status: string): boolean =>
['downloading', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
const formatPeerSpeed = (bytesPerSecond: number): string =>
`${formatDownloadBytes(bytesPerSecond)}/s`;
@@ -95,6 +97,7 @@ export const PropertiesModal = () => {
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false);
const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
const [torrentFileAllocation, setTorrentFileAllocation] = useState<TorrentFileAllocation>('prealloc');
const [torrentTrackers, setTorrentTrackers] = useState('');
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
const [torrentTrackerConnectTimeout, setTorrentTrackerConnectTimeout] = useState('');
@@ -106,6 +109,11 @@ export const PropertiesModal = () => {
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
const [torrentFileProgress, setTorrentFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
const [torrentFileSelection, setTorrentFileSelection] = useState<TorrentFileSelectionSnapshot | null>(null);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails | null>(null);
const [torrentDetailsError, setTorrentDetailsError] = useState(false);
const [isTorrentDetailsPending, setIsTorrentDetailsPending] = useState(false);
const [isTorrentVerifyPending, setIsTorrentVerifyPending] = useState(false);
const [torrentFileProgressError, setTorrentFileProgressError] = useState(false);
const [isTorrentFileProgressPending, setIsTorrentFileProgressPending] = useState(false);
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
@@ -132,9 +140,14 @@ export const PropertiesModal = () => {
const [errorMessage, setErrorMessage] = useState('');
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
const torrentFileProgressByIndex = new Map(
(torrentFileProgress?.files ?? []).map(file => [file.index, file])
);
const actionRequestRef = useRef(0);
const peerDiagnosticsRequestRef = useRef(0);
const torrentFileProgressRequestRef = useRef(0);
const torrentFileSelectionRequestRef = useRef(0);
const torrentDetailsRequestRef = useRef(0);
const torrentPieceProgressRequestRef = useRef(0);
const torrentWebSeedsRequestRef = useRef(0);
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
@@ -154,6 +167,10 @@ export const PropertiesModal = () => {
setTorrentFileProgress(null);
setTorrentFileProgressError(false);
setIsTorrentFileProgressPending(false);
torrentDetailsRequestRef.current += 1;
setTorrentDetails(null);
setTorrentDetailsError(false);
setIsTorrentDetailsPending(false);
torrentPieceProgressRequestRef.current += 1;
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
@@ -227,6 +244,7 @@ export const PropertiesModal = () => {
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true);
setTorrentEncryptionPolicy(normalizeTorrentEncryptionPolicy(activeItem.torrentEncryptionPolicy) || TORRENT_ENCRYPTION_POLICY_DISABLED);
setTorrentFileAllocation(normalizeTorrentFileAllocation(activeItem.torrentFileAllocation) || 'prealloc');
setTorrentTrackers(activeItem.torrentTrackers || '');
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
setTorrentTrackerConnectTimeout(activeItem.torrentTrackerConnectTimeout === undefined ? '' : String(activeItem.torrentTrackerConnectTimeout));
@@ -244,6 +262,58 @@ export const PropertiesModal = () => {
}
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
useEffect(() => {
torrentFileSelectionRequestRef.current += 1;
setTorrentFileSelection(null);
if (!selectedPropertiesDownloadId || !item?.isTorrent) return;
const requestId = torrentFileSelectionRequestRef.current;
const propertiesDownloadId = item.id;
void invoke('get_torrent_file_selection', { id: propertiesDownloadId })
.then(snapshot => {
if (
requestId === torrentFileSelectionRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentFileSelection(snapshot);
}
})
.catch(() => {
if (requestId === torrentFileSelectionRequestRef.current) setTorrentFileSelection(null);
});
}, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]);
useEffect(() => {
torrentDetailsRequestRef.current += 1;
setTorrentDetails(null);
setTorrentDetailsError(false);
setIsTorrentDetailsPending(false);
if (!selectedPropertiesDownloadId || !item?.isTorrent || !item.torrentPath) return;
const requestId = torrentDetailsRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentDetailsPending(true);
void invoke('get_torrent_details', { id: propertiesDownloadId })
.then(details => {
if (
requestId === torrentDetailsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentDetails(details);
}
})
.catch(() => {
if (
requestId === torrentDetailsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentDetailsError(true);
}
})
.finally(() => {
if (requestId === torrentDetailsRequestRef.current) setIsTorrentDetailsPending(false);
});
}, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]);
useEffect(() => {
const activeLimit = item?.speedLimit?.trim();
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
@@ -569,6 +639,32 @@ export const PropertiesModal = () => {
}
};
const handleVerifyTorrentData = async () => {
if (!item?.isTorrent || isTorrentVerifyPending) return;
const restoreStatus = item.status;
const previousVerifyOnly = item.torrentVerifyOnly;
const previousRestoreStatus = item.torrentVerifyRestoreStatus;
// Mark the maintenance lifecycle before invoking the command so a very
// fast queued/completed event cannot be mistaken for the normal download
// lifecycle. The backend persists the same markers before dispatching.
useDownloadStore.getState().updateDownload(item.id, {
torrentVerifyOnly: true,
torrentVerifyRestoreStatus: restoreStatus
});
setIsTorrentVerifyPending(true);
try {
await invoke('verify_torrent_data', { id: item.id });
} catch (error) {
useDownloadStore.getState().updateDownload(item.id, {
torrentVerifyOnly: previousVerifyOnly,
torrentVerifyRestoreStatus: previousRestoreStatus
});
setErrorMessage(error instanceof Error ? error.message : String(error));
} finally {
setIsTorrentVerifyPending(false);
}
};
const handleSave = async () => {
if (!url.trim()) {
setErrorMessage(t($ => $.properties.enterValidUrl));
@@ -638,6 +734,21 @@ export const PropertiesModal = () => {
setErrorMessage(t($ => $.properties.torrentEncryptionPolicyInvalid));
return;
}
const selectedTorrentIndices = torrentFileSelection
? torrentFileSelection.files.filter(file => file.selected).map(file => file.index)
: [];
const allTorrentFilesSelected = Boolean(
torrentFileSelection
&& selectedTorrentIndices?.length === torrentFileSelection.files.length
);
if (torrentFileSelection && selectedTorrentIndices?.length === 0) {
setErrorMessage(t($ => $.properties.torrentFileSelectionRequired));
return;
}
if (item.isTorrent && torrentRemoveUnselectedFile && torrentFileSelection && allTorrentFilesSelected) {
setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired));
return;
}
if (
item.isTorrent
&& torrentRemoveUnselectedFile
@@ -676,12 +787,18 @@ export const PropertiesModal = () => {
: undefined,
torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
torrentRemoveUnselectedFile: item.torrentFileIndices !== undefined
torrentFileIndices: torrentFileSelection
? (allTorrentFilesSelected ? undefined : selectedTorrentIndices)
: item.torrentFileIndices,
torrentRemoveUnselectedFile: (torrentFileSelection
? !allTorrentFilesSelected
: item.torrentFileIndices !== undefined)
? torrentRemoveUnselectedFile
: undefined,
torrentEncryptionPolicy: torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED
? torrentEncryptionPolicy
: undefined,
torrentFileAllocation,
}
: {}),
...(connectionsDirty
@@ -839,6 +956,9 @@ export const PropertiesModal = () => {
const liveTorrentUploadLimitAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
const liveTorrentPeerOptionsAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
const torrentPeerDiagnosticsAvailable = item.isTorrent && isPeerDiagnosticsStatus(item.status);
const torrentFileSelectionIsEmpty = item.isTorrent
&& torrentFileSelection !== null
&& !torrentFileSelection.files.some(file => file.selected);
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
const observedConnectionTotal = Math.max(
1,
@@ -919,7 +1039,7 @@ export const PropertiesModal = () => {
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
else if (item.status === 'downloading' || item.status === 'seeding' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; }
else if (item.status === 'downloading' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; }
else if (item.status === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; }
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
@@ -1105,6 +1225,72 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</div>
{torrentFileSelection && (
<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.torrentFileSelection)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="app-button px-2.5 text-[11px] disabled:opacity-50"
disabled={transferLocked || torrentFileSelection.files.length === 0}
onClick={() => setTorrentFileSelection(current => current
? { ...current, files: current.files.map(file => ({ ...file, selected: true })) }
: current)}
>
{t($ => $.properties.torrentFileSelectionAll)}
</button>
<button
type="button"
className="app-button px-2.5 text-[11px] disabled:opacity-50"
disabled={transferLocked || torrentFileSelection.files.length === 0}
onClick={() => setTorrentFileSelection(current => current
? { ...current, files: current.files.map(file => ({ ...file, selected: false })) }
: current)}
>
{t($ => $.properties.torrentFileSelectionClear)}
</button>
</div>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileSelectionHint)}
</p>
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
{torrentFileSelection.files.map(file => (
<label key={file.index} className="flex items-center gap-2 border-b border-border-modal/40 px-2 py-1.5 text-[11px] last:border-b-0">
<input
type="checkbox"
checked={file.selected}
disabled={transferLocked}
onChange={event => setTorrentFileSelection(current => current
? {
...current,
files: current.files.map(candidate => candidate.index === file.index
? { ...candidate, selected: event.currentTarget.checked }
: candidate)
}
: current)}
className="accent-accent disabled:opacity-50"
/>
<span className="font-mono text-text-muted">{file.index}</span>
<span className="min-w-0 flex-1 truncate" dir="auto" title={file.relativePath}>{file.relativePath}</span>
<span className="text-end text-text-muted whitespace-nowrap">
{(() => {
const progress = torrentFileProgressByIndex.get(file.index);
const completedLength = progress?.completedLength ?? file.completedLength ?? 0;
const percentage = file.length === 0
? 100
: Math.round((completedLength / file.length) * 100);
return `${formatDownloadBytes(completedLength)} / ${formatDownloadBytes(file.length)} (${percentage}%)`;
})()}
</span>
</label>
))}
</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">
@@ -1442,6 +1628,25 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentPrioritizePieceHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-file-allocation-properties">
{t($ => $.properties.torrentFileAllocation)}
</label>
<div>
<select
id="torrent-file-allocation-properties"
value={torrentFileAllocation}
onChange={event => setTorrentFileAllocation(event.currentTarget.value as TorrentFileAllocation)}
disabled={transferLocked}
aria-describedby="torrent-file-allocation-properties-hint"
className="app-control max-w-56 px-2.5 py-1.5 text-xs disabled:opacity-50"
>
<option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option>
<option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option>
</select>
<p id="torrent-file-allocation-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentFileAllocationHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-encryption-policy-properties">
{t($ => $.properties.torrentEncryptionPolicy)}
</label>
@@ -1485,6 +1690,18 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentVerifyIntegrityHint)}
</span>
</label>
<div className="col-start-2">
<button
type="button"
onClick={() => void handleVerifyTorrentData()}
disabled={isTorrentVerifyPending || getTransferLocked(item.status) || !['completed', 'paused', 'failed'].includes(item.status)}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentVerifyPending
? t($ => $.properties.torrentVerifyNowLoading)
: t($ => $.properties.torrentVerifyNow)}
</button>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-remove-unselected-file">
{t($ => $.properties.torrentRemoveUnselectedFile)}
</label>
@@ -1682,6 +1899,73 @@ 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.torrentDetails)}
</h3>
{isTorrentDetailsPending && (
<p className="text-xs text-text-muted">{t($ => $.properties.torrentDetailsLoading)}</p>
)}
{torrentDetailsError && (
<p className="text-xs text-red-400">{t($ => $.properties.torrentDetailsUnavailable)}</p>
)}
{torrentDetails && (
<div className="grid grid-cols-[100px_1fr] gap-x-4 gap-y-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs">
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsDisplayName)}</span>
<span className="text-text-primary break-words" dir="auto">{torrentDetails.displayName}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsInfoHash)}</span>
<span className="font-mono text-text-primary break-all">{torrentDetails.infoHash}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsSize)}</span>
<span className="text-text-primary">{formatDownloadBytes(torrentDetails.totalBytes)}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsFiles)}</span>
<span className="text-text-primary">{torrentDetails.fileCount}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsPieces)}</span>
<span className="text-text-primary">{torrentDetails.pieceCount} × {formatDownloadBytes(torrentDetails.pieceLength)}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsPrivate)}</span>
<span className="text-text-primary">{torrentDetails.private
? t($ => $.properties.torrentDetailsPrivateYes)
: t($ => $.properties.torrentDetailsPrivateNo)}</span>
{torrentDetails.creationDate && (
<>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsCreated)}</span>
<span className="text-text-primary">{formatDateTime(torrentDetails.creationDate, {
locale: i18n.language,
calendar: calendarPreference,
options: { dateStyle: 'medium', timeStyle: 'short' }
})}</span>
</>
)}
{torrentDetails.creator && (
<>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsCreator)}</span>
<span className="text-text-primary break-words" dir="auto">{torrentDetails.creator}</span>
</>
)}
{torrentDetails.comment && (
<>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsComment)}</span>
<span className="whitespace-pre-wrap break-words text-text-primary" dir="auto">{torrentDetails.comment}</span>
</>
)}
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsTrackers)}</span>
<span className="text-text-primary break-words" dir="auto">
{torrentDetails.trackers.length > 0 ? torrentDetails.trackers.join(', ') : '—'}
</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsWebSeeds)}</span>
<span className="text-text-primary break-words" dir="auto">
{torrentDetails.webSeeds.length > 0 ? torrentDetails.webSeeds.join(', ') : '—'}
</span>
{torrentDetails.private && (
<p className="col-span-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentDetailsPrivateHint)}
</p>
)}
</div>
)}
</section>
)}
{item.isTorrent && (
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
@@ -1794,8 +2078,8 @@ export const PropertiesModal = () => {
<button
type="button"
onClick={handleSave}
disabled={transferLocked}
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={transferLocked || torrentFileSelectionIsEmpty}
className={`app-button app-button-primary px-4 text-xs ${transferLocked || torrentFileSelectionIsEmpty ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<CheckCircle size={14} />
{t($ => $.properties.save)}
+41
View File
@@ -1280,6 +1280,19 @@ runEngineChecks(false);
type="checkbox"
checked={settings.torrentEnableDht6}
onChange={(event) => settings.setTorrentEnableDht6(event.target.checked)}
disabled={!settings.torrentIpv6Enabled}
className="mac-switch disabled:opacity-50"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentIpv6Enabled)}</span>
<small>{t($ => $.settings.network.torrentIpv6EnabledDescription)}</small>
</div>
<input
type="checkbox"
checked={settings.torrentIpv6Enabled}
onChange={(event) => settings.setTorrentIpv6Enabled(event.target.checked)}
className="mac-switch"
/>
</label>
@@ -1328,6 +1341,20 @@ runEngineChecks(false);
aria-label={t($ => $.settings.network.torrentListenPort)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentBindAddress)}</span>
<small>{t($ => $.settings.network.torrentBindAddressDescription)}</small>
</div>
<input
type="text"
value={settings.torrentBindAddress}
onChange={(event) => settings.setTorrentBindAddress(event.target.value)}
placeholder="192.0.2.10 or 2001:db8::10"
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentBindAddress)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtListenPort)}</span>
@@ -1519,6 +1546,20 @@ runEngineChecks(false);
aria-label={t($ => $.settings.network.torrentMaxOpenFiles)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.aria2DiskCache)}</span>
<small>{t($ => $.settings.network.aria2DiskCacheDescription)}</small>
</div>
<input
type="text"
value={settings.aria2DiskCache}
onChange={(event) => settings.setAria2DiskCache(event.target.value)}
placeholder="16M"
className="app-control settings-network-input text-center"
aria-label={t($ => $.settings.network.aria2DiskCache)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentOverallUploadLimit)}</span>
+35
View File
@@ -89,6 +89,7 @@ const common = {
queued: 'Queued',
downloading: 'Downloading',
processing: 'Processing',
verifying: 'Verifying',
seeding: 'Seeding',
waitingToSeed: 'Waiting to seed',
paused: 'Paused',
@@ -251,6 +252,8 @@ const common = {
torrentTrackerIntervalInvalid: 'Tracker interval must be a whole number from 0 to 604800 seconds',
torrentVerifyIntegrity: 'Verify Torrent integrity',
torrentVerifyIntegrityHint: 'Applied when this Torrent starts or retries. It may recheck pieces and download damaged data; active transfers cannot change it.',
torrentVerifyNow: 'Verify now',
torrentVerifyNowLoading: 'Verifying…',
torrentMaxPeers: 'Maximum Torrent peers',
torrentPeerSpeedLimit: 'Peer speed threshold',
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
@@ -262,6 +265,11 @@ const common = {
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',
torrentFileSelection: 'Torrent file selection',
torrentFileSelectionHint: 'Choose which files to download. Selecting every file removes the filter; at least one file must remain selected.',
torrentFileSelectionRequired: 'Select at least one Torrent file.',
torrentFileSelectionAll: 'Select all',
torrentFileSelectionClear: 'Clear',
torrentFileProgressRefresh: 'Refresh',
torrentFileProgressLoading: 'Loading file progress…',
torrentFileProgressUnavailable: 'File progress is available while this Torrent is active or paused.',
@@ -300,6 +308,27 @@ const common = {
torrentPrioritizePieceHint: 'Optional Aria2 preview policy: head, tail, or both; each may use a size such as 1M. Changes apply when the Torrent starts or retries.',
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
torrentEncryptionPolicy: 'Torrent encryption policy',
torrentFileAllocation: 'Torrent file allocation',
torrentFileAllocationPrealloc: 'Preallocate files',
torrentFileAllocationNone: 'Allocate as needed',
torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.',
torrentDetails: 'Torrent details',
torrentDetailsLoading: 'Loading Torrent details…',
torrentDetailsUnavailable: 'Torrent details are not available.',
torrentDetailsDisplayName: 'Display name',
torrentDetailsInfoHash: 'Info hash',
torrentDetailsSize: 'Total size',
torrentDetailsFiles: 'Files',
torrentDetailsPieces: 'Pieces',
torrentDetailsPrivate: 'Private',
torrentDetailsPrivateYes: 'Yes',
torrentDetailsPrivateNo: 'No',
torrentDetailsCreated: 'Created',
torrentDetailsCreator: 'Creator',
torrentDetailsComment: 'Comment',
torrentDetailsTrackers: 'Trackers',
torrentDetailsWebSeeds: 'Embedded web seeds',
torrentDetailsPrivateHint: 'This private Torrent disables DHT, DHT6, PEX, and LPD discovery regardless of broader settings.',
torrentEncryptionPolicyHint: 'Applied when this Torrent starts or retries. Choose one policy so the handshake and payload encryption settings stay consistent.',
torrentEncryptionDisabled: 'Disabled',
torrentEncryptionRequireCrypto: 'Require obfuscated handshake',
@@ -819,6 +848,8 @@ const common = {
torrentDhtDescription: 'Find peers without relying only on trackers. Disabling this also disables UDP tracker support.',
torrentDht6: 'IPv6 DHT',
torrentDht6Description: 'Use IPv6 for distributed peer discovery when the network provides a usable IPv6 path.',
torrentIpv6Enabled: 'Enable IPv6 for Torrent networking',
torrentIpv6EnabledDescription: 'Keep IPv6 available to BitTorrent, DHT, and peer discovery. Disabling this overrides IPv6 DHT even when its preference remains enabled.',
torrentPex: 'Peer Exchange (PEX)',
torrentPexDescription: 'Allow connected peers to share additional peer addresses.',
torrentLpd: 'Local Peer Discovery (LPD)',
@@ -834,6 +865,8 @@ const common = {
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.',
torrentBindAddress: 'Torrent bind address',
torrentBindAddressDescription: 'Optional local IPv4 or IPv6 address for Aria2 sockets. Invalid addresses are rejected; changes apply after restart.',
torrentDhtListenPort: 'UDP/DHT ports',
torrentDhtListenPortDescription: 'UDP ports for DHT and UDP trackers. Leave blank for Aria2s default range.',
torrentExternalIp: 'External IP address',
@@ -855,6 +888,8 @@ const common = {
torrentResourceLimits: 'BitTorrent resource limits',
torrentMaxOpenFiles: 'Maximum open Torrent files',
torrentMaxOpenFilesDescription: 'Global Aria2 limit for files open at once in multi-file Torrents. Lower values reduce file-descriptor use; the default is 100. Changes apply to new Torrents without restarting Aria2, and this does not raise your operating system limit.',
aria2DiskCache: 'Aria2 disk cache',
aria2DiskCacheDescription: 'Cache size for Aria2, using 0 or a positive value such as 16M. Accepts K/M values up to 1024M and applies after restart.',
torrentMaxOpenFilesUpdateFailed: 'Could not apply the Torrent open-file limit: {{detail}}',
torrentOverallUploadLimit: 'Overall Aria2 upload limit',
torrentOverallUploadLimitDescription: 'Caps combined Aria2 upload traffic, primarily active Torrent seeding in Firelink. Leave blank for unlimited; the value is applied live and restored when Firelink restarts.',
+35
View File
@@ -89,6 +89,7 @@ const fa = {
queued: 'در صف',
downloading: 'در حال دانلود',
processing: 'در حال پردازش',
verifying: 'در حال بررسی صحت',
seeding: 'در حال اشتراک‌گذاری',
waitingToSeed: 'در انتظار اشتراک‌گذاری',
paused: 'متوقف‌شده',
@@ -251,6 +252,8 @@ const fa = {
torrentTrackerIntervalInvalid: 'فاصله Tracker باید عددی صحیح بین ۰ تا ۶۰۴۸۰۰ ثانیه باشد',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال می‌شود. ممکن است قطعه‌ها دوباره بررسی و داده‌های خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
torrentVerifyNow: 'بررسی صحت اکنون',
torrentVerifyNowLoading: 'در حال بررسی صحت…',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
@@ -262,6 +265,11 @@ const fa = {
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده می‌شود؛ IP، پورت، شناسه و بیت‌فیلد همتاها ذخیره نمی‌شود.',
torrentFileProgress: 'پیشرفت فایل‌های تورنت',
torrentFileSelection: 'انتخاب فایل‌های تورنت',
torrentFileSelectionHint: 'فایل‌های موردنظر برای دانلود را انتخاب کنید. انتخاب همهٔ فایل‌ها فیلتر را حذف می‌کند؛ حداقل یک فایل باید انتخاب شود.',
torrentFileSelectionRequired: 'حداقل یک فایل تورنت را انتخاب کنید.',
torrentFileSelectionAll: 'انتخاب همه',
torrentFileSelectionClear: 'پاک‌کردن',
torrentFileProgressRefresh: 'تازه‌سازی',
torrentFileProgressLoading: 'در حال دریافت پیشرفت فایل‌ها…',
torrentFileProgressUnavailable: 'پیشرفت فایل هنگام فعال یا متوقف‌بودن تورنت در دسترس است.',
@@ -300,6 +308,27 @@ const fa = {
torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام می‌توان اندازه‌ای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
torrentFileAllocation: 'نحوهٔ تخصیص فایل تورنت',
torrentFileAllocationPrealloc: 'تخصیص از پیش',
torrentFileAllocationNone: 'تخصیص هنگام نیاز',
torrentFileAllocationHint: 'تخصیص از پیش فضای فایل‌های انتخاب‌شده را قبل از انتقال رزرو می‌کند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمی‌دهد.',
torrentDetails: 'جزئیات تورنت',
torrentDetailsLoading: 'در حال دریافت جزئیات تورنت…',
torrentDetailsUnavailable: 'جزئیات تورنت در دسترس نیست.',
torrentDetailsDisplayName: 'نام نمایشی',
torrentDetailsInfoHash: 'هش اطلاعات',
torrentDetailsSize: 'حجم کل',
torrentDetailsFiles: 'فایل‌ها',
torrentDetailsPieces: 'قطعه‌ها',
torrentDetailsPrivate: 'خصوصی',
torrentDetailsPrivateYes: 'بله',
torrentDetailsPrivateNo: 'خیر',
torrentDetailsCreated: 'ایجادشده',
torrentDetailsCreator: 'سازنده',
torrentDetailsComment: 'توضیح',
torrentDetailsTrackers: 'ترکرها',
torrentDetailsWebSeeds: 'وب‌سیدهای داخلی',
torrentDetailsPrivateHint: 'این تورنت خصوصی، مستقل از تنظیمات کلی، کشف DHT، DHT6، PEX و LPD را غیرفعال می‌کند.',
torrentEncryptionPolicyHint: 'هنگام شروع یا تلاش مجدد اعمال می‌شود. یک سیاست واحد انتخاب کنید تا تنظیمات handshake و رمزنگاری payload آریا۲ سازگار بمانند.',
torrentEncryptionDisabled: 'غیرفعال',
torrentEncryptionRequireCrypto: 'الزام handshake مبهم‌سازی‌شده',
@@ -819,6 +848,8 @@ const fa = {
torrentDhtDescription: 'همتاها را بدون تکیه صرف بر ترکرها پیدا می‌کند. خاموش کردن آن پشتیبانی از ترکرهای UDP را هم خاموش می‌کند.',
torrentDht6: 'DHT نسخه IPv6',
torrentDht6Description: 'وقتی مسیر IPv6 قابل استفاده باشد، از آن برای کشف توزیع‌شده همتاها استفاده می‌کند.',
torrentIpv6Enabled: 'فعال‌سازی IPv6 برای تورنت',
torrentIpv6EnabledDescription: 'IPv6 را برای BitTorrent، DHT و کشف همتاها فعال نگه می‌دارد. غیرفعال‌کردن آن IPv6 DHT را خاموش می‌کند.',
torrentPex: 'تبادل همتا (PEX)',
torrentPexDescription: 'به همتاهای متصل اجازه می‌دهد آدرس همتاهای بیشتری را به اشتراک بگذارند.',
torrentLpd: 'کشف همتای محلی (LPD)',
@@ -834,6 +865,8 @@ const fa = {
torrentMaxConcurrentSeedsDescription: 'وقتی ظرفیت جداگانه فعال است، حداکثر تعداد تورنت‌هایی که Firelink هم‌زمان سید می‌کند.',
torrentListenPort: 'پورت‌های همتای TCP',
torrentListenPortDescription: 'پورت‌های TCP برای اتصال‌های ورودی همتاهای بیت‌تورنت. برای محدوده پیش‌فرض Aria2 خالی بگذارید.',
torrentBindAddress: 'نشانی اتصال تورنت',
torrentBindAddressDescription: 'نشانی محلی اختیاری IPv4 یا IPv6 برای سوکت‌های Aria2. نشانی نامعتبر رد می‌شود و تغییر پس از راه‌اندازی مجدد اعمال می‌شود.',
torrentDhtListenPort: 'پورت‌های UDP/DHT',
torrentDhtListenPortDescription: 'پورت‌های UDP برای DHT و ترکرهای UDP. برای محدوده پیش‌فرض Aria2 خالی بگذارید.',
torrentExternalIp: 'آدرس IP خارجی',
@@ -855,6 +888,8 @@ const fa = {
torrentResourceLimits: 'محدودیت منابع بیت‌تورنت',
torrentMaxOpenFiles: 'حداکثر فایل‌های باز تورنت',
torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایل‌های هم‌زمان باز در تورنت‌های چندفایلی. مقدار کمتر مصرف file descriptor را کم می‌کند؛ پیش‌فرض ۱۰۰ است. تغییرات برای تورنت‌های جدید و بدون راه‌اندازی مجدد Aria2 اعمال می‌شوند و محدودیت سیستم‌عامل را افزایش نمی‌دهند.',
aria2DiskCache: 'کش دیسک Aria2',
aria2DiskCacheDescription: 'اندازهٔ کش Aria2؛ صفر یا مقداری مانند 16M وارد کنید. مقادیر K/M تا 1024M پذیرفته می‌شوند و پس از راه‌اندازی مجدد اعمال می‌شوند.',
torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایل‌های باز تورنت ممکن نشد: {{detail}}',
torrentOverallUploadLimit: 'محدودیت کلی آپلود Aria2',
torrentOverallUploadLimitDescription: 'سرعت کلی آپلود Aria2 را محدود می‌کند؛ در Firelink این مقدار عمدتاً برای سیدینگ تورنت‌هاست. برای نامحدود بودن خالی بگذارید؛ مقدار جدید زنده اعمال می‌شود و پس از راه‌اندازی مجدد Firelink برمی‌گردد.',
+35
View File
@@ -89,6 +89,7 @@ const he = {
queued: 'בתור',
downloading: 'מוריד',
processing: 'מעבד',
verifying: 'מאמת',
seeding: 'משתף',
waitingToSeed: 'ממתין לשיתוף',
paused: 'מושהה',
@@ -251,6 +252,8 @@ const he = {
torrentTrackerIntervalInvalid: 'מרווח ה-Tracker חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
torrentVerifyNow: 'אמת עכשיו',
torrentVerifyNowLoading: 'מאמת…',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
@@ -262,6 +265,11 @@ const he = {
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.',
torrentFileProgress: 'התקדמות קובצי הטורנט',
torrentFileSelection: 'בחירת קובצי טורנט',
torrentFileSelectionHint: 'בחר אילו קבצים להוריד. בחירת כל הקבצים מסירה את הסינון; יש להשאיר לפחות קובץ אחד.',
torrentFileSelectionRequired: 'בחר לפחות קובץ טורנט אחד.',
torrentFileSelectionAll: 'בחר הכול',
torrentFileSelectionClear: 'נקה',
torrentFileProgressRefresh: 'רענון',
torrentFileProgressLoading: 'טוען את התקדמות הקבצים…',
torrentFileProgressUnavailable: 'התקדמות הקבצים זמינה כשהטורנט פעיל או מושהה.',
@@ -300,6 +308,27 @@ const he = {
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
torrentFileAllocation: 'הקצאת קובצי Torrent',
torrentFileAllocationPrealloc: 'הקצאה מראש',
torrentFileAllocationNone: 'הקצאה לפי הצורך',
torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.',
torrentDetails: 'פרטי טורנט',
torrentDetailsLoading: 'טוען פרטי טורנט…',
torrentDetailsUnavailable: 'פרטי הטורנט אינם זמינים.',
torrentDetailsDisplayName: 'שם תצוגה',
torrentDetailsInfoHash: 'גיבוב מידע',
torrentDetailsSize: 'גודל כולל',
torrentDetailsFiles: 'קבצים',
torrentDetailsPieces: 'חלקים',
torrentDetailsPrivate: 'פרטי',
torrentDetailsPrivateYes: 'כן',
torrentDetailsPrivateNo: 'לא',
torrentDetailsCreated: 'נוצר',
torrentDetailsCreator: 'יוצר',
torrentDetailsComment: 'הערה',
torrentDetailsTrackers: 'עוקבים',
torrentDetailsWebSeeds: 'זרעי Web משובצים',
torrentDetailsPrivateHint: 'טורנט פרטי זה משבית גילוי DHT, DHT6, PEX ו-LPD ללא קשר להגדרות הכלליות.',
torrentEncryptionPolicyHint: 'מוחלת כשה-Torrent מתחיל או מנסה שוב. בחרו מדיניות אחת כדי לשמור על הגדרות handshake והצפנת payload עקביות.',
torrentEncryptionDisabled: 'מושבתת',
torrentEncryptionRequireCrypto: 'דרישת handshake מוסווה',
@@ -819,6 +848,8 @@ const he = {
torrentDhtDescription: 'מאתר עמיתים בלי להסתמך רק על מעקבים. השבתה מכבה גם תמיכה במעקבי UDP.',
torrentDht6: 'DHT של IPv6',
torrentDht6Description: 'משתמש ב-IPv6 לגילוי מבוזר של עמיתים כשיש נתיב IPv6 זמין.',
torrentIpv6Enabled: 'הפעלת IPv6 עבור טורנטים',
torrentIpv6EnabledDescription: 'משאיר את IPv6 זמין עבור BitTorrent, DHT וגילוי עמיתים. השבתה זו מכבה גם IPv6 DHT.',
torrentPex: 'החלפת עמיתים (PEX)',
torrentPexDescription: 'מאפשר לעמיתים מחוברים לשתף כתובות של עמיתים נוספים.',
torrentLpd: 'גילוי עמיתים מקומיים (LPD)',
@@ -834,6 +865,8 @@ const he = {
torrentMaxConcurrentSeedsDescription: 'מספר הטורנטים המרבי ש-Firelink יזריע בו-זמנית כשהקיבולת הנפרדת פעילה.',
torrentListenPort: 'יציאות עמיתי TCP',
torrentListenPortDescription: 'יציאות TCP לחיבורי עמיתים נכנסים של BitTorrent. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.',
torrentBindAddress: 'כתובת קישור לטורנטים',
torrentBindAddressDescription: 'כתובת IPv4 או IPv6 מקומית ואופציונלית לשקעי Aria2. כתובות לא תקינות נדחות; השינוי חל לאחר הפעלה מחדש.',
torrentDhtListenPort: 'יציאות UDP/DHT',
torrentDhtListenPortDescription: 'יציאות UDP עבור DHT ועוקבי UDP. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.',
torrentExternalIp: 'כתובת IP חיצונית',
@@ -855,6 +888,8 @@ const he = {
torrentResourceLimits: 'מגבלות משאבי BitTorrent',
torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי',
torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.',
aria2DiskCache: 'מטמון דיסק של Aria2',
aria2DiskCacheDescription: 'גודל מטמון Aria2: 0 או ערך כמו 16M. ערכי K/M עד 1024M מתקבלים; חל לאחר הפעלה מחדש.',
torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}',
torrentOverallUploadLimit: 'מגבלת העלאה כוללת של Aria2',
torrentOverallUploadLimitDescription: 'מגבילה את מהירות ההעלאה המשולבת של Aria2, בעיקר עבור העלאת טורנטים פעילים ב-Firelink. השאר ריק ללא הגבלה; הערך מוחל מיד ומשוחזר לאחר הפעלה מחדש של Firelink.',
+35
View File
@@ -89,6 +89,7 @@ const ru = {
queued: 'В очереди',
downloading: 'Загрузка',
processing: 'Обработка',
verifying: 'Проверка',
seeding: 'Раздача',
waitingToSeed: 'Ожидание раздачи',
paused: 'Приостановлено',
@@ -251,6 +252,8 @@ const ru = {
torrentTrackerIntervalInvalid: 'Интервал трекера должен быть целым числом от 0 до 604800 секунд',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
torrentVerifyNow: 'Проверить сейчас',
torrentVerifyNowLoading: 'Проверка…',
torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
@@ -262,6 +265,11 @@ const ru = {
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.',
torrentFileProgress: 'Прогресс файлов торрента',
torrentFileSelection: 'Выбор файлов торрента',
torrentFileSelectionHint: 'Выберите файлы для загрузки. Выбор всех файлов снимает фильтр; должен остаться хотя бы один файл.',
torrentFileSelectionRequired: 'Выберите хотя бы один файл торрента.',
torrentFileSelectionAll: 'Выбрать все',
torrentFileSelectionClear: 'Очистить',
torrentFileProgressRefresh: 'Обновить',
torrentFileProgressLoading: 'Загрузка прогресса файлов…',
torrentFileProgressUnavailable: 'Прогресс файлов доступен, пока торрент активен или приостановлен.',
@@ -300,7 +308,28 @@ const ru = {
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
torrentEncryptionPolicy: 'Политика шифрования Torrent',
torrentFileAllocation: 'Выделение места для файлов Torrent',
torrentFileAllocationPrealloc: 'Предварительное выделение',
torrentFileAllocationNone: 'Выделять по мере необходимости',
torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.',
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
torrentDetails: 'Сведения о Torrent',
torrentDetailsLoading: 'Загрузка сведений о Torrent…',
torrentDetailsUnavailable: 'Сведения о Torrent недоступны.',
torrentDetailsDisplayName: 'Отображаемое имя',
torrentDetailsInfoHash: 'Инфохеш',
torrentDetailsSize: 'Общий размер',
torrentDetailsFiles: 'Файлы',
torrentDetailsPieces: 'Части',
torrentDetailsPrivate: 'Приватный',
torrentDetailsPrivateYes: 'Да',
torrentDetailsPrivateNo: 'Нет',
torrentDetailsCreated: 'Создан',
torrentDetailsCreator: 'Создатель',
torrentDetailsComment: 'Комментарий',
torrentDetailsTrackers: 'Трекеры',
torrentDetailsWebSeeds: 'Встроенные веб-сиды',
torrentDetailsPrivateHint: 'Этот приватный Torrent отключает обнаружение через DHT, DHT6, PEX и LPD независимо от общих настроек.',
torrentEncryptionDisabled: 'Отключено',
torrentEncryptionRequireCrypto: 'Требовать зашифрованное рукопожатие',
torrentEncryptionForceEncryption: 'Принудительно шифровать payload (ARC4)',
@@ -819,6 +848,8 @@ const ru = {
torrentDhtDescription: 'Ищет пиры не только через трекеры. Отключение также отключает поддержку UDP-трекеров.',
torrentDht6: 'DHT по IPv6',
torrentDht6Description: 'Использует IPv6 для распределённого поиска пиров, если доступен рабочий IPv6-маршрут.',
torrentIpv6Enabled: 'Использовать IPv6 для торрентов',
torrentIpv6EnabledDescription: 'Оставляет IPv6 доступным для BitTorrent, DHT и поиска пиров. Отключение также выключает IPv6 DHT.',
torrentPex: 'Обмен пирами (PEX)',
torrentPexDescription: 'Позволяет подключённым пирам передавать адреса дополнительных пиров.',
torrentLpd: 'Локальное обнаружение пиров (LPD)',
@@ -834,6 +865,8 @@ const ru = {
torrentMaxConcurrentSeedsDescription: 'Максимальное число торрентов, которые Firelink раздаёт одновременно при включённой отдельной ёмкости.',
torrentListenPort: 'TCP-порты пиров',
torrentListenPortDescription: 'TCP-порты для входящих соединений BitTorrent. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.',
torrentBindAddress: 'Адрес привязки торрентов',
torrentBindAddressDescription: 'Необязательный локальный IPv4- или IPv6-адрес для сокетов Aria2. Недопустимые адреса отклоняются; применяется после перезапуска.',
torrentDhtListenPort: 'Порты UDP/DHT',
torrentDhtListenPortDescription: 'UDP-порты для DHT и UDP-трекеров. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.',
torrentExternalIp: 'Внешний IP-адрес',
@@ -855,6 +888,8 @@ const ru = {
torrentResourceLimits: 'Ограничения ресурсов BitTorrent',
torrentMaxOpenFiles: 'Максимум открытых файлов Torrent',
torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.',
aria2DiskCache: 'Дисковый кэш Aria2',
aria2DiskCacheDescription: 'Размер кэша Aria2: 0 или значение вроде 16M. Допустимы K/M до 1024M; применяется после перезапуска.',
torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}',
torrentOverallUploadLimit: 'Общий лимит отдачи Aria2',
torrentOverallUploadLimitDescription: 'Ограничивает суммарную скорость отдачи Aria2; в Firelink это в основном раздача активных торрентов. Оставьте поле пустым для снятия ограничения; значение применяется сразу и восстанавливается после перезапуска Firelink.',
+35
View File
@@ -89,6 +89,7 @@ const uk = {
queued: 'У черзі',
downloading: 'Завантаження',
processing: 'Обробка',
verifying: 'Перевірка',
seeding: 'Роздача',
waitingToSeed: 'Очікування роздачі',
paused: 'Призупинено',
@@ -251,6 +252,8 @@ const uk = {
torrentTrackerIntervalInvalid: 'Інтервал трекера має бути цілим числом від 0 до 604800 секунд',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
torrentVerifyNow: 'Перевірити зараз',
torrentVerifyNowLoading: 'Перевірка…',
torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
@@ -262,6 +265,11 @@ const uk = {
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.',
torrentFileProgress: 'Прогрес файлів торрента',
torrentFileSelection: 'Вибір файлів торрента',
torrentFileSelectionHint: 'Виберіть файли для завантаження. Вибір усіх файлів прибирає фільтр; має залишитися хоча б один файл.',
torrentFileSelectionRequired: 'Виберіть хоча б один файл торрента.',
torrentFileSelectionAll: 'Вибрати все',
torrentFileSelectionClear: 'Очистити',
torrentFileProgressRefresh: 'Оновити',
torrentFileProgressLoading: 'Завантаження прогресу файлів…',
torrentFileProgressUnavailable: 'Прогрес файлів доступний, коли торрент активний або призупинений.',
@@ -300,7 +308,28 @@ const uk = {
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
torrentEncryptionPolicy: 'Політика шифрування Torrent',
torrentFileAllocation: 'Виділення місця для файлів Torrent',
torrentFileAllocationPrealloc: 'Попереднє виділення',
torrentFileAllocationNone: 'Виділяти за потреби',
torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.',
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
torrentDetails: 'Відомості про Torrent',
torrentDetailsLoading: 'Завантаження відомостей про Torrent…',
torrentDetailsUnavailable: 'Відомості про Torrent недоступні.',
torrentDetailsDisplayName: 'Назва',
torrentDetailsInfoHash: 'Інфохеш',
torrentDetailsSize: 'Загальний розмір',
torrentDetailsFiles: 'Файли',
torrentDetailsPieces: 'Частини',
torrentDetailsPrivate: 'Приватний',
torrentDetailsPrivateYes: 'Так',
torrentDetailsPrivateNo: 'Ні',
torrentDetailsCreated: 'Створено',
torrentDetailsCreator: 'Автор',
torrentDetailsComment: 'Коментар',
torrentDetailsTrackers: 'Трекери',
torrentDetailsWebSeeds: 'Вбудовані веб-сиди',
torrentDetailsPrivateHint: 'Цей приватний Torrent вимикає виявлення через DHT, DHT6, PEX і LPD незалежно від загальних налаштувань.',
torrentEncryptionDisabled: 'Вимкнено',
torrentEncryptionRequireCrypto: 'Вимагати зашифроване рукостискання',
torrentEncryptionForceEncryption: 'Примусово шифрувати payload (ARC4)',
@@ -819,6 +848,8 @@ const uk = {
torrentDhtDescription: 'Шукає пірів не лише через трекери. Вимкнення також вимикає підтримку UDP-трекерів.',
torrentDht6: 'DHT через IPv6',
torrentDht6Description: 'Використовує IPv6 для розподіленого пошуку пірів, якщо доступний робочий маршрут IPv6.',
torrentIpv6Enabled: 'Використовувати IPv6 для торентів',
torrentIpv6EnabledDescription: 'Залишає IPv6 доступним для BitTorrent, DHT і пошуку пірів. Вимкнення також вимикає IPv6 DHT.',
torrentPex: 'Обмін пірами (PEX)',
torrentPexDescription: 'Дозволяє підключеним пірам передавати адреси додаткових пірів.',
torrentLpd: 'Локальний пошук пірів (LPD)',
@@ -834,6 +865,8 @@ const uk = {
torrentMaxConcurrentSeedsDescription: 'Максимальна кількість торентів, які Firelink роздає одночасно за ввімкненої окремої місткості.',
torrentListenPort: 'TCP-порти пірів',
torrentListenPortDescription: 'TCP-порти для вхідних з’єднань BitTorrent. Залиште порожнім, щоб використати типовий діапазон Aria2.',
torrentBindAddress: 'Адреса прив’язки торентів',
torrentBindAddressDescription: 'Необов’язкова локальна IPv4- або IPv6-адреса для сокетів Aria2. Некоректні адреси відхиляються; застосовується після перезапуску.',
torrentDhtListenPort: 'Порти UDP/DHT',
torrentDhtListenPortDescription: 'UDP-порти для DHT і UDP-трекерів. Залиште порожнім, щоб використати типовий діапазон Aria2.',
torrentExternalIp: 'Зовнішня IP-адреса',
@@ -855,6 +888,8 @@ const uk = {
torrentResourceLimits: 'Обмеження ресурсів BitTorrent',
torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent',
torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.',
aria2DiskCache: 'Дисковий кеш Aria2',
aria2DiskCacheDescription: 'Розмір кешу Aria2: 0 або значення на кшталт 16M. Допустимі K/M до 1024M; застосовується після перезапуску.',
torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}',
torrentOverallUploadLimit: 'Загальне обмеження віддачі Aria2',
torrentOverallUploadLimitDescription: 'Обмежує сумарну швидкість віддачі Aria2; у Firelink це переважно роздача активних торрентів. Залиште поле порожнім без обмеження; значення застосовується одразу й відновлюється після перезапуску Firelink.',
+35
View File
@@ -89,6 +89,7 @@ const zhCN = {
queued: '已排队',
downloading: '下载中',
processing: '处理中',
verifying: '校验中',
seeding: '做种中',
waitingToSeed: '等待做种',
paused: '已暂停',
@@ -251,6 +252,8 @@ const zhCN = {
torrentTrackerIntervalInvalid: 'Tracker 间隔必须是 0 到 604800 秒之间的整数',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
torrentVerifyNow: '立即验证',
torrentVerifyNowLoading: '验证中…',
torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值',
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
@@ -262,6 +265,11 @@ const zhCN = {
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
torrentPeerDiagnosticsHint: '仅显示速度和连接状态;不会保留对等节点 IP、端口、ID 或位域。',
torrentFileProgress: 'Torrent 文件进度',
torrentFileSelection: 'Torrent 文件选择',
torrentFileSelectionHint: '选择要下载的文件。选择全部文件会移除筛选;至少要保留一个文件。',
torrentFileSelectionRequired: '请至少选择一个 Torrent 文件。',
torrentFileSelectionAll: '全选',
torrentFileSelectionClear: '清除',
torrentFileProgressRefresh: '刷新',
torrentFileProgressLoading: '正在加载文件进度…',
torrentFileProgressUnavailable: 'Torrent 活跃或暂停时可查看文件进度。',
@@ -300,7 +308,28 @@ const zhCN = {
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
torrentEncryptionPolicy: 'Torrent 加密策略',
torrentFileAllocation: 'Torrent 文件分配',
torrentFileAllocationPrealloc: '预分配文件',
torrentFileAllocationNone: '按需分配',
torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。',
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
torrentDetails: 'Torrent 详细信息',
torrentDetailsLoading: '正在加载 Torrent 详细信息…',
torrentDetailsUnavailable: 'Torrent 详细信息不可用。',
torrentDetailsDisplayName: '显示名称',
torrentDetailsInfoHash: '信息哈希',
torrentDetailsSize: '总大小',
torrentDetailsFiles: '文件',
torrentDetailsPieces: '分片',
torrentDetailsPrivate: '私有',
torrentDetailsPrivateYes: '是',
torrentDetailsPrivateNo: '否',
torrentDetailsCreated: '创建时间',
torrentDetailsCreator: '创建者',
torrentDetailsComment: '备注',
torrentDetailsTrackers: 'Tracker',
torrentDetailsWebSeeds: '内嵌 Web seed',
torrentDetailsPrivateHint: '此私有 Torrent 会独立于全局设置禁用 DHT、DHT6、PEX 和 LPD 发现。',
torrentEncryptionDisabled: '已禁用',
torrentEncryptionRequireCrypto: '要求加密握手',
torrentEncryptionForceEncryption: '强制加密 payloadARC4',
@@ -819,6 +848,8 @@ const zhCN = {
torrentDhtDescription: '不只依赖 Tracker 查找节点。关闭后也会禁用 UDP Tracker 支持。',
torrentDht6: 'IPv6 分布式哈希表',
torrentDht6Description: '当网络提供可用的 IPv6 路径时,使用 IPv6 进行分布式节点发现。',
torrentIpv6Enabled: '为种子网络启用 IPv6',
torrentIpv6EnabledDescription: '为 BitTorrent、DHT 和节点发现保留 IPv6。禁用后也会关闭 IPv6 DHT。',
torrentPex: '节点交换(PEX',
torrentPexDescription: '允许已连接的节点共享其他节点的地址。',
torrentLpd: '本地节点发现(LPD',
@@ -834,6 +865,8 @@ const zhCN = {
torrentMaxConcurrentSeedsDescription: '启用独立容量后,Firelink 同时做种的 Torrent 数量上限。',
torrentListenPort: 'TCP 节点端口',
torrentListenPortDescription: '用于传入 BitTorrent 节点连接的 TCP 端口。留空以使用 Aria2 的默认范围。',
torrentBindAddress: '种子绑定地址',
torrentBindAddressDescription: '可选的本地 IPv4 或 IPv6 地址,用于 Aria2 套接字。无效地址会被拒绝;重启后生效。',
torrentDhtListenPort: 'UDP/DHT 端口',
torrentDhtListenPortDescription: '用于 DHT 和 UDP 跟踪器的 UDP 端口。留空以使用 Aria2 的默认范围。',
torrentExternalIp: '外部 IP 地址',
@@ -855,6 +888,8 @@ const zhCN = {
torrentResourceLimits: 'BitTorrent 资源限制',
torrentMaxOpenFiles: 'Torrent 最大打开文件数',
torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。',
aria2DiskCache: 'Aria2 磁盘缓存',
aria2DiskCacheDescription: 'Aria2 缓存大小:0 或类似 16M 的值。接受最大 1024M 的 K/M 值,重启后生效。',
torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}',
torrentOverallUploadLimit: 'Aria2 总上传限制',
torrentOverallUploadLimitDescription: '限制 Aria2 的总上传速度,在 Firelink 中主要用于活动 Torrent 做种。留空表示不限速;新值会立即应用,并在 Firelink 重启后恢复。',
+6
View File
@@ -23,6 +23,8 @@ 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 type { TorrentDetails } from './bindings/TorrentDetails';
import type { TorrentFileSelectionSnapshot } from './bindings/TorrentFileSelectionSnapshot';
type CommandMap = {
fetch_metadata: {
@@ -84,6 +86,10 @@ type CommandMap = {
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_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
set_torrent_file_selection: { args: { id: string; selected_indices: number[] | null }; result: TorrentFileSelectionSnapshot };
get_torrent_details: { args: { id: string }; result: TorrentDetails };
verify_torrent_data: { args: { id: string }; result: void };
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 };
+16 -6
View File
@@ -45,14 +45,14 @@ const startDownloadListeners = async () => {
// A sidecar can flush one last progress chunk after a pause, failure,
// completion, or lifecycle reset. Do not let that stale chunk repopulate
// the live progress map or overwrite a later lifecycle's first frame.
if (!['downloading', 'processing', 'seeding'].includes(current.status)) {
if (!['downloading', 'processing', 'verifying', 'seeding'].includes(current.status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
return;
}
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {};
if (current.status === 'downloading' || current.status === 'processing' || current.status === 'seeding') {
if (current.status === 'downloading' || current.status === 'processing' || current.status === 'verifying' || current.status === 'seeding') {
updates.fraction = payload.fraction;
updates.speed = current.status === 'seeding'
? payload.upload_speed ?? '-'
@@ -121,7 +121,7 @@ const startDownloadListeners = async () => {
return;
}
if (status === 'downloading' || status === 'processing' ||
status === 'seeding' || status === 'waitingToSeed' ||
status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' ||
status === 'completed' || status === 'failed') {
clearDownloadControlIntent(payload.id, 'resume');
}
@@ -173,7 +173,7 @@ const startDownloadListeners = async () => {
: {})
} : {}),
...(payload.error ? { lastError: payload.error } : {}),
...((status === 'downloading' || status === 'retrying')
...((status === 'downloading' || status === 'verifying' || status === 'retrying')
? { lastTry: new Date().toISOString() }
: {})
};
@@ -189,10 +189,20 @@ const startDownloadListeners = async () => {
updates.fileName = payload.fileName;
updates.category = categoryForFileName(payload.fileName);
}
if (status !== 'downloading') {
if (status !== 'downloading' && status !== 'verifying') {
updates.speed = '-';
updates.eta = '-';
}
if (
current.torrentVerifyOnly === true &&
['ready', 'staged', 'paused', 'completed', 'failed'].includes(status)
) {
// Verification is a maintenance lifecycle layered over the existing
// row. Clear its markers once Aria2 has reached the restored terminal
// state so restart cannot replay verification indefinitely.
updates.torrentVerifyOnly = undefined;
updates.torrentVerifyRestoreStatus = undefined;
}
mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding' || status === 'waitingToSeed') {
@@ -205,7 +215,7 @@ const startDownloadListeners = async () => {
: { pendingOrder: [...state.pendingOrder, payload.id] });
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
+27
View File
@@ -167,6 +167,33 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads[0].torrentRemoveUnselectedFile).toBe(false);
});
it('detaches a paused backend lifecycle even when the frontend registration set is stale', async () => {
useDownloadStore.setState({
downloads: [{
id: 'paused-stale-registration',
url: 'magnet:?xt=urn:btih:abc',
fileName: 'torrent',
status: 'paused',
category: 'Other',
dateAdded: '',
isTorrent: true,
torrentFileIndices: [1]
}] as any[],
backendRegisteredIds: new Set()
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().applyProperties('paused-stale-registration', {
torrentFileIndices: [2]
});
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'detach_download_for_reconfigure',
{ id: 'paused-stale-registration' }
);
expect(useDownloadStore.getState().downloads[0].torrentFileIndices).toEqual([2]);
});
it('replaces stale media intent when an appended handoff reuses a URL', () => {
useDownloadStore.getState().openAddModalWithUrls(
'https://example.com/file.bin', '', '', '', '', true
+37 -12
View File
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -362,6 +362,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
torrent_encryption_policy: item.torrentEncryptionPolicy || undefined,
torrent_file_allocation: item.torrentFileAllocation || undefined,
torrent_verify_only: item.torrentVerifyOnly,
torrent_verify_restore_status: item.torrentVerifyRestoreStatus,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -690,6 +693,16 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
: undefined;
const rawEncryptionPolicy = download.torrentEncryptionPolicy as unknown;
const normalizedEncryptionPolicy = normalizeTorrentEncryptionPolicy(rawEncryptionPolicy);
const rawFileAllocation = download.torrentFileAllocation as unknown;
const normalizedFileAllocation = normalizeTorrentFileAllocation(rawFileAllocation);
const rawVerifyOnly = download.torrentVerifyOnly as unknown;
const normalizedVerifyOnly = rawVerifyOnly === true ? true : undefined;
const rawVerifyRestoreStatus = download.torrentVerifyRestoreStatus as unknown;
const normalizedVerifyRestoreStatus = normalizedVerifyOnly === true
&& typeof rawVerifyRestoreStatus === 'string'
&& ['paused', 'failed', 'completed'].includes(rawVerifyRestoreStatus)
? rawVerifyRestoreStatus
: undefined;
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
rawWebSeeds !== normalizedWebSeeds ||
rawMaxPeers !== normalizedMaxPeers ||
@@ -703,7 +716,10 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawStopTimeout !== normalizedStopTimeout ||
rawPrioritizePiece !== normalizedPrioritizePiece ||
rawRemoveUnselectedFile !== normalizedRemoveUnselectedFile ||
rawEncryptionPolicy !== normalizedEncryptionPolicy
rawEncryptionPolicy !== normalizedEncryptionPolicy ||
rawFileAllocation !== normalizedFileAllocation ||
rawVerifyOnly !== normalizedVerifyOnly ||
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus
? {
...download,
torrentSeedRemaining: normalizedSeedRemaining,
@@ -719,7 +735,10 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizedPrioritizePiece,
torrentRemoveUnselectedFile: normalizedRemoveUnselectedFile,
torrentEncryptionPolicy: normalizedEncryptionPolicy
torrentEncryptionPolicy: normalizedEncryptionPolicy,
torrentFileAllocation: normalizedFileAllocation,
torrentVerifyOnly: normalizedVerifyOnly,
torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus
}
: download;
@@ -940,7 +959,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
&& normalizedUpdates.torrentRemoveUnselectedFile === false
&& item.torrentRemoveUnselectedFile !== false;
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'seeding' || item.status === 'retrying') {
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') {
throw new Error(i18n.t($ => $.downloadTable.transferActive));
}
@@ -974,15 +993,18 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
}
} else if (item.status === 'paused') {
if (isRegistered) {
try {
await invoke('detach_download_for_reconfigure', { id });
} catch (e) {
console.error("Failed to detach for reconfigure:", e);
throw e; // Preserve old properties if detach fails
}
state.unregisterBackendIds([id]);
// The frontend deliberately removes paused rows from
// backendRegisteredIds, but the backend keeps a paused Aria2 GID and
// its old payload alive for an in-place resume. Any property change,
// especially Torrent selection/output changes, must retire that
// lifecycle or resume will silently use stale daemon options.
try {
await invoke('detach_download_for_reconfigure', { id });
} catch (e) {
console.error("Failed to detach for reconfigure:", e);
throw e; // Preserve old properties if detach fails
}
if (isRegistered) state.unregisterBackendIds([id]);
if (disablingTorrentRemoval) {
await invoke('clear_torrent_removal_paths', { id });
}
@@ -2288,6 +2310,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
torrent_encryption_policy: item.torrentEncryptionPolicy || undefined,
torrent_file_allocation: item.torrentFileAllocation || undefined,
torrent_verify_only: item.torrentVerifyOnly,
torrent_verify_restore_status: item.torrentVerifyRestoreStatus,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
+25
View File
@@ -254,6 +254,7 @@ export interface SettingsState {
torrentDhtMessageTimeout: number;
torrentSeparateSeedSlots: boolean;
torrentMaxConcurrentSeeds: number;
torrentIpv6Enabled: boolean;
torrentListenPort: string;
torrentDhtListenPort: string;
torrentExternalIp: string;
@@ -263,6 +264,8 @@ export interface SettingsState {
torrentLpdInterface: string;
torrentPeerIdPrefix: string;
torrentPeerAgent: string;
torrentBindAddress: string;
aria2DiskCache: string;
customUserAgent: string;
askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean;
@@ -322,6 +325,7 @@ export interface SettingsState {
setTorrentDhtMessageTimeout: (value: number) => void;
setTorrentSeparateSeedSlots: (enabled: boolean) => void;
setTorrentMaxConcurrentSeeds: (value: number) => void;
setTorrentIpv6Enabled: (enabled: boolean) => void;
setTorrentListenPort: (value: string) => void;
setTorrentDhtListenPort: (value: string) => void;
setTorrentExternalIp: (value: string) => void;
@@ -331,6 +335,8 @@ export interface SettingsState {
setTorrentLpdInterface: (value: string) => void;
setTorrentPeerIdPrefix: (value: string) => void;
setTorrentPeerAgent: (value: string) => void;
setTorrentBindAddress: (value: string) => void;
setAria2DiskCache: (value: string) => void;
setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
@@ -416,6 +422,7 @@ export const useSettingsStore = create<SettingsState>()(
torrentDhtMessageTimeout: DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
torrentSeparateSeedSlots: false,
torrentMaxConcurrentSeeds: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
torrentIpv6Enabled: true,
torrentListenPort: '',
torrentDhtListenPort: '',
torrentExternalIp: '',
@@ -425,6 +432,8 @@ export const useSettingsStore = create<SettingsState>()(
torrentLpdInterface: '',
torrentPeerIdPrefix: '',
torrentPeerAgent: '',
torrentBindAddress: '',
aria2DiskCache: '16M',
customUserAgent: '',
askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true,
@@ -549,6 +558,8 @@ export const useSettingsStore = create<SettingsState>()(
setTorrentLpdInterface: (torrentLpdInterface) => set({ torrentLpdInterface }),
setTorrentPeerIdPrefix: (torrentPeerIdPrefix) => set({ torrentPeerIdPrefix }),
setTorrentPeerAgent: (torrentPeerAgent) => set({ torrentPeerAgent }),
setTorrentBindAddress: (torrentBindAddress) => set({ torrentBindAddress }),
setAria2DiskCache: (aria2DiskCache) => set({ aria2DiskCache }),
setTorrentMaxOpenFiles: (value) => {
const normalized = normalizeTorrentMaxOpenFiles(value);
if (normalized === undefined) {
@@ -578,6 +589,7 @@ export const useSettingsStore = create<SettingsState>()(
? value
: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
}),
setTorrentIpv6Enabled: (torrentIpv6Enabled) => set({ torrentIpv6Enabled }),
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
@@ -768,6 +780,7 @@ export const useSettingsStore = create<SettingsState>()(
torrentDhtMessageTimeout: state.torrentDhtMessageTimeout,
torrentSeparateSeedSlots: state.torrentSeparateSeedSlots,
torrentMaxConcurrentSeeds: state.torrentMaxConcurrentSeeds,
torrentIpv6Enabled: state.torrentIpv6Enabled,
torrentListenPort: state.torrentListenPort,
torrentDhtListenPort: state.torrentDhtListenPort,
torrentExternalIp: state.torrentExternalIp,
@@ -777,6 +790,8 @@ export const useSettingsStore = create<SettingsState>()(
torrentLpdInterface: state.torrentLpdInterface,
torrentPeerIdPrefix: state.torrentPeerIdPrefix,
torrentPeerAgent: state.torrentPeerAgent,
torrentBindAddress: state.torrentBindAddress,
aria2DiskCache: state.aria2DiskCache,
customUserAgent: state.customUserAgent,
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
@@ -840,6 +855,10 @@ export const useSettingsStore = create<SettingsState>()(
&& persisted.torrentMaxConcurrentSeeds <= 64
? persisted.torrentMaxConcurrentSeeds
: currentState.torrentMaxConcurrentSeeds,
torrentIpv6Enabled: persistedBoolean(
persisted.torrentIpv6Enabled,
currentState.torrentIpv6Enabled
),
torrentListenPort: typeof persisted.torrentListenPort === 'string'
? persisted.torrentListenPort
: currentState.torrentListenPort,
@@ -867,6 +886,12 @@ export const useSettingsStore = create<SettingsState>()(
torrentPeerAgent: typeof persisted.torrentPeerAgent === 'string'
? persisted.torrentPeerAgent
: currentState.torrentPeerAgent,
torrentBindAddress: typeof persisted.torrentBindAddress === 'string'
? persisted.torrentBindAddress
: currentState.torrentBindAddress,
aria2DiskCache: typeof persisted.aria2DiskCache === 'string'
? persisted.aria2DiskCache
: currentState.aria2DiskCache,
sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition)
? persisted.sidebarPosition
: currentState.sidebarPosition,
+2 -1
View File
@@ -20,7 +20,7 @@ describe('download action policy', () => {
expect(canStartDownload(status)).toBe(true);
expect(canPauseDownload(status)).toBe(false);
}
for (const status of ['staged', 'queued', 'downloading', 'seeding', 'processing', 'retrying'] as const) {
for (const status of ['staged', 'queued', 'downloading', 'seeding', 'processing', 'verifying', 'retrying'] as const) {
expect(canPauseDownload(status)).toBe(true);
}
for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) {
@@ -40,6 +40,7 @@ describe('download action policy', () => {
expect(getPauseResumeAction('queued')).toBe('pause');
expect(getPauseResumeAction('downloading')).toBe('pause');
expect(getPauseResumeAction('processing')).toBe('pause');
expect(getPauseResumeAction('verifying')).toBe('pause');
expect(getPauseResumeAction('seeding')).toBe('pause');
expect(getPauseResumeAction('retrying')).toBe('pause');
expect(getPauseResumeAction('paused')).toBe('resume');
+2 -1
View File
@@ -15,6 +15,7 @@ const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'seeding',
'waitingToSeed',
'processing',
'verifying',
'retrying',
]);
@@ -66,7 +67,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 === 'waitingToSeed' || status === 'retrying';
status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying';
export const isIdentityLocked = (status: DownloadStatus): boolean =>
isTransferLocked(status) || status === 'completed';
+2
View File
@@ -73,6 +73,8 @@ export const downloadProgressColorClass = (status: string): string => {
return 'download-status-failed';
case 'processing':
return 'download-status-processing';
case 'verifying':
return 'download-status-processing';
case 'seeding':
return 'download-status-seeding';
case 'queued':
+1
View File
@@ -22,6 +22,7 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'downloading' ||
status === 'seeding' ||
status === 'processing' ||
status === 'verifying' ||
status === 'retrying';
const hasPositiveProgress = (download: DownloadItem): boolean =>
+8
View File
@@ -56,6 +56,14 @@ describe('download persistence progress snapshots', () => {
expect(persisted.totalIsEstimate).toBe(false);
}
);
it('does not persist verification byte counters across restart', () => {
const persisted = redactDownloadForPersistence(item('verifying'));
expect(persisted.downloadedBytes).toBeUndefined();
expect(persisted.totalBytes).toBeUndefined();
expect(persisted.totalIsEstimate).toBeUndefined();
});
});
describe('Torrent tracker input validation', () => {
+8 -1
View File
@@ -30,6 +30,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'queued',
'downloading',
'processing',
'verifying',
'seeding',
'waitingToSeed',
'retrying',
@@ -40,7 +41,7 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
/** Transfer states that consume a worker/permit. Queued is intentionally excluded. */
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying';
status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'retrying';
export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16;
@@ -66,6 +67,11 @@ export const normalizeTorrentEncryptionPolicy = (
return undefined;
};
export type TorrentFileAllocation = 'prealloc' | 'none';
export const normalizeTorrentFileAllocation = (value: unknown): TorrentFileAllocation | undefined =>
value === 'prealloc' || value === 'none' ? value : undefined;
export const MAX_TORRENT_TRACKER_TIMEOUT = 604800;
export const MAX_TORRENT_TRACKER_INTERVAL = 604800;
export const DEFAULT_TORRENT_MAX_OPEN_FILES = 100;
@@ -456,6 +462,7 @@ export const isMediaUrl = (rawUrl: string): boolean => {
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
const VOLATILE_PROGRESS_STATUSES = new Set([
'downloading',
'verifying',
'seeding'
]);