mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 09:53:17 +00:00
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:
@@ -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,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,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";
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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>, };
|
||||
@@ -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, };
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 Aria2’s default range.',
|
||||
torrentDhtListenPort: 'UDP/DHT ports',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 端口',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user