mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 09:53:17 +00:00
feat(torrent): complete lifecycle controls and diagnostics
- add durable Torrent telemetry, availability, sharing, relocation, and web-seed workflows\n- fence queue ownership, lifecycle recovery, persistence, and native control races\n- add localized UI, generated bindings, regression coverage, and smoke validation
This commit is contained in:
@@ -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, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: 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, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: 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,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 DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, };
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, torrent_seeded_seconds?: 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" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying";
|
||||
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying" | "moving";
|
||||
|
||||
@@ -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 TorrentAvailabilityBucket = { minimumCopies: 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 { TorrentAvailabilityBucket } from "./TorrentAvailabilityBucket";
|
||||
|
||||
export type TorrentAvailabilitySnapshot = { pieceCount: number, availability: number, connectedPeers: number, buckets: Array<TorrentAvailabilityBucket>, };
|
||||
@@ -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 TorrentMoveProgressEvent = { id: string, fraction: number, copiedBytes: number, totalBytes: number, };
|
||||
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
expandTilde,
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type MediaSelection
|
||||
} from '../utils/addDownloadMetadata';
|
||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const k = 1024;
|
||||
@@ -241,7 +242,17 @@ export const AddDownloadsModal = () => {
|
||||
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
|
||||
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
|
||||
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
||||
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
|
||||
const [torrentFileAllocation, setTorrentFileAllocation] = useState<TorrentFileAllocation>('prealloc');
|
||||
const [torrentPreviewHeadEnabled, setTorrentPreviewHeadEnabled] = useState(false);
|
||||
const [torrentPreviewHeadSize, setTorrentPreviewHeadSize] = useState('1M');
|
||||
const [torrentPreviewTailEnabled, setTorrentPreviewTailEnabled] = useState(false);
|
||||
const [torrentPreviewTailSize, setTorrentPreviewTailSize] = useState('1M');
|
||||
const torrentPreviewPriority = serializeTorrentPreviewPriority(
|
||||
torrentPreviewHeadEnabled,
|
||||
torrentPreviewHeadSize,
|
||||
torrentPreviewTailEnabled,
|
||||
torrentPreviewTailSize
|
||||
);
|
||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||
const freeSpaceRequestRef = useRef(0);
|
||||
|
||||
@@ -389,6 +400,11 @@ export const AddDownloadsModal = () => {
|
||||
setTorrentTrackerTimeout('');
|
||||
setTorrentTrackerInterval('0');
|
||||
setTorrentStopTimeout('0');
|
||||
setTorrentFileAllocation('prealloc');
|
||||
setTorrentPreviewHeadEnabled(false);
|
||||
setTorrentPreviewHeadSize('1M');
|
||||
setTorrentPreviewTailEnabled(false);
|
||||
setTorrentPreviewTailSize('1M');
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
@@ -1005,10 +1021,18 @@ export const AddDownloadsModal = () => {
|
||||
addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
|
||||
if (hasSelectedTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPreviewPriority) {
|
||||
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
for (const item of selectedItems) {
|
||||
if (!item.isTorrent || !item.torrentFiles?.length) continue;
|
||||
const rows = item.torrentWebSeedRows ?? [];
|
||||
if (!normalizeTorrentWebSeedDrafts(rows, item.torrentFiles)) {
|
||||
addToast({ message: t($ => $.properties.torrentWebSeedsFailed), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
hasSelectedTorrent
|
||||
&& torrentStopTimeout.trim()
|
||||
@@ -1529,7 +1553,11 @@ export const AddDownloadsModal = () => {
|
||||
? Number(torrentTrackerInterval)
|
||||
: undefined,
|
||||
torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined,
|
||||
torrentPrioritizePiece: item.isTorrent ? normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined : undefined,
|
||||
torrentPrioritizePiece: item.isTorrent ? torrentPreviewPriority || undefined : undefined,
|
||||
torrentFileAllocation: item.isTorrent ? torrentFileAllocation : undefined,
|
||||
torrentWebSeeds: item.isTorrent && item.torrentFiles
|
||||
? normalizeTorrentWebSeedDrafts(item.torrentWebSeedRows ?? [], item.torrentFiles) || undefined
|
||||
: undefined,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||
sizeBytes: item.sizeBytes
|
||||
}, action);
|
||||
@@ -2112,6 +2140,24 @@ export const AddDownloadsModal = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
|
||||
<section className="add-download-section relative overflow-hidden p-4">
|
||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||
<Link size={16} className="text-blue-500" /> {t($ => $.properties.torrentWebSeeds)}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted mb-3">{t($ => $.properties.torrentWebSeedsHint)}</p>
|
||||
<TorrentWebSeedEditor
|
||||
files={parsedItems[selectedItemIndex].torrentFiles ?? []}
|
||||
rows={parsedItems[selectedItemIndex].torrentWebSeedRows ?? []}
|
||||
onChange={rows => setParsedItems(items => items.map((item, index) => index === selectedItemIndex
|
||||
? { ...item, torrentWebSeedRows: rows }
|
||||
: item
|
||||
))}
|
||||
idPrefix="add-torrent-web-seed"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
|
||||
<section className="add-download-section relative overflow-hidden p-4">
|
||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||
@@ -2195,6 +2241,64 @@ export const AddDownloadsModal = () => {
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
|
||||
<label htmlFor="torrent-file-allocation" className="text-text-muted">
|
||||
{t($ => $.properties.torrentFileAllocation)}
|
||||
</label>
|
||||
<select
|
||||
id="torrent-file-allocation"
|
||||
value={torrentFileAllocation}
|
||||
onChange={event => setTorrentFileAllocation(event.currentTarget.value as TorrentFileAllocation)}
|
||||
aria-describedby="torrent-file-allocation-hint"
|
||||
className="app-control max-w-56 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option>
|
||||
<option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option>
|
||||
</select>
|
||||
<p id="torrent-file-allocation-hint" className="col-span-2 text-[10px] text-text-muted">
|
||||
{t($ => $.properties.torrentFileAllocationHint)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 pt-2 border-t border-border-modal/50">
|
||||
<span className="block text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}</span>
|
||||
<label className="flex items-center gap-2 text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={torrentPreviewHeadEnabled}
|
||||
onChange={event => setTorrentPreviewHeadEnabled(event.target.checked)}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
{t($ => $.properties.torrentPrioritizePieceHead)}
|
||||
<input
|
||||
type="text"
|
||||
value={torrentPreviewHeadSize}
|
||||
onChange={event => setTorrentPreviewHeadSize(event.currentTarget.value)}
|
||||
disabled={!torrentPreviewHeadEnabled}
|
||||
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
|
||||
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={torrentPreviewTailEnabled}
|
||||
onChange={event => setTorrentPreviewTailEnabled(event.target.checked)}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
{t($ => $.properties.torrentPrioritizePieceTail)}
|
||||
<input
|
||||
type="text"
|
||||
value={torrentPreviewTailSize}
|
||||
onChange={event => setTorrentPreviewTailSize(event.currentTarget.value)}
|
||||
disabled={!torrentPreviewTailEnabled}
|
||||
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
|
||||
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
<p id="torrent-prioritize-piece-hint" className="text-[10px] text-text-muted">
|
||||
{t($ => $.properties.torrentPrioritizePieceHint)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
|
||||
<label htmlFor="torrent-encryption-policy" className="text-text-muted">
|
||||
{t($ => $.addDownloads.torrentEncryptionPolicy)}
|
||||
@@ -2382,23 +2486,6 @@ export const AddDownloadsModal = () => {
|
||||
{t($ => $.addDownloads.torrentStopTimeoutHint)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-border-modal/50">
|
||||
<label htmlFor="torrent-prioritize-piece" className="block text-text-muted">
|
||||
{t($ => $.addDownloads.torrentPrioritizePiece)}
|
||||
</label>
|
||||
<input
|
||||
id="torrent-prioritize-piece"
|
||||
type="text"
|
||||
value={torrentPrioritizePiece}
|
||||
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
|
||||
placeholder="head=1M,tail=1M"
|
||||
aria-describedby="torrent-prioritize-piece-hint"
|
||||
className="app-control mt-1 w-full px-2.5 py-1.5 text-xs font-mono"
|
||||
/>
|
||||
<p id="torrent-prioritize-piece-hint" className="mt-1 text-[10px] text-text-muted">
|
||||
{t($ => $.addDownloads.torrentPrioritizePieceHint)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
import {
|
||||
downloadProgressColorClass,
|
||||
formatTorrentDuration,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
@@ -72,6 +73,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const { t, i18n } = useTranslation();
|
||||
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||
const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]);
|
||||
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isRowHovered, setIsRowHovered] = React.useState(false);
|
||||
const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false);
|
||||
@@ -178,7 +180,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
};
|
||||
}, [isActionVisible, updateActionPosition]);
|
||||
|
||||
const displayFraction = download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
|
||||
const displayFraction = download.status === 'moving'
|
||||
? moveProgress ?? download.fraction ?? 0
|
||||
: download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
|
||||
? liveProgress?.fraction ?? download.fraction ?? 0
|
||||
: download.fraction ?? 0;
|
||||
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
||||
@@ -190,7 +194,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
? t($ => $.downloads.values.processing)
|
||||
: '-';
|
||||
const displayEta = download.status === 'seeding'
|
||||
? '-'
|
||||
? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0
|
||||
? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language)
|
||||
: '-'
|
||||
: download.status === 'downloading' || download.status === 'verifying'
|
||||
? liveProgress?.eta ?? download.eta
|
||||
: download.status === 'processing'
|
||||
@@ -298,6 +304,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download.status === 'seeding' ? 'seeding' :
|
||||
download.status === 'processing' ? 'processing' :
|
||||
download.status === 'verifying' ? 'processing' :
|
||||
download.status === 'moving' ? 'processing' :
|
||||
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
||||
download.status === 'retrying' ? 'retrying' : ''
|
||||
}`}
|
||||
@@ -322,6 +329,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download.status === 'failed' ? 'download-status-failed' :
|
||||
download.status === 'processing' ? 'download-status-processing' :
|
||||
download.status === 'verifying' ? 'download-status-processing' :
|
||||
download.status === 'moving' ? 'download-status-processing' :
|
||||
download.status === 'downloading' ? 'download-status-downloading' :
|
||||
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
|
||||
download.status === 'retrying' ? 'download-status-retrying' : ''
|
||||
@@ -334,7 +342,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
{downloadStatusLabel} #{queueIndex + 1}
|
||||
</span>
|
||||
</>
|
||||
) : download.status === 'downloading' || download.status === 'verifying' ? (
|
||||
) : download.status === 'downloading' || download.status === 'verifying' || download.status === 'moving' ? (
|
||||
displayPercent
|
||||
) : download.status === 'seeding' ? (
|
||||
displayPercent
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||
import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary';
|
||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
sortDownloads,
|
||||
@@ -2552,6 +2553,23 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
{t($ => $.downloadTable.copyAddress)}
|
||||
</button>
|
||||
|
||||
{contextItem.isTorrent && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setContextMenu(null);
|
||||
try {
|
||||
const magnet = await invoke('get_torrent_magnet_link', { id: contextItem.id });
|
||||
await writeClipboardText(magnet);
|
||||
} catch (error) {
|
||||
showInteractionError(t($ => $.downloadTable.copyMagnetFailed), error);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
{t($ => $.downloadTable.copyMagnet)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{contextItem.status === 'completed' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
|
||||
@@ -8,10 +8,11 @@ import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgres
|
||||
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 type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
|
||||
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';
|
||||
import { open, save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||
import {
|
||||
getPauseResumeAction,
|
||||
@@ -22,12 +23,15 @@ import {
|
||||
downloadProgressColorClass,
|
||||
formatDownloadBytes,
|
||||
formatDownloadTotal,
|
||||
formatTorrentDuration,
|
||||
formatTorrentRatio,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
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 { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, parseTorrentPreviewPriority, serializeTorrentPreviewPriority, torrentWebSeedDraftsFromSeeds, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation, type TorrentWebSeedDraft } from '../utils/downloads';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
@@ -50,6 +54,9 @@ const isPeerDiagnosticsStatus = (status: string): boolean =>
|
||||
const isTorrentFileProgressStatus = (status: string): boolean =>
|
||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
|
||||
|
||||
const isTorrentAvailabilityStatus = (status: string): boolean =>
|
||||
['downloading', 'verifying', 'seeding', 'retrying', 'paused'].includes(status);
|
||||
|
||||
const formatPeerSpeed = (bytesPerSecond: number): string =>
|
||||
`${formatDownloadBytes(bytesPerSecond)}/s`;
|
||||
|
||||
@@ -78,6 +85,9 @@ export const PropertiesModal = () => {
|
||||
? state.progressMap[selectedPropertiesDownloadId]
|
||||
: undefined
|
||||
));
|
||||
const moveProgress = useDownloadProgressStore(state =>
|
||||
selectedPropertiesDownloadId ? state.moveProgressMap[selectedPropertiesDownloadId] : undefined
|
||||
);
|
||||
|
||||
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
|
||||
|
||||
@@ -104,7 +114,10 @@ export const PropertiesModal = () => {
|
||||
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
|
||||
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
|
||||
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
||||
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
|
||||
const [torrentPreviewHeadEnabled, setTorrentPreviewHeadEnabled] = useState(false);
|
||||
const [torrentPreviewHeadSize, setTorrentPreviewHeadSize] = useState('1M');
|
||||
const [torrentPreviewTailEnabled, setTorrentPreviewTailEnabled] = useState(false);
|
||||
const [torrentPreviewTailSize, setTorrentPreviewTailSize] = useState('1M');
|
||||
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
|
||||
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
|
||||
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
|
||||
@@ -119,10 +132,15 @@ export const PropertiesModal = () => {
|
||||
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
|
||||
const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false);
|
||||
const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = useState(false);
|
||||
const [torrentAvailability, setTorrentAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
||||
const [torrentAvailabilityError, setTorrentAvailabilityError] = useState(false);
|
||||
const [isTorrentAvailabilityPending, setIsTorrentAvailabilityPending] = useState(false);
|
||||
const [torrentShareMessage, setTorrentShareMessage] = useState('');
|
||||
const [isTorrentMovePending, setIsTorrentMovePending] = useState(false);
|
||||
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
|
||||
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
|
||||
const [torrentWebSeedsText, setTorrentWebSeedsText] = useState('');
|
||||
const [torrentWebSeedRows, setTorrentWebSeedRows] = useState<TorrentWebSeedDraft[]>([]);
|
||||
const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false);
|
||||
const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false);
|
||||
|
||||
@@ -149,6 +167,7 @@ export const PropertiesModal = () => {
|
||||
const torrentFileSelectionRequestRef = useRef(0);
|
||||
const torrentDetailsRequestRef = useRef(0);
|
||||
const torrentPieceProgressRequestRef = useRef(0);
|
||||
const torrentAvailabilityRequestRef = useRef(0);
|
||||
const torrentWebSeedsRequestRef = useRef(0);
|
||||
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
|
||||
|
||||
@@ -175,9 +194,16 @@ export const PropertiesModal = () => {
|
||||
setTorrentPieceProgress(null);
|
||||
setTorrentPieceProgressError(false);
|
||||
setIsTorrentPieceProgressPending(false);
|
||||
torrentAvailabilityRequestRef.current += 1;
|
||||
setTorrentAvailability(null);
|
||||
setTorrentAvailabilityError(false);
|
||||
setIsTorrentAvailabilityPending(false);
|
||||
setTorrentShareMessage('');
|
||||
setIsTorrentMovePending(false);
|
||||
torrentWebSeedsRequestRef.current += 1;
|
||||
setTorrentWebSeedsError(false);
|
||||
setIsTorrentWebSeedsPending(false);
|
||||
setTorrentWebSeedRows([]);
|
||||
}, [selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -251,10 +277,12 @@ export const PropertiesModal = () => {
|
||||
setTorrentTrackerTimeout(activeItem.torrentTrackerTimeout === undefined ? '' : String(activeItem.torrentTrackerTimeout));
|
||||
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'));
|
||||
const previewPriority = parseTorrentPreviewPriority(activeItem.torrentPrioritizePiece);
|
||||
setTorrentPreviewHeadEnabled(Boolean(previewPriority.head));
|
||||
setTorrentPreviewHeadSize(previewPriority.head || '1M');
|
||||
setTorrentPreviewTailEnabled(Boolean(previewPriority.tail));
|
||||
setTorrentPreviewTailSize(previewPriority.tail || '1M');
|
||||
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(activeItem.torrentWebSeeds));
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
@@ -389,7 +417,7 @@ export const PropertiesModal = () => {
|
||||
requestId === torrentWebSeedsRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
) {
|
||||
setTorrentWebSeedsText(seeds.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
|
||||
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(seeds));
|
||||
useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: seeds });
|
||||
}
|
||||
})
|
||||
@@ -611,26 +639,126 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshTorrentAvailability = async () => {
|
||||
if (
|
||||
isTorrentAvailabilityPending
|
||||
|| !item.isTorrent
|
||||
|| !isTorrentAvailabilityStatus(item.status)
|
||||
) return;
|
||||
const requestId = ++torrentAvailabilityRequestRef.current;
|
||||
const propertiesDownloadId = item.id;
|
||||
setIsTorrentAvailabilityPending(true);
|
||||
setTorrentAvailabilityError(false);
|
||||
try {
|
||||
const snapshot = await invoke('get_torrent_availability', { id: propertiesDownloadId });
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
|
||||
if (
|
||||
requestId === torrentAvailabilityRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
&& currentItem?.isTorrent
|
||||
&& isTorrentAvailabilityStatus(currentItem.status)
|
||||
) {
|
||||
setTorrentAvailability(snapshot);
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
requestId === torrentAvailabilityRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
) {
|
||||
setTorrentAvailabilityError(true);
|
||||
setTorrentAvailability(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === torrentAvailabilityRequestRef.current) setIsTorrentAvailabilityPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyTorrentMagnet = async () => {
|
||||
if (!item.isTorrent) return;
|
||||
setTorrentShareMessage('');
|
||||
try {
|
||||
const magnet = await invoke('get_torrent_magnet_link', { id: item.id });
|
||||
await writeClipboardText(magnet);
|
||||
setTorrentShareMessage(t($ => $.properties.torrentMagnetCopied));
|
||||
} catch {
|
||||
setTorrentShareMessage(t($ => $.properties.torrentMagnetCopyFailed));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportTorrentMetadata = async () => {
|
||||
if (!item.isTorrent) return;
|
||||
setTorrentShareMessage('');
|
||||
try {
|
||||
const destination = await save({
|
||||
defaultPath: `${item.fileName.replace(/\.torrent$/i, '')}.torrent`,
|
||||
filters: [{ name: 'Torrent metadata', extensions: ['torrent'] }]
|
||||
});
|
||||
if (!destination) return;
|
||||
await invoke('export_torrent_metadata', { id: item.id, destination });
|
||||
setTorrentShareMessage(t($ => $.properties.torrentMetadataExported));
|
||||
} catch {
|
||||
setTorrentShareMessage(t($ => $.properties.torrentMetadataExportFailed));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveTorrentData = async () => {
|
||||
if (!item.isTorrent || isTorrentMovePending || !['paused', 'completed', 'failed'].includes(item.status)) return;
|
||||
const propertiesDownloadId = item.id;
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
|
||||
});
|
||||
if (!selected || typeof selected !== 'string') return;
|
||||
if (!window.confirm(t($ => $.properties.torrentMoveConfirm))) return;
|
||||
setIsTorrentMovePending(true);
|
||||
setTorrentShareMessage('');
|
||||
try {
|
||||
await invoke('move_torrent_data', { id: propertiesDownloadId, destination: selected });
|
||||
useDownloadStore.getState().updateDownload(propertiesDownloadId, {
|
||||
destination: selected,
|
||||
torrentRelocationCheckPending: item.torrentRelocationCheckPending === true
|
||||
|| item.status === 'paused'
|
||||
|| item.status === 'failed'
|
||||
? true
|
||||
: undefined
|
||||
});
|
||||
setSaveLocation(selected);
|
||||
setTorrentShareMessage(t($ => $.properties.torrentMoveCompleted));
|
||||
} catch {
|
||||
setTorrentShareMessage(t($ => $.properties.torrentMoveFailed));
|
||||
} finally {
|
||||
setIsTorrentMovePending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelTorrentMove = async () => {
|
||||
if (!item?.isTorrent || !isTorrentMovePending) return;
|
||||
try {
|
||||
await invoke('cancel_torrent_move_data', { id: item.id });
|
||||
setTorrentShareMessage(t($ => $.properties.torrentMoveCancelRequested));
|
||||
} catch {
|
||||
setTorrentShareMessage(t($ => $.properties.torrentMoveFailed));
|
||||
}
|
||||
};
|
||||
|
||||
const torrentWebSeedFiles = (torrentFileSelection?.files ?? torrentFileProgress?.files ?? [])
|
||||
.map(file => ({ index: file.index, relativePath: file.relativePath }));
|
||||
const torrentWebSeedsMetadataPending = torrentWebSeedRows.length > 0 && torrentWebSeedFiles.length === 0;
|
||||
|
||||
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 });
|
||||
const files = torrentWebSeedFiles.map(file => ({ index: file.index }));
|
||||
const seeds = normalizeTorrentWebSeedDrafts(torrentWebSeedRows, files);
|
||||
if (!seeds) {
|
||||
setTorrentWebSeedsError(true);
|
||||
return;
|
||||
}
|
||||
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'));
|
||||
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(normalized));
|
||||
useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized });
|
||||
} catch {
|
||||
setTorrentWebSeedsError(true);
|
||||
@@ -711,7 +839,13 @@ export const PropertiesModal = () => {
|
||||
setErrorMessage(t($ => $.properties.torrentTrackerIntervalInvalid));
|
||||
return;
|
||||
}
|
||||
if (item.isTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
|
||||
const torrentPrioritizePiece = serializeTorrentPreviewPriority(
|
||||
torrentPreviewHeadEnabled,
|
||||
torrentPreviewHeadSize,
|
||||
torrentPreviewTailEnabled,
|
||||
torrentPreviewTailSize
|
||||
);
|
||||
if (item.isTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPrioritizePiece) {
|
||||
setErrorMessage(t($ => $.properties.torrentPrioritizePieceInvalid));
|
||||
return;
|
||||
}
|
||||
@@ -786,7 +920,7 @@ export const PropertiesModal = () => {
|
||||
? Number(torrentTrackerInterval)
|
||||
: undefined,
|
||||
torrentStopTimeout: normalizedStopTimeout,
|
||||
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
|
||||
torrentPrioritizePiece: torrentPrioritizePiece || undefined,
|
||||
torrentFileIndices: torrentFileSelection
|
||||
? (allTorrentFilesSelected ? undefined : selectedTorrentIndices)
|
||||
: item.torrentFileIndices,
|
||||
@@ -1018,6 +1152,20 @@ export const PropertiesModal = () => {
|
||||
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||
})();
|
||||
const torrentUploadedBytes = item.isTorrent
|
||||
? liveProgress?.uploaded_bytes ?? item.torrentUploadedBytes ?? 0
|
||||
: 0;
|
||||
const torrentSeededSeconds = item.isTorrent
|
||||
? liveProgress?.torrent_seeded_seconds ?? item.torrentSeededSeconds ?? 0
|
||||
: 0;
|
||||
const torrentRatioDenominator = item.isTorrent
|
||||
? torrentFileSelection?.files.length
|
||||
? torrentFileSelection.files.reduce((total, file) => total + (file.selected ? file.length : 0), 0)
|
||||
: torrentDetails?.totalBytes ?? item.totalBytes ?? 0
|
||||
: 0;
|
||||
const torrentRatio = formatTorrentRatio(torrentUploadedBytes, torrentRatioDenominator, i18n.language);
|
||||
const torrentSeederCount = liveProgress?.num_seeders ?? torrentPeerDiagnostics?.totalSeeders;
|
||||
const torrentConnectedPeerCount = torrentPeerDiagnostics?.totalPeers;
|
||||
const statusLabel = t($ => $.downloads.status[item.status]);
|
||||
const pauseResumeAction = getPauseResumeAction(item.status);
|
||||
const pauseResumeLabel = pauseResumeAction === 'pause'
|
||||
@@ -1040,7 +1188,7 @@ export const PropertiesModal = () => {
|
||||
let StatusIcon = Info;
|
||||
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
|
||||
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 === 'processing' || item.status === 'moving') { 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; }
|
||||
|
||||
@@ -1113,6 +1261,19 @@ export const PropertiesModal = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.isTorrent && (
|
||||
<div className="mt-3 rounded-lg border border-border-modal/70 bg-bg-input/20 p-2.5" aria-label={t($ => $.properties.torrentStatistics)}>
|
||||
<div className="mb-2 text-[11px] font-semibold text-text-primary">{t($ => $.properties.torrentStatistics)}</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px] sm:grid-cols-3">
|
||||
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentUploaded)}</span><span className="truncate text-text-secondary">{formatDownloadBytes(torrentUploadedBytes)}</span></div>
|
||||
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentRatio)}</span><span className="truncate text-text-secondary">{torrentRatio}</span></div>
|
||||
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentSeededDuration)}</span><span className="truncate text-text-secondary">{formatTorrentDuration(torrentSeededSeconds, i18n.language)}</span></div>
|
||||
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentConnectedPeers)}</span><span className="truncate text-text-secondary">{torrentConnectedPeerCount ?? '—'}</span></div>
|
||||
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentSeeders)}</span><span className="truncate text-text-secondary">{torrentSeederCount ?? '—'}</span></div>
|
||||
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentUploadSpeed)}</span><span className="truncate text-text-secondary">{liveProgress?.upload_speed ?? '—'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
|
||||
@@ -1347,6 +1508,54 @@ export const PropertiesModal = () => {
|
||||
</>
|
||||
)}
|
||||
</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.torrentAvailability)}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefreshTorrentAvailability()}
|
||||
disabled={!isTorrentAvailabilityStatus(item.status) || isTorrentAvailabilityPending}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{isTorrentAvailabilityPending
|
||||
? t($ => $.properties.torrentAvailabilityLoading)
|
||||
: t($ => $.properties.torrentAvailabilityRefresh)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentAvailabilityHint)}</p>
|
||||
{!isTorrentAvailabilityStatus(item.status) && (
|
||||
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentAvailabilityUnavailable)}</p>
|
||||
)}
|
||||
{torrentAvailabilityError && (
|
||||
<p className="text-[11px] text-red-400">{t($ => $.properties.torrentAvailabilityFailed)}</p>
|
||||
)}
|
||||
{torrentAvailability && (
|
||||
<>
|
||||
<div className="text-[11px] text-text-secondary">
|
||||
{t($ => $.properties.torrentAvailabilitySummary, {
|
||||
availability: new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 2 }).format(torrentAvailability.availability),
|
||||
peers: torrentAvailability.connectedPeers,
|
||||
pieces: torrentAvailability.pieceCount
|
||||
})}
|
||||
</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, torrentAvailability.buckets.length))}, minmax(0, 1fr))` }}
|
||||
role="img"
|
||||
aria-label={t($ => $.properties.torrentAvailabilityMap)}
|
||||
>
|
||||
{torrentAvailability.buckets.map((bucket, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="aspect-square min-w-1 rounded-sm bg-emerald-500 motion-safe:transition-opacity motion-reduce:transition-none"
|
||||
style={{ opacity: Math.min(1, 0.2 + bucket.minimumCopies / Math.max(1, torrentAvailability.availability + 1)) }}
|
||||
title={t($ => $.properties.torrentAvailabilityBucket, { copies: bucket.minimumCopies })}
|
||||
/>
|
||||
))}
|
||||
</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">
|
||||
@@ -1610,21 +1819,45 @@ export const PropertiesModal = () => {
|
||||
{t($ => $.properties.torrentStopTimeoutHint)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="text-xs text-text-muted text-right" htmlFor="torrent-prioritize-piece-properties">
|
||||
{t($ => $.properties.torrentPrioritizePiece)}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
id="torrent-prioritize-piece-properties"
|
||||
type="text"
|
||||
value={torrentPrioritizePiece}
|
||||
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
|
||||
placeholder="head=1M,tail=1M"
|
||||
disabled={transferLocked}
|
||||
aria-describedby="torrent-prioritize-piece-properties-hint"
|
||||
className="app-control w-full px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
<p id="torrent-prioritize-piece-properties-hint" className="mt-1 text-[11px] text-text-muted">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<span className="block text-xs text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}</span>
|
||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={torrentPreviewHeadEnabled}
|
||||
onChange={event => setTorrentPreviewHeadEnabled(event.target.checked)}
|
||||
disabled={transferLocked}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
{t($ => $.properties.torrentPrioritizePieceHead)}
|
||||
<input
|
||||
type="text"
|
||||
value={torrentPreviewHeadSize}
|
||||
onChange={event => setTorrentPreviewHeadSize(event.currentTarget.value)}
|
||||
disabled={transferLocked || !torrentPreviewHeadEnabled}
|
||||
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
|
||||
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={torrentPreviewTailEnabled}
|
||||
onChange={event => setTorrentPreviewTailEnabled(event.target.checked)}
|
||||
disabled={transferLocked}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
{t($ => $.properties.torrentPrioritizePieceTail)}
|
||||
<input
|
||||
type="text"
|
||||
value={torrentPreviewTailSize}
|
||||
onChange={event => setTorrentPreviewTailSize(event.currentTarget.value)}
|
||||
disabled={transferLocked || !torrentPreviewTailEnabled}
|
||||
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
|
||||
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPrioritizePieceHint)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1904,6 +2137,29 @@ export const PropertiesModal = () => {
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
|
||||
{t($ => $.properties.torrentDetails)}
|
||||
</h3>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={() => void handleCopyTorrentMagnet()} className="app-button px-3 text-xs">
|
||||
{t($ => $.properties.torrentCopyMagnet)}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleExportTorrentMetadata()} className="app-button px-3 text-xs">
|
||||
{t($ => $.properties.torrentExportMetadata)}
|
||||
</button>
|
||||
{(isTorrentMovePending || ['paused', 'completed', 'failed'].includes(item.status)) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void (isTorrentMovePending ? handleCancelTorrentMove() : handleMoveTorrentData())}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{isTorrentMovePending ? t($ => $.properties.torrentMoveCancel) : t($ => $.properties.torrentMove)}
|
||||
</button>
|
||||
)}
|
||||
{isTorrentMovePending && moveProgress !== undefined && (
|
||||
<span className="text-[11px] text-text-secondary tabular-nums" role="status">
|
||||
{Math.round(moveProgress * 100)}%
|
||||
</span>
|
||||
)}
|
||||
{torrentShareMessage && <span className="text-[11px] text-text-secondary" role="status">{torrentShareMessage}</span>}
|
||||
</div>
|
||||
{isTorrentDetailsPending && (
|
||||
<p className="text-xs text-text-muted">{t($ => $.properties.torrentDetailsLoading)}</p>
|
||||
)}
|
||||
@@ -1972,13 +2228,12 @@ export const PropertiesModal = () => {
|
||||
{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)}
|
||||
<TorrentWebSeedEditor
|
||||
files={torrentWebSeedFiles}
|
||||
rows={torrentWebSeedRows}
|
||||
onChange={setTorrentWebSeedRows}
|
||||
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"
|
||||
idPrefix="properties-torrent-web-seed"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2 gap-2">
|
||||
<span className="text-xs text-red-500">
|
||||
@@ -1987,7 +2242,7 @@ export const PropertiesModal = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTorrentWebSeedsSave()}
|
||||
disabled={isTorrentWebSeedsPending}
|
||||
disabled={isTorrentWebSeedsPending || torrentWebSeedsMetadataPending}
|
||||
className="app-button px-3 text-xs"
|
||||
>
|
||||
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { TorrentFile } from '../bindings/TorrentFile';
|
||||
import type { TorrentFileSelectionEntry } from '../bindings/TorrentFileSelectionEntry';
|
||||
import { normalizeTorrentWebSeedDrafts, type TorrentWebSeedDraft } from '../utils/downloads';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type TorrentWebSeedFile = Pick<TorrentFile, 'index' | 'path'> | Pick<TorrentFileSelectionEntry, 'index' | 'relativePath'>;
|
||||
|
||||
type Props = {
|
||||
files: readonly TorrentWebSeedFile[];
|
||||
rows: readonly TorrentWebSeedDraft[];
|
||||
onChange: (rows: TorrentWebSeedDraft[]) => void;
|
||||
disabled?: boolean;
|
||||
idPrefix: string;
|
||||
};
|
||||
|
||||
const filePath = (file: TorrentWebSeedFile): string => 'path' in file ? file.path : file.relativePath;
|
||||
|
||||
export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false, idPrefix }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const uriRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const focusAfterRemoveRef = useRef<number | null>(null);
|
||||
const filesForValidation = files.map(file => ({ index: file.index }));
|
||||
const rowsAreValid = normalizeTorrentWebSeedDrafts(rows, filesForValidation) !== null;
|
||||
useEffect(() => {
|
||||
const rowIndex = focusAfterRemoveRef.current;
|
||||
focusAfterRemoveRef.current = null;
|
||||
if (rowIndex !== null) uriRefs.current[rowIndex]?.focus();
|
||||
}, [rows]);
|
||||
const addRow = () => onChange([...rows, { fileIndex: files[0]?.index ?? null, uri: '' }]);
|
||||
const updateRow = (rowIndex: number, update: Partial<TorrentWebSeedDraft>) => onChange(
|
||||
rows.map((row, index) => index === rowIndex ? { ...row, ...update } : row)
|
||||
);
|
||||
const removeRow = (rowIndex: number) => {
|
||||
const nextRows = rows.filter((_, index) => index !== rowIndex);
|
||||
focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : null;
|
||||
onChange(nextRows);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rows.length === 0 && (
|
||||
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentWebSeedsEmpty)}</p>
|
||||
)}
|
||||
{rows.map((row, rowIndex) => {
|
||||
const rowId = `${idPrefix}-${rowIndex}`;
|
||||
const rowIsValid = normalizeTorrentWebSeedDrafts([row], filesForValidation) !== null;
|
||||
return (
|
||||
<div key={rowId} className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] gap-2 items-end">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor={`${rowId}-file`} className="block text-[10px] text-text-muted mb-1">
|
||||
{t($ => $.properties.torrentWebSeedsFile)}
|
||||
</label>
|
||||
{files.length === 1 ? (
|
||||
<select
|
||||
id={`${rowId}-file`}
|
||||
value={files[0].index}
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
aria-invalid={!rowIsValid}
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
|
||||
>
|
||||
<option value={files[0].index}>{files[0].index + 1}: {filePath(files[0])}</option>
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
id={`${rowId}-file`}
|
||||
value={row.fileIndex ?? ''}
|
||||
onChange={event => updateRow(rowIndex, { fileIndex: Number(event.currentTarget.value) })}
|
||||
disabled={disabled || files.length === 0}
|
||||
aria-invalid={!rowIsValid}
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>{t($ => $.properties.torrentWebSeedsFile)}</option>
|
||||
{files.map(file => (
|
||||
<option key={file.index} value={file.index}>
|
||||
{file.index + 1}: {filePath(file)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label htmlFor={`${rowId}-uri`} className="block text-[10px] text-text-muted mb-1">
|
||||
{t($ => $.properties.torrentWebSeedsUri)}
|
||||
</label>
|
||||
<input
|
||||
id={`${rowId}-uri`}
|
||||
type="url"
|
||||
ref={element => { uriRefs.current[rowIndex] = element; }}
|
||||
value={row.uri}
|
||||
onChange={event => updateRow(rowIndex, { uri: event.currentTarget.value })}
|
||||
disabled={disabled}
|
||||
aria-invalid={!rowIsValid}
|
||||
placeholder="https://mirror.example/torrent/"
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
disabled={disabled}
|
||||
aria-label={t($ => $.properties.torrentWebSeedsRemove)}
|
||||
className="app-button min-h-[30px] px-2 text-xs disabled:opacity-50"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rows.length > 0 && !rowsAreValid && (
|
||||
<p className="text-[11px] text-red-500" role="alert">
|
||||
{t($ => $.properties.torrentWebSeedsInvalid)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={disabled || files.length === 0}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
+ {t($ => $.properties.torrentWebSeedsAdd)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+49
-6
@@ -96,6 +96,7 @@ const common = {
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
retrying: 'Retrying',
|
||||
moving: 'Moving data',
|
||||
},
|
||||
values: {
|
||||
processing: 'Processing...',
|
||||
@@ -288,11 +289,16 @@ const common = {
|
||||
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/',
|
||||
torrentWebSeedsHint: 'Add one HTTP(S) base URI per Torrent file. Firelink expands multi-file paths natively.',
|
||||
torrentWebSeedsApply: 'Apply web seeds',
|
||||
torrentWebSeedsLoading: 'Applying…',
|
||||
torrentWebSeedsFailed: 'Could not validate or apply the Torrent web seeds.',
|
||||
torrentWebSeedsEmpty: 'No web seeds configured.',
|
||||
torrentWebSeedsFile: 'File',
|
||||
torrentWebSeedsUri: 'HTTP(S) base URI',
|
||||
torrentWebSeedsAdd: 'Add web seed',
|
||||
torrentWebSeedsRemove: 'Remove web seed',
|
||||
torrentWebSeedsInvalid: 'Each web-seed row needs a valid Torrent file and an HTTP(S) base URI without credentials or fragments.',
|
||||
torrentPeerCount: '{{total}} peers · {{seeders}} seeders',
|
||||
torrentPeerDownload: 'Download',
|
||||
torrentPeerUpload: 'Upload',
|
||||
@@ -300,12 +306,22 @@ const common = {
|
||||
torrentPeerAmChoking: 'Firelink choking',
|
||||
torrentPeerChoking: 'Peer choking',
|
||||
torrentPeerShowing: 'Showing {{shown}} of {{total}} peers.',
|
||||
torrentStatistics: 'Torrent statistics',
|
||||
torrentUploaded: 'Uploaded',
|
||||
torrentRatio: 'Ratio',
|
||||
torrentSeededDuration: 'Seeded',
|
||||
torrentConnectedPeers: 'Peers',
|
||||
torrentSeeders: 'Seeders',
|
||||
torrentUploadSpeed: 'Upload speed',
|
||||
seconds: 'seconds',
|
||||
torrentStopTimeout: 'Stop stalled Torrent after',
|
||||
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy; changes apply when the Torrent starts or retries.',
|
||||
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
|
||||
torrentPrioritizePiece: 'Prioritize Torrent pieces',
|
||||
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.',
|
||||
torrentPrioritizePiece: 'Prioritize first/last pieces for preview',
|
||||
torrentPrioritizePieceHead: 'Prioritize first pieces',
|
||||
torrentPrioritizePieceTail: 'Prioritize last pieces',
|
||||
torrentPrioritizePieceSize: 'Preview piece range size',
|
||||
torrentPrioritizePieceHint: 'Optional preview policy. Each enabled range defaults to 1M and applies 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',
|
||||
@@ -313,6 +329,28 @@ const common = {
|
||||
torrentFileAllocationNone: 'Allocate as needed',
|
||||
torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.',
|
||||
torrentDetails: 'Torrent details',
|
||||
torrentCopyMagnet: 'Copy magnet link',
|
||||
torrentExportMetadata: 'Export .torrent',
|
||||
torrentMagnetCopied: 'Identity-only magnet copied.',
|
||||
torrentMagnetCopyFailed: 'Could not copy the magnet link.',
|
||||
torrentMetadataExported: 'Torrent metadata exported.',
|
||||
torrentMetadataExportFailed: 'Could not export Torrent metadata.',
|
||||
torrentMove: 'Move data…',
|
||||
torrentMoveLoading: 'Moving…',
|
||||
torrentMoveCancel: 'Cancel move',
|
||||
torrentMoveCancelRequested: 'Canceling move…',
|
||||
torrentMoveConfirm: 'Move the managed Torrent data to this folder? Existing files are never overwritten.',
|
||||
torrentMoveCompleted: 'Torrent data moved.',
|
||||
torrentMoveFailed: 'Could not move Torrent data.',
|
||||
torrentAvailability: 'Swarm availability',
|
||||
torrentAvailabilityRefresh: 'Refresh',
|
||||
torrentAvailabilityLoading: 'Loading availability…',
|
||||
torrentAvailabilityUnavailable: 'Availability is available for an active or paused Torrent.',
|
||||
torrentAvailabilityFailed: 'Could not read Torrent availability.',
|
||||
torrentAvailabilityHint: 'Only aggregate copy counts are shown; peer identities and raw bitfields are never exposed.',
|
||||
torrentAvailabilitySummary: '{{availability}} copies available · {{peers}} connected peers · {{pieces}} pieces',
|
||||
torrentAvailabilityMap: 'Torrent swarm availability map',
|
||||
torrentAvailabilityBucket: 'At least {{copies}} copies in this range',
|
||||
torrentDetailsLoading: 'Loading Torrent details…',
|
||||
torrentDetailsUnavailable: 'Torrent details are not available.',
|
||||
torrentDetailsDisplayName: 'Display name',
|
||||
@@ -513,12 +551,14 @@ const common = {
|
||||
moveOneFailed: 'Could not move download to queue',
|
||||
copyAddressesFailed: 'Could not copy addresses',
|
||||
copyAddressFailed: 'Could not copy address',
|
||||
copyMagnetFailed: 'Could not copy the magnet link',
|
||||
copyPathFailed: 'Could not copy file path',
|
||||
missingFileName: 'File name is missing',
|
||||
redownloadFailed: 'Redownload failed',
|
||||
startResume: 'Start/Resume',
|
||||
addToQueue: 'Add to Queue',
|
||||
copyAddress: 'Copy Address',
|
||||
copyMagnet: 'Copy magnet link',
|
||||
remove: 'Remove',
|
||||
open: 'Open',
|
||||
showInFolder: 'Show in Folder',
|
||||
@@ -603,8 +643,11 @@ const common = {
|
||||
torrentStopTimeout: 'Stop stalled Torrent after',
|
||||
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy.',
|
||||
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
|
||||
torrentPrioritizePiece: 'Prioritize Torrent pieces',
|
||||
torrentPrioritizePieceHint: 'Saved with this Torrent and applied on its next start or retry. Use head, tail, or both with optional K or M sizes.',
|
||||
torrentPrioritizePiece: 'Prioritize first/last pieces for preview',
|
||||
torrentPrioritizePieceHead: 'Prioritize first pieces',
|
||||
torrentPrioritizePieceTail: 'Prioritize last pieces',
|
||||
torrentPrioritizePieceSize: 'Preview piece range size',
|
||||
torrentPrioritizePieceHint: 'Choose first pieces, last pieces, or both for preview. Each enabled range defaults to 1M and applies on the next start or retry.',
|
||||
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
|
||||
torrentEncryptionPolicy: 'Torrent encryption policy',
|
||||
torrentEncryptionPolicyHint: 'Saved with this Torrent and applied on its next start or retry. The selected policy keeps Aria2 encryption settings consistent.',
|
||||
|
||||
+49
-6
@@ -96,6 +96,7 @@ const fa = {
|
||||
completed: 'تکمیلشده',
|
||||
failed: 'ناموفق',
|
||||
retrying: 'در حال تلاش مجدد',
|
||||
moving: 'در حال جابهجایی داده',
|
||||
},
|
||||
values: {
|
||||
processing: 'در حال پردازش…',
|
||||
@@ -288,11 +289,16 @@ const fa = {
|
||||
torrentPieceProgressSummary: '{{completed}} از {{total}} قطعه کامل شده · هرکدام {{size}}',
|
||||
torrentPieceProgressMap: 'نقشه تکمیل قطعههای تورنت',
|
||||
torrentWebSeeds: 'وبسیدهای تورنت',
|
||||
torrentWebSeedsHint: 'هر خط بهشکل شماره فایل|نشانی HTTP(S). مسیر فایلهای چندفایلی را Firelink در بخش native میسازد.',
|
||||
torrentWebSeedsPlaceholder: '۰|https://mirror.example/torrent/',
|
||||
torrentWebSeedsHint: 'برای هر فایل تورنت یک نشانی پایهٔ HTTP(S) اضافه کنید. Firelink مسیر فایلهای چندفایلی را خودش میسازد.',
|
||||
torrentWebSeedsApply: 'اعمال وبسیدها',
|
||||
torrentWebSeedsLoading: 'در حال اعمال…',
|
||||
torrentWebSeedsFailed: 'اعتبارسنجی یا اعمال وبسیدهای تورنت انجام نشد.',
|
||||
torrentWebSeedsEmpty: 'وبسیدی تنظیم نشده است.',
|
||||
torrentWebSeedsFile: 'فایل',
|
||||
torrentWebSeedsUri: 'نشانی پایهٔ HTTP(S)',
|
||||
torrentWebSeedsAdd: 'افزودن وبسید',
|
||||
torrentWebSeedsRemove: 'حذف وبسید',
|
||||
torrentWebSeedsInvalid: 'هر ردیف وبسید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
|
||||
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
|
||||
torrentPeerDownload: 'دریافت',
|
||||
torrentPeerUpload: 'آپلود',
|
||||
@@ -300,12 +306,22 @@ const fa = {
|
||||
torrentPeerAmChoking: 'محدودسازی از طرف Firelink',
|
||||
torrentPeerChoking: 'محدودسازی از طرف همتا',
|
||||
torrentPeerShowing: 'نمایش {{shown}} همتا از {{total}} همتا.',
|
||||
torrentStatistics: 'آمار تورنت',
|
||||
torrentUploaded: 'آپلودشده',
|
||||
torrentRatio: 'نسبت',
|
||||
torrentSeededDuration: 'مدت سید',
|
||||
torrentConnectedPeers: 'همتاها',
|
||||
torrentSeeders: 'سیدها',
|
||||
torrentUploadSpeed: 'سرعت آپلود',
|
||||
seconds: 'ثانیه',
|
||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
||||
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
||||
torrentPrioritizePieceHint: 'سیاست اختیاری پیشنمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام میتوان اندازهای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
||||
torrentPrioritizePiece: 'اولویت دادن به قطعههای ابتدا/انتها برای پیشنمایش',
|
||||
torrentPrioritizePieceHead: 'اولویت قطعههای ابتدا',
|
||||
torrentPrioritizePieceTail: 'اولویت قطعههای انتها',
|
||||
torrentPrioritizePieceSize: 'اندازهٔ بازهٔ پیشنمایش',
|
||||
torrentPrioritizePieceHint: 'سیاست اختیاری پیشنمایش. هر بازهٔ فعال بهطور پیشفرض 1M است و هنگام شروع یا تلاش مجدد اعمال میشود.',
|
||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
|
||||
torrentFileAllocation: 'نحوهٔ تخصیص فایل تورنت',
|
||||
@@ -313,6 +329,28 @@ const fa = {
|
||||
torrentFileAllocationNone: 'تخصیص هنگام نیاز',
|
||||
torrentFileAllocationHint: 'تخصیص از پیش فضای فایلهای انتخابشده را قبل از انتقال رزرو میکند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمیدهد.',
|
||||
torrentDetails: 'جزئیات تورنت',
|
||||
torrentCopyMagnet: 'کپی پیوند مگنت',
|
||||
torrentExportMetadata: 'خروجی .torrent',
|
||||
torrentMagnetCopied: 'مگنت فقط-هویتی کپی شد.',
|
||||
torrentMagnetCopyFailed: 'کپی پیوند مگنت ناموفق بود.',
|
||||
torrentMetadataExported: 'فراداده تورنت خروجی گرفته شد.',
|
||||
torrentMetadataExportFailed: 'خروجی فراداده تورنت ناموفق بود.',
|
||||
torrentMove: 'جابهجایی داده…',
|
||||
torrentMoveLoading: 'در حال جابهجایی…',
|
||||
torrentMoveCancel: 'لغو جابهجایی',
|
||||
torrentMoveCancelRequested: 'درخواست لغو جابهجایی ارسال شد…',
|
||||
torrentMoveConfirm: 'داده تورنت مدیریتشده به این پوشه منتقل شود؟ فایلهای موجود هرگز بازنویسی نمیشوند.',
|
||||
torrentMoveCompleted: 'داده تورنت جابهجا شد.',
|
||||
torrentMoveFailed: 'جابهجایی داده تورنت ناموفق بود.',
|
||||
torrentAvailability: 'دسترسپذیری شبکه',
|
||||
torrentAvailabilityRefresh: 'تازهسازی',
|
||||
torrentAvailabilityLoading: 'در حال بارگیری دسترسپذیری…',
|
||||
torrentAvailabilityUnavailable: 'دسترسپذیری برای تورنت فعال یا متوقف در دسترس است.',
|
||||
torrentAvailabilityFailed: 'خواندن دسترسپذیری تورنت ناموفق بود.',
|
||||
torrentAvailabilityHint: 'فقط شمارش کلی کپیها نمایش داده میشود؛ هویت همتاها و بیتفیلد خام هرگز نمایش داده نمیشوند.',
|
||||
torrentAvailabilitySummary: '{{availability}} کپی در دسترس · {{peers}} همتای متصل · {{pieces}} قطعه',
|
||||
torrentAvailabilityMap: 'نقشه دسترسپذیری شبکه تورنت',
|
||||
torrentAvailabilityBucket: 'حداقل {{copies}} کپی در این بازه',
|
||||
torrentDetailsLoading: 'در حال دریافت جزئیات تورنت…',
|
||||
torrentDetailsUnavailable: 'جزئیات تورنت در دسترس نیست.',
|
||||
torrentDetailsDisplayName: 'نام نمایشی',
|
||||
@@ -513,12 +551,14 @@ const fa = {
|
||||
moveOneFailed: 'انتقال دانلود به صف ناموفق بود',
|
||||
copyAddressesFailed: 'آدرسها کپی نشدند',
|
||||
copyAddressFailed: 'آدرس کپی نشد',
|
||||
copyMagnetFailed: 'پیوند مگنت کپی نشد',
|
||||
copyPathFailed: 'مسیر فایل کپی نشد',
|
||||
missingFileName: 'نام فایل وجود ندارد',
|
||||
redownloadFailed: 'دانلود مجدد ناموفق بود',
|
||||
startResume: 'شروع/ادامه',
|
||||
addToQueue: 'افزودن به صف',
|
||||
copyAddress: 'کپی آدرس',
|
||||
copyMagnet: 'کپی پیوند مگنت',
|
||||
remove: 'حذف',
|
||||
open: 'باز کردن',
|
||||
showInFolder: 'نمایش در پوشه',
|
||||
@@ -603,8 +643,11 @@ const fa = {
|
||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند.',
|
||||
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
||||
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
||||
torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
|
||||
torrentPrioritizePiece: 'اولویت دادن به قطعههای ابتدا/انتها برای پیشنمایش',
|
||||
torrentPrioritizePieceHead: 'اولویت قطعههای ابتدا',
|
||||
torrentPrioritizePieceTail: 'اولویت قطعههای انتها',
|
||||
torrentPrioritizePieceSize: 'اندازهٔ بازهٔ پیشنمایش',
|
||||
torrentPrioritizePieceHint: 'قطعههای ابتدا، انتها یا هر دو را برای پیشنمایش انتخاب کنید. هر بازهٔ فعال بهطور پیشفرض 1M است و در شروع یا تلاش مجدد بعدی اعمال میشود.',
|
||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
|
||||
torrentEncryptionPolicyHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. سیاست انتخابی تنظیمات رمزنگاری آریا۲ را سازگار نگه میدارد.',
|
||||
|
||||
+49
-6
@@ -96,6 +96,7 @@ const he = {
|
||||
completed: 'הושלם',
|
||||
failed: 'נכשל',
|
||||
retrying: 'ניסיון חוזר',
|
||||
moving: 'מעביר נתונים',
|
||||
},
|
||||
values: {
|
||||
processing: 'מעבד…',
|
||||
@@ -288,11 +289,16 @@ const he = {
|
||||
torrentPieceProgressSummary: '{{completed}} מתוך {{total}} חלקים הושלמו · {{size}} לכל חלק',
|
||||
torrentPieceProgressMap: 'מפת השלמת חלקי הטורנט',
|
||||
torrentWebSeeds: 'זריעות Web של טורנט',
|
||||
torrentWebSeedsHint: 'שורה אחת לכל זרע בפורמט file index|כתובת HTTP(S). Firelink מרחיב נתיבי קבצים מרובי-קבצים באופן מקורי.',
|
||||
torrentWebSeedsPlaceholder: '0|https://מראה.example/torrent/',
|
||||
torrentWebSeedsHint: 'הוסף כתובת בסיס HTTP(S) אחת לכל קובץ טורנט. Firelink מרחיב נתיבים של טורנטים מרובי-קבצים.',
|
||||
torrentWebSeedsApply: 'החל זריעות Web',
|
||||
torrentWebSeedsLoading: 'מיישם…',
|
||||
torrentWebSeedsFailed: 'לא ניתן לאמת או להחיל את זריעות ה-Web של הטורנט.',
|
||||
torrentWebSeedsEmpty: 'לא הוגדרו זריעות Web.',
|
||||
torrentWebSeedsFile: 'קובץ',
|
||||
torrentWebSeedsUri: 'כתובת בסיס HTTP(S)',
|
||||
torrentWebSeedsAdd: 'הוסף זריעת Web',
|
||||
torrentWebSeedsRemove: 'הסר זריעת Web',
|
||||
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
|
||||
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
|
||||
torrentPeerDownload: 'הורדה',
|
||||
torrentPeerUpload: 'העלאה',
|
||||
@@ -300,12 +306,22 @@ const he = {
|
||||
torrentPeerAmChoking: 'Firelink מגביל',
|
||||
torrentPeerChoking: 'העמית מגביל',
|
||||
torrentPeerShowing: 'מוצגים {{shown}} מתוך {{total}} עמיתים.',
|
||||
torrentStatistics: 'סטטיסטיקות טורנט',
|
||||
torrentUploaded: 'הועלה',
|
||||
torrentRatio: 'יחס',
|
||||
torrentSeededDuration: 'משך שיתוף',
|
||||
torrentConnectedPeers: 'עמיתים',
|
||||
torrentSeeders: 'משתפים',
|
||||
torrentUploadSpeed: 'מהירות העלאה',
|
||||
seconds: 'שניות',
|
||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
|
||||
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
||||
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
|
||||
torrentPrioritizePiece: 'תעדוף החלקים הראשונים/האחרונים לתצוגה מקדימה',
|
||||
torrentPrioritizePieceHead: 'תעדף חלקים ראשונים',
|
||||
torrentPrioritizePieceTail: 'תעדף חלקים אחרונים',
|
||||
torrentPrioritizePieceSize: 'גודל טווח התצוגה המקדימה',
|
||||
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית. כל טווח מופעל מתחיל ב־1M ומוחל כשהטורנט מופעל או מנסה שוב.',
|
||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
|
||||
torrentFileAllocation: 'הקצאת קובצי Torrent',
|
||||
@@ -313,6 +329,28 @@ const he = {
|
||||
torrentFileAllocationNone: 'הקצאה לפי הצורך',
|
||||
torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.',
|
||||
torrentDetails: 'פרטי טורנט',
|
||||
torrentCopyMagnet: 'העתקת קישור מגנט',
|
||||
torrentExportMetadata: 'ייצוא .torrent',
|
||||
torrentMagnetCopied: 'קישור מגנט זהותי הועתק.',
|
||||
torrentMagnetCopyFailed: 'לא ניתן להעתיק את קישור המגנט.',
|
||||
torrentMetadataExported: 'מטא־נתוני הטורנט יוצאו.',
|
||||
torrentMetadataExportFailed: 'לא ניתן לייצא את מטא־נתוני הטורנט.',
|
||||
torrentMove: 'העברת נתונים…',
|
||||
torrentMoveLoading: 'מעביר…',
|
||||
torrentMoveCancel: 'ביטול ההעברה',
|
||||
torrentMoveCancelRequested: 'בקשת ביטול נשלחה…',
|
||||
torrentMoveConfirm: 'להעביר את נתוני הטורנט המנוהלים לתיקייה זו? קבצים קיימים לעולם לא יוחלפו.',
|
||||
torrentMoveCompleted: 'נתוני הטורנט הועברו.',
|
||||
torrentMoveFailed: 'לא ניתן להעביר את נתוני הטורנט.',
|
||||
torrentAvailability: 'זמינות הנחיל',
|
||||
torrentAvailabilityRefresh: 'רענון',
|
||||
torrentAvailabilityLoading: 'טוען זמינות…',
|
||||
torrentAvailabilityUnavailable: 'הזמינות זמינה עבור טורנט פעיל או מושהה.',
|
||||
torrentAvailabilityFailed: 'לא ניתן לקרוא את זמינות הטורנט.',
|
||||
torrentAvailabilityHint: 'מוצגים רק מספרי עותקים מצטברים; זהויות עמיתים וביטפילדים גולמיים לעולם אינם נחשפים.',
|
||||
torrentAvailabilitySummary: '{{availability}} עותקים זמינים · {{peers}} עמיתים מחוברים · {{pieces}} חלקים',
|
||||
torrentAvailabilityMap: 'מפת זמינות נחיל הטורנט',
|
||||
torrentAvailabilityBucket: 'לפחות {{copies}} עותקים בטווח זה',
|
||||
torrentDetailsLoading: 'טוען פרטי טורנט…',
|
||||
torrentDetailsUnavailable: 'פרטי הטורנט אינם זמינים.',
|
||||
torrentDetailsDisplayName: 'שם תצוגה',
|
||||
@@ -513,12 +551,14 @@ const he = {
|
||||
moveOneFailed: 'לא ניתן להעביר הורדה לתור',
|
||||
copyAddressesFailed: 'לא ניתן להעתיק כתובות',
|
||||
copyAddressFailed: 'לא ניתן להעתיק כתובת',
|
||||
copyMagnetFailed: 'לא ניתן להעתיק את קישור המגנט',
|
||||
copyPathFailed: 'לא ניתן להעתיק נתיב קובץ',
|
||||
missingFileName: 'שם הקובץ חסר',
|
||||
redownloadFailed: 'הורדה מחדש נכשלה',
|
||||
startResume: 'הפעלה/חידוש',
|
||||
addToQueue: 'הוספה לתור',
|
||||
copyAddress: 'העתקת כתובת',
|
||||
copyMagnet: 'העתקת קישור מגנט',
|
||||
remove: 'הסרה',
|
||||
open: 'פתיחה',
|
||||
showInFolder: 'הצגה בתיקייה',
|
||||
@@ -603,8 +643,11 @@ const he = {
|
||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.',
|
||||
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
||||
torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
|
||||
torrentPrioritizePiece: 'תעדוף החלקים הראשונים/האחרונים לתצוגה מקדימה',
|
||||
torrentPrioritizePieceHead: 'תעדף חלקים ראשונים',
|
||||
torrentPrioritizePieceTail: 'תעדף חלקים אחרונים',
|
||||
torrentPrioritizePieceSize: 'גודל טווח התצוגה המקדימה',
|
||||
torrentPrioritizePieceHint: 'בחר חלקים ראשונים, אחרונים או את שניהם לתצוגה מקדימה. כל טווח מופעל מתחיל ב־1M ומוחל בהפעלה או בניסיון חוזר הבא.',
|
||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
|
||||
torrentEncryptionPolicyHint: 'נשמרת עם ה-Torrent ומוחלת בהפעלה או בניסיון חוזר. המדיניות שומרת על הגדרות ההצפנה של Aria2 עקביות.',
|
||||
|
||||
+49
-6
@@ -96,6 +96,7 @@ const ru = {
|
||||
completed: 'Завершено',
|
||||
failed: 'Ошибка',
|
||||
retrying: 'Повторная попытка',
|
||||
moving: 'Перемещение данных',
|
||||
},
|
||||
values: {
|
||||
processing: 'Обработка…',
|
||||
@@ -288,11 +289,16 @@ const ru = {
|
||||
torrentPieceProgressSummary: '{{completed}} из {{total}} частей завершено · по {{size}} на часть',
|
||||
torrentPieceProgressMap: 'Карта завершения частей торрента',
|
||||
torrentWebSeeds: 'Веб-сиды торрента',
|
||||
torrentWebSeedsHint: 'Одна строка на сид в формате индекс файла|HTTP(S)-URI. Firelink сам расширяет пути многофайловых торрентов.',
|
||||
torrentWebSeedsPlaceholder: '0|https://зеркало.example/torrent/',
|
||||
torrentWebSeedsHint: 'Добавьте по одному базовому HTTP(S)-адресу для каждого файла торрента. Firelink сам расширит пути многофайлового торрента.',
|
||||
torrentWebSeedsApply: 'Применить веб-сиды',
|
||||
torrentWebSeedsLoading: 'Применение…',
|
||||
torrentWebSeedsFailed: 'Не удалось проверить или применить веб-сиды торрента.',
|
||||
torrentWebSeedsEmpty: 'Веб-сиды не настроены.',
|
||||
torrentWebSeedsFile: 'Файл',
|
||||
torrentWebSeedsUri: 'Базовый HTTP(S)-адрес',
|
||||
torrentWebSeedsAdd: 'Добавить веб-сид',
|
||||
torrentWebSeedsRemove: 'Удалить веб-сид',
|
||||
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
|
||||
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
|
||||
torrentPeerDownload: 'Загрузка',
|
||||
torrentPeerUpload: 'Отдача',
|
||||
@@ -300,12 +306,22 @@ const ru = {
|
||||
torrentPeerAmChoking: 'Firelink ограничивает',
|
||||
torrentPeerChoking: 'Пир ограничивает',
|
||||
torrentPeerShowing: 'Показано {{shown}} из {{total}} пиров.',
|
||||
torrentStatistics: 'Статистика торрента',
|
||||
torrentUploaded: 'Отдано',
|
||||
torrentRatio: 'Коэффициент',
|
||||
torrentSeededDuration: 'Время раздачи',
|
||||
torrentConnectedPeers: 'Пиры',
|
||||
torrentSeeders: 'Сиды',
|
||||
torrentUploadSpeed: 'Скорость отдачи',
|
||||
seconds: 'секунд',
|
||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Приоритет частей торрента',
|
||||
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
|
||||
torrentPrioritizePiece: 'Приоритет первых/последних частей для предпросмотра',
|
||||
torrentPrioritizePieceHead: 'Приоритет первых частей',
|
||||
torrentPrioritizePieceTail: 'Приоритет последних частей',
|
||||
torrentPrioritizePieceSize: 'Размер диапазона предпросмотра',
|
||||
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра. Каждый включённый диапазон по умолчанию равен 1M и применяется при запуске или повторной попытке.',
|
||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||
torrentEncryptionPolicy: 'Политика шифрования Torrent',
|
||||
torrentFileAllocation: 'Выделение места для файлов Torrent',
|
||||
@@ -314,6 +330,28 @@ const ru = {
|
||||
torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.',
|
||||
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
|
||||
torrentDetails: 'Сведения о Torrent',
|
||||
torrentCopyMagnet: 'Копировать magnet-ссылку',
|
||||
torrentExportMetadata: 'Экспортировать .torrent',
|
||||
torrentMagnetCopied: 'Magnet-ссылка только с идентификатором скопирована.',
|
||||
torrentMagnetCopyFailed: 'Не удалось скопировать magnet-ссылку.',
|
||||
torrentMetadataExported: 'Метаданные Torrent экспортированы.',
|
||||
torrentMetadataExportFailed: 'Не удалось экспортировать метаданные Torrent.',
|
||||
torrentMove: 'Переместить данные…',
|
||||
torrentMoveLoading: 'Перемещение…',
|
||||
torrentMoveCancel: 'Отменить перемещение',
|
||||
torrentMoveCancelRequested: 'Запрос на отмену отправлен…',
|
||||
torrentMoveConfirm: 'Переместить управляемые данные Torrent в эту папку? Существующие файлы не перезаписываются.',
|
||||
torrentMoveCompleted: 'Данные Torrent перемещены.',
|
||||
torrentMoveFailed: 'Не удалось переместить данные Torrent.',
|
||||
torrentAvailability: 'Доступность раздачи',
|
||||
torrentAvailabilityRefresh: 'Обновить',
|
||||
torrentAvailabilityLoading: 'Загрузка доступности…',
|
||||
torrentAvailabilityUnavailable: 'Доступность доступна для активного или приостановленного Torrent.',
|
||||
torrentAvailabilityFailed: 'Не удалось прочитать доступность Torrent.',
|
||||
torrentAvailabilityHint: 'Показываются только агрегированные копии; идентификаторы пиров и исходные битовые поля не раскрываются.',
|
||||
torrentAvailabilitySummary: '{{availability}} доступных копий · {{peers}} подключённых пиров · {{pieces}} частей',
|
||||
torrentAvailabilityMap: 'Карта доступности раздачи Torrent',
|
||||
torrentAvailabilityBucket: 'Не менее {{copies}} копий в этом диапазоне',
|
||||
torrentDetailsLoading: 'Загрузка сведений о Torrent…',
|
||||
torrentDetailsUnavailable: 'Сведения о Torrent недоступны.',
|
||||
torrentDetailsDisplayName: 'Отображаемое имя',
|
||||
@@ -513,12 +551,14 @@ const ru = {
|
||||
moveOneFailed: 'Не удалось переместить загрузку в очередь',
|
||||
copyAddressesFailed: 'Не удалось скопировать адреса',
|
||||
copyAddressFailed: 'Не удалось скопировать адрес',
|
||||
copyMagnetFailed: 'Не удалось скопировать magnet-ссылку',
|
||||
copyPathFailed: 'Не удалось скопировать путь к файлу',
|
||||
missingFileName: 'Отсутствует имя файла',
|
||||
redownloadFailed: 'Не удалось скачать повторно',
|
||||
startResume: 'Запустить/Возобновить',
|
||||
addToQueue: 'Добавить в очередь',
|
||||
copyAddress: 'Скопировать адрес',
|
||||
copyMagnet: 'Копировать magnet-ссылку',
|
||||
remove: 'Удалить',
|
||||
open: 'Открыть',
|
||||
showInFolder: 'Показать в папке',
|
||||
@@ -603,8 +643,11 @@ const ru = {
|
||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Приоритет частей торрента',
|
||||
torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
|
||||
torrentPrioritizePiece: 'Приоритет первых/последних частей для предпросмотра',
|
||||
torrentPrioritizePieceHead: 'Приоритет первых частей',
|
||||
torrentPrioritizePieceTail: 'Приоритет последних частей',
|
||||
torrentPrioritizePieceSize: 'Размер диапазона предпросмотра',
|
||||
torrentPrioritizePieceHint: 'Выберите первые, последние части или оба варианта для предпросмотра. Каждый включённый диапазон по умолчанию равен 1M и применяется при следующем запуске или повторной попытке.',
|
||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||
torrentEncryptionPolicy: 'Политика шифрования Torrent',
|
||||
torrentEncryptionPolicyHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Выбранная политика согласует параметры шифрования Aria2.',
|
||||
|
||||
+49
-6
@@ -96,6 +96,7 @@ const uk = {
|
||||
completed: 'Завершено',
|
||||
failed: 'Помилка',
|
||||
retrying: 'Повторна спроба',
|
||||
moving: 'Переміщення даних',
|
||||
},
|
||||
values: {
|
||||
processing: 'Обробка…',
|
||||
@@ -288,11 +289,16 @@ const uk = {
|
||||
torrentPieceProgressSummary: '{{completed}} із {{total}} частин завершено · по {{size}} на частину',
|
||||
torrentPieceProgressMap: 'Карта завершення частин торрента',
|
||||
torrentWebSeeds: 'Вебсіди торента',
|
||||
torrentWebSeedsHint: 'Один рядок на сід у форматі індекс файлу|HTTP(S)-URI. Firelink сам розгортає шляхи багатофайлових торентів.',
|
||||
torrentWebSeedsPlaceholder: '0|https://дзеркало.example/torrent/',
|
||||
torrentWebSeedsHint: 'Додайте одну базову HTTP(S)-адресу для кожного файлу торента. Firelink сам розгорне шляхи багатофайлового торента.',
|
||||
torrentWebSeedsApply: 'Застосувати вебсіди',
|
||||
torrentWebSeedsLoading: 'Застосування…',
|
||||
torrentWebSeedsFailed: 'Не вдалося перевірити або застосувати вебсіди торента.',
|
||||
torrentWebSeedsEmpty: 'Вебсіди не налаштовано.',
|
||||
torrentWebSeedsFile: 'Файл',
|
||||
torrentWebSeedsUri: 'Базова HTTP(S)-адреса',
|
||||
torrentWebSeedsAdd: 'Додати вебсід',
|
||||
torrentWebSeedsRemove: 'Видалити вебсід',
|
||||
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
|
||||
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
|
||||
torrentPeerDownload: 'Завантаження',
|
||||
torrentPeerUpload: 'Віддача',
|
||||
@@ -300,12 +306,22 @@ const uk = {
|
||||
torrentPeerAmChoking: 'Firelink обмежує',
|
||||
torrentPeerChoking: 'Пір обмежує',
|
||||
torrentPeerShowing: 'Показано {{shown}} із {{total}} пірів.',
|
||||
torrentStatistics: 'Статистика торента',
|
||||
torrentUploaded: 'Віддано',
|
||||
torrentRatio: 'Коефіцієнт',
|
||||
torrentSeededDuration: 'Час роздачі',
|
||||
torrentConnectedPeers: 'Піри',
|
||||
torrentSeeders: 'Сіди',
|
||||
torrentUploadSpeed: 'Швидкість віддачі',
|
||||
seconds: 'секунд',
|
||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
||||
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
|
||||
torrentPrioritizePiece: 'Пріоритет перших/останніх частин для попереднього перегляду',
|
||||
torrentPrioritizePieceHead: 'Пріоритет перших частин',
|
||||
torrentPrioritizePieceTail: 'Пріоритет останніх частин',
|
||||
torrentPrioritizePieceSize: 'Розмір діапазону попереднього перегляду',
|
||||
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду. Кожен увімкнений діапазон за замовчуванням має розмір 1M і застосовується під час запуску або повторної спроби.',
|
||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||
torrentEncryptionPolicy: 'Політика шифрування Torrent',
|
||||
torrentFileAllocation: 'Виділення місця для файлів Torrent',
|
||||
@@ -314,6 +330,28 @@ const uk = {
|
||||
torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.',
|
||||
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
|
||||
torrentDetails: 'Відомості про Torrent',
|
||||
torrentCopyMagnet: 'Копіювати magnet-посилання',
|
||||
torrentExportMetadata: 'Експортувати .torrent',
|
||||
torrentMagnetCopied: 'Magnet-посилання лише з ідентифікатором скопійовано.',
|
||||
torrentMagnetCopyFailed: 'Не вдалося скопіювати magnet-посилання.',
|
||||
torrentMetadataExported: 'Метадані Torrent експортовано.',
|
||||
torrentMetadataExportFailed: 'Не вдалося експортувати метадані Torrent.',
|
||||
torrentMove: 'Перемістити дані…',
|
||||
torrentMoveLoading: 'Переміщення…',
|
||||
torrentMoveCancel: 'Скасувати переміщення',
|
||||
torrentMoveCancelRequested: 'Запит на скасування надіслано…',
|
||||
torrentMoveConfirm: 'Перемістити керовані дані Torrent до цієї папки? Наявні файли не перезаписуються.',
|
||||
torrentMoveCompleted: 'Дані Torrent переміщено.',
|
||||
torrentMoveFailed: 'Не вдалося перемістити дані Torrent.',
|
||||
torrentAvailability: 'Доступність рою',
|
||||
torrentAvailabilityRefresh: 'Оновити',
|
||||
torrentAvailabilityLoading: 'Завантаження доступності…',
|
||||
torrentAvailabilityUnavailable: 'Доступність доступна для активного або призупиненого Torrent.',
|
||||
torrentAvailabilityFailed: 'Не вдалося прочитати доступність Torrent.',
|
||||
torrentAvailabilityHint: 'Показано лише агреговані копії; ідентифікатори пірів і сирі бітові поля не розкриваються.',
|
||||
torrentAvailabilitySummary: '{{availability}} доступних копій · {{peers}} підключених пірів · {{pieces}} частин',
|
||||
torrentAvailabilityMap: 'Мапа доступності рою Torrent',
|
||||
torrentAvailabilityBucket: 'Щонайменше {{copies}} копій у цьому діапазоні',
|
||||
torrentDetailsLoading: 'Завантаження відомостей про Torrent…',
|
||||
torrentDetailsUnavailable: 'Відомості про Torrent недоступні.',
|
||||
torrentDetailsDisplayName: 'Назва',
|
||||
@@ -513,12 +551,14 @@ const uk = {
|
||||
moveOneFailed: 'Не вдалося перемістити завантаження до черги',
|
||||
copyAddressesFailed: 'Не вдалося скопіювати адреси',
|
||||
copyAddressFailed: 'Не вдалося скопіювати адресу',
|
||||
copyMagnetFailed: 'Не вдалося скопіювати magnet-посилання',
|
||||
copyPathFailed: 'Не вдалося скопіювати шлях до файлу',
|
||||
missingFileName: 'Ім\'я файлу відсутнє',
|
||||
redownloadFailed: 'Не вдалося повторно завантажити',
|
||||
startResume: 'Запустити/Відновити',
|
||||
addToQueue: 'Додати до черги',
|
||||
copyAddress: 'Скопіювати адресу',
|
||||
copyMagnet: 'Копіювати magnet-посилання',
|
||||
remove: 'Видалити',
|
||||
open: 'Відкрити',
|
||||
showInFolder: 'Показати в папці',
|
||||
@@ -603,8 +643,11 @@ const uk = {
|
||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.',
|
||||
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
||||
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
||||
torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
|
||||
torrentPrioritizePiece: 'Пріоритет перших/останніх частин для попереднього перегляду',
|
||||
torrentPrioritizePieceHead: 'Пріоритет перших частин',
|
||||
torrentPrioritizePieceTail: 'Пріоритет останніх частин',
|
||||
torrentPrioritizePieceSize: 'Розмір діапазону попереднього перегляду',
|
||||
torrentPrioritizePieceHint: 'Виберіть перші, останні частини або обидва варіанти для попереднього перегляду. Кожен увімкнений діапазон за замовчуванням має розмір 1M і застосовується під час наступного запуску або повторної спроби.',
|
||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||
torrentEncryptionPolicy: 'Політика шифрування Torrent',
|
||||
torrentEncryptionPolicyHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Вибрана політика узгоджує параметри шифрування Aria2.',
|
||||
|
||||
@@ -96,6 +96,7 @@ const zhCN = {
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
retrying: '重试中',
|
||||
moving: '正在移动数据',
|
||||
},
|
||||
values: {
|
||||
processing: '处理中…',
|
||||
@@ -288,11 +289,16 @@ const zhCN = {
|
||||
torrentPieceProgressSummary: '{{completed}}/{{total}} 个分片已完成 · 每片 {{size}}',
|
||||
torrentPieceProgressMap: 'Torrent 分片完成度地图',
|
||||
torrentWebSeeds: 'Torrent Web 做种',
|
||||
torrentWebSeedsHint: '每行一个做种,格式为文件索引|HTTP(S) URI。多文件路径由 Firelink 原生展开。',
|
||||
torrentWebSeedsPlaceholder: '0|https://镜像.example/torrent/',
|
||||
torrentWebSeedsHint: '为每个 Torrent 文件添加一个 HTTP(S) 基础地址。多文件路径由 Firelink 原生展开。',
|
||||
torrentWebSeedsApply: '应用 Web 做种',
|
||||
torrentWebSeedsLoading: '正在应用…',
|
||||
torrentWebSeedsFailed: '无法验证或应用 Torrent Web 做种。',
|
||||
torrentWebSeedsEmpty: '尚未配置 Web 做种。',
|
||||
torrentWebSeedsFile: '文件',
|
||||
torrentWebSeedsUri: 'HTTP(S) 基础地址',
|
||||
torrentWebSeedsAdd: '添加 Web 做种',
|
||||
torrentWebSeedsRemove: '移除 Web 做种',
|
||||
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
|
||||
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
|
||||
torrentPeerDownload: '下载',
|
||||
torrentPeerUpload: '上传',
|
||||
@@ -300,12 +306,22 @@ const zhCN = {
|
||||
torrentPeerAmChoking: 'Firelink 限制中',
|
||||
torrentPeerChoking: '对等节点限制中',
|
||||
torrentPeerShowing: '显示 {{total}} 个节点中的 {{shown}} 个。',
|
||||
torrentStatistics: '种子统计',
|
||||
torrentUploaded: '已上传',
|
||||
torrentRatio: '分享率',
|
||||
torrentSeededDuration: '做种时长',
|
||||
torrentConnectedPeers: '连接数',
|
||||
torrentSeeders: '种子数',
|
||||
torrentUploadSpeed: '上传速度',
|
||||
seconds: '秒',
|
||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
|
||||
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
||||
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
||||
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
|
||||
torrentPrioritizePiece: '为预览优先下载开头/结尾片段',
|
||||
torrentPrioritizePieceHead: '优先下载开头片段',
|
||||
torrentPrioritizePieceTail: '优先下载结尾片段',
|
||||
torrentPrioritizePieceSize: '预览片段范围大小',
|
||||
torrentPrioritizePieceHint: '可选的预览策略。每个启用的范围默认为 1M,并在 Torrent 启动或重试时应用。',
|
||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||
torrentEncryptionPolicy: 'Torrent 加密策略',
|
||||
torrentFileAllocation: 'Torrent 文件分配',
|
||||
@@ -314,6 +330,28 @@ const zhCN = {
|
||||
torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。',
|
||||
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
|
||||
torrentDetails: 'Torrent 详细信息',
|
||||
torrentCopyMagnet: '复制磁力链接',
|
||||
torrentExportMetadata: '导出 .torrent',
|
||||
torrentMagnetCopied: '仅包含身份信息的磁力链接已复制。',
|
||||
torrentMagnetCopyFailed: '无法复制磁力链接。',
|
||||
torrentMetadataExported: 'Torrent 元数据已导出。',
|
||||
torrentMetadataExportFailed: '无法导出 Torrent 元数据。',
|
||||
torrentMove: '移动数据…',
|
||||
torrentMoveLoading: '正在移动…',
|
||||
torrentMoveCancel: '取消移动',
|
||||
torrentMoveCancelRequested: '已请求取消移动…',
|
||||
torrentMoveConfirm: '要将托管的 Torrent 数据移动到此文件夹吗?不会覆盖现有文件。',
|
||||
torrentMoveCompleted: 'Torrent 数据已移动。',
|
||||
torrentMoveFailed: '无法移动 Torrent 数据。',
|
||||
torrentAvailability: '种群可用性',
|
||||
torrentAvailabilityRefresh: '刷新',
|
||||
torrentAvailabilityLoading: '正在加载可用性…',
|
||||
torrentAvailabilityUnavailable: '活动或暂停的 Torrent 可查看可用性。',
|
||||
torrentAvailabilityFailed: '无法读取 Torrent 可用性。',
|
||||
torrentAvailabilityHint: '仅显示聚合副本数量;不会暴露节点身份或原始位字段。',
|
||||
torrentAvailabilitySummary: '{{availability}} 个可用副本 · {{peers}} 个已连接节点 · {{pieces}} 个分片',
|
||||
torrentAvailabilityMap: 'Torrent 种群可用性图',
|
||||
torrentAvailabilityBucket: '此范围至少有 {{copies}} 个副本',
|
||||
torrentDetailsLoading: '正在加载 Torrent 详细信息…',
|
||||
torrentDetailsUnavailable: 'Torrent 详细信息不可用。',
|
||||
torrentDetailsDisplayName: '显示名称',
|
||||
@@ -513,12 +551,14 @@ const zhCN = {
|
||||
moveOneFailed: '无法将下载移动到队列',
|
||||
copyAddressesFailed: '无法复制地址',
|
||||
copyAddressFailed: '无法复制地址',
|
||||
copyMagnetFailed: '无法复制磁力链接',
|
||||
copyPathFailed: '无法复制文件路径',
|
||||
missingFileName: '缺少文件名',
|
||||
redownloadFailed: '重新下载失败',
|
||||
startResume: '开始/恢复',
|
||||
addToQueue: '添加到队列',
|
||||
copyAddress: '复制地址',
|
||||
copyMagnet: '复制磁力链接',
|
||||
remove: '移除',
|
||||
open: '打开',
|
||||
showInFolder: '在文件夹中显示',
|
||||
@@ -603,8 +643,11 @@ const zhCN = {
|
||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。',
|
||||
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
||||
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
||||
torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
|
||||
torrentPrioritizePiece: '为预览优先下载开头/结尾片段',
|
||||
torrentPrioritizePieceHead: '优先下载开头片段',
|
||||
torrentPrioritizePieceTail: '优先下载结尾片段',
|
||||
torrentPrioritizePieceSize: '预览片段范围大小',
|
||||
torrentPrioritizePieceHint: '选择开头片段、结尾片段或两者用于预览。每个启用的范围默认为 1M,并在下次启动或重试时应用。',
|
||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||
torrentEncryptionPolicy: 'Torrent 加密策略',
|
||||
torrentEncryptionPolicyHint: '随 Torrent 保存,并在下次启动或重试时应用。所选策略会保持 Aria2 加密设置一致。',
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgre
|
||||
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
||||
import type { TorrentDetails } from './bindings/TorrentDetails';
|
||||
import type { TorrentFileSelectionSnapshot } from './bindings/TorrentFileSelectionSnapshot';
|
||||
import type { TorrentAvailabilitySnapshot } from './bindings/TorrentAvailabilitySnapshot';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -89,7 +90,12 @@ type CommandMap = {
|
||||
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 };
|
||||
get_torrent_availability: { args: { id: string }; result: TorrentAvailabilitySnapshot };
|
||||
verify_torrent_data: { args: { id: string }; result: void };
|
||||
get_torrent_magnet_link: { args: { id: string }; result: string };
|
||||
export_torrent_metadata: { args: { id: string; destination: string }; result: void };
|
||||
move_torrent_data: { args: { id: string; destination: string }; result: void };
|
||||
cancel_torrent_move_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 };
|
||||
@@ -169,6 +175,7 @@ type EventMap = {
|
||||
'schedule-trigger': { action: 'start' | 'stop'; key: string };
|
||||
'download-progress': DownloadProgressEvent;
|
||||
'download-state': DownloadStateEvent;
|
||||
'torrent-move-progress': import('./bindings/TorrentMoveProgressEvent').TorrentMoveProgressEvent;
|
||||
'download-complete': string;
|
||||
'download-failed': string;
|
||||
'extension-add-download': ExtensionDownload;
|
||||
|
||||
@@ -3,12 +3,16 @@ import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
moveProgressMap: Record<string, number>;
|
||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||
clearDownloadProgress: (id: string) => void;
|
||||
setMoveProgress: (id: string, fraction: number) => void;
|
||||
clearMoveProgress: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
||||
progressMap: {},
|
||||
moveProgressMap: {},
|
||||
updateDownloadProgress: (id, payload) =>
|
||||
set((state) => ({
|
||||
progressMap: {
|
||||
@@ -18,9 +22,22 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
|
||||
})),
|
||||
clearDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.progressMap)) return state;
|
||||
if (!(id in state.progressMap) && !(id in state.moveProgressMap)) return state;
|
||||
const next = { ...state.progressMap };
|
||||
delete next[id];
|
||||
return { progressMap: next };
|
||||
const nextMove = { ...state.moveProgressMap };
|
||||
delete nextMove[id];
|
||||
return { progressMap: next, moveProgressMap: nextMove };
|
||||
}),
|
||||
setMoveProgress: (id, fraction) =>
|
||||
set((state) => ({
|
||||
moveProgressMap: { ...state.moveProgressMap, [id]: fraction }
|
||||
})),
|
||||
clearMoveProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.moveProgressMap)) return state;
|
||||
const next = { ...state.moveProgressMap };
|
||||
delete next[id];
|
||||
return { moveProgressMap: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('useDownloadProgressStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
||||
useDownloadProgressStore.setState({ progressMap: {} });
|
||||
useDownloadProgressStore.setState({ progressMap: {}, moveProgressMap: {} });
|
||||
clearDownloadControlIntents();
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('useDownloadProgressStore', () => {
|
||||
const first = initDownloadListener();
|
||||
const second = initDownloadListener();
|
||||
|
||||
expect(ipc.listenEvent).toHaveBeenCalledTimes(3);
|
||||
expect(ipc.listenEvent).toHaveBeenCalledTimes(4);
|
||||
|
||||
const releaseFirst = await first;
|
||||
const releaseSecond = await second;
|
||||
@@ -51,7 +51,7 @@ describe('useDownloadProgressStore', () => {
|
||||
expect(unlisten).not.toHaveBeenCalled();
|
||||
|
||||
releaseSecond();
|
||||
expect(unlisten).toHaveBeenCalledTimes(3);
|
||||
expect(unlisten).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export { useDownloadProgressStore } from './downloadProgressStore';
|
||||
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
let unlistenState: UnlistenFn | null = null;
|
||||
let unlistenMoveProgress: UnlistenFn | null = null;
|
||||
let unlistenTray: UnlistenFn | null = null;
|
||||
let listenerSetup: Promise<void> | null = null;
|
||||
let listenerConsumers = 0;
|
||||
@@ -25,6 +26,8 @@ const disposeDownloadListeners = () => {
|
||||
unlistenProgress = null;
|
||||
unlistenState?.();
|
||||
unlistenState = null;
|
||||
unlistenMoveProgress?.();
|
||||
unlistenMoveProgress = null;
|
||||
unlistenTray?.();
|
||||
unlistenTray = null;
|
||||
listenerSetup = null;
|
||||
@@ -71,6 +74,20 @@ const startDownloadListeners = async () => {
|
||||
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
|
||||
updates.totalIsEstimate = payload.total_is_estimate;
|
||||
}
|
||||
if (current.isTorrent) {
|
||||
if (payload.uploaded_bytes !== null
|
||||
&& payload.uploaded_bytes !== undefined
|
||||
&& Number.isSafeInteger(payload.uploaded_bytes)
|
||||
&& payload.uploaded_bytes >= 0) {
|
||||
updates.torrentUploadedBytes = payload.uploaded_bytes;
|
||||
}
|
||||
if (payload.torrent_seeded_seconds !== null
|
||||
&& payload.torrent_seeded_seconds !== undefined
|
||||
&& Number.isSafeInteger(payload.torrent_seeded_seconds)
|
||||
&& payload.torrent_seeded_seconds >= 0) {
|
||||
updates.torrentSeededSeconds = payload.torrent_seeded_seconds;
|
||||
}
|
||||
}
|
||||
const observedDownloadedBytes = Math.max(
|
||||
current.downloadedBytes ?? 0,
|
||||
payload.downloaded_bytes ?? 0
|
||||
@@ -103,6 +120,9 @@ const startDownloadListeners = async () => {
|
||||
return;
|
||||
}
|
||||
const status = payload.status as DownloadStatus;
|
||||
if (status !== 'moving') {
|
||||
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
|
||||
}
|
||||
|
||||
// resume_download queues the row before the backend can emit its new
|
||||
// active state. Paused events already emitted by the old lifecycle may
|
||||
@@ -120,7 +140,7 @@ const startDownloadListeners = async () => {
|
||||
// applied while the transition is in flight.
|
||||
return;
|
||||
}
|
||||
if (status === 'downloading' || status === 'processing' ||
|
||||
if (status === 'downloading' || status === 'processing' || status === 'moving' ||
|
||||
status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' ||
|
||||
status === 'completed' || status === 'failed') {
|
||||
clearDownloadControlIntent(payload.id, 'resume');
|
||||
@@ -136,13 +156,14 @@ const startDownloadListeners = async () => {
|
||||
// before asking the backend to resume, so an active event arriving while
|
||||
// the row is still paused cannot represent a new lifecycle.
|
||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||
status !== current.status) {
|
||||
status !== current.status && status !== 'moving') {
|
||||
return;
|
||||
}
|
||||
if (current.status === 'paused' &&
|
||||
status !== 'paused' &&
|
||||
status !== 'completed' &&
|
||||
status !== 'failed') {
|
||||
status !== 'failed' &&
|
||||
status !== 'moving') {
|
||||
return;
|
||||
}
|
||||
if (current.status === 'seeding' &&
|
||||
@@ -150,7 +171,8 @@ const startDownloadListeners = async () => {
|
||||
status !== 'waitingToSeed' &&
|
||||
status !== 'paused' &&
|
||||
status !== 'completed' &&
|
||||
status !== 'failed') {
|
||||
status !== 'failed' &&
|
||||
status !== 'moving') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -158,8 +180,14 @@ const startDownloadListeners = async () => {
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
const moveRestoreStatus = status === 'moving'
|
||||
? current.status === 'paused' || current.status === 'completed' || current.status === 'failed'
|
||||
? current.status
|
||||
: current.torrentMoveRestoreStatus
|
||||
: undefined;
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
torrentMoveRestoreStatus: moveRestoreStatus,
|
||||
...(progress ? {
|
||||
fraction: progress.fraction,
|
||||
...(progress.downloaded_bytes != null
|
||||
@@ -221,6 +249,17 @@ const startDownloadListeners = async () => {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
}
|
||||
}),
|
||||
listen('torrent-move-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
const current = useDownloadStore.getState().downloads.find(d => d.id === payload.id);
|
||||
if (!current || current.status !== 'moving') {
|
||||
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(payload.fraction) && payload.fraction >= 0 && payload.fraction <= 1) {
|
||||
useDownloadProgressStore.getState().setMoveProgress(payload.id, payload.fraction);
|
||||
}
|
||||
}),
|
||||
listen('tray-action', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
if (event.payload === 'pause-all') {
|
||||
@@ -241,13 +280,15 @@ const startDownloadListeners = async () => {
|
||||
throw failedRegistration.reason;
|
||||
}
|
||||
|
||||
const [progress, state, tray] = registrations as [
|
||||
const [progress, state, moveProgress, tray] = registrations as [
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
];
|
||||
unlistenProgress = progress.value;
|
||||
unlistenState = state.value;
|
||||
unlistenMoveProgress = moveProgress.value;
|
||||
unlistenTray = tray.value;
|
||||
};
|
||||
|
||||
|
||||
@@ -938,6 +938,24 @@ describe('useDownloadStore', () => {
|
||||
expect(normalized.torrentEncryptionPolicy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recovers an interrupted Torrent move without discarding the native destination marker', () => {
|
||||
const normalized = normalizePersistedDownloadProgress({
|
||||
id: 'interrupted-torrent-move',
|
||||
url: 'magnet:?xt=urn:btih:bad',
|
||||
fileName: 'payload',
|
||||
status: 'moving',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
destination: '/downloads/new',
|
||||
torrentMoveDestination: '/downloads/new',
|
||||
torrentMoveRestoreStatus: 'paused'
|
||||
});
|
||||
|
||||
expect(normalized.status).toBe('paused');
|
||||
expect(normalized.torrentMoveDestination).toBe('/downloads/new');
|
||||
expect(normalized.torrentMoveRestoreStatus).toBe('paused');
|
||||
});
|
||||
|
||||
it('normalizes proxy settings for download dispatch', async () => {
|
||||
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
|
||||
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
|
||||
|
||||
@@ -352,7 +352,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
torrent_check_integrity: item.torrentCheckIntegrity,
|
||||
torrent_check_integrity: item.torrentCheckIntegrity || item.torrentRelocationCheckPending,
|
||||
torrent_trackers: item.torrentTrackers || undefined,
|
||||
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
|
||||
@@ -636,6 +636,18 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
rawSeedRemaining >= 0
|
||||
? rawSeedRemaining
|
||||
: undefined;
|
||||
const rawUploadedBytes = download.torrentUploadedBytes as unknown;
|
||||
const normalizedUploadedBytes = typeof rawUploadedBytes === 'number'
|
||||
&& Number.isSafeInteger(rawUploadedBytes)
|
||||
&& rawUploadedBytes >= 0
|
||||
? rawUploadedBytes
|
||||
: rawUploadedBytes === undefined ? undefined : 0;
|
||||
const rawSeededSeconds = download.torrentSeededSeconds as unknown;
|
||||
const normalizedSeededSeconds = typeof rawSeededSeconds === 'number'
|
||||
&& Number.isSafeInteger(rawSeededSeconds)
|
||||
&& rawSeededSeconds >= 0
|
||||
? rawSeededSeconds
|
||||
: rawSeededSeconds === undefined ? undefined : 0;
|
||||
const rawMaxPeers = download.torrentMaxPeers as unknown;
|
||||
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
|
||||
Number.isInteger(rawMaxPeers) &&
|
||||
@@ -654,6 +666,17 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
(seed as { uri: string }).uri.length <= 2048
|
||||
).slice(0, 256)
|
||||
: undefined;
|
||||
const rawNativeWebSeeds = download.torrentWebSeedsNative as unknown;
|
||||
const normalizedNativeWebSeeds = Array.isArray(rawNativeWebSeeds)
|
||||
? rawNativeWebSeeds.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
|
||||
@@ -697,6 +720,24 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
const normalizedFileAllocation = normalizeTorrentFileAllocation(rawFileAllocation);
|
||||
const rawVerifyOnly = download.torrentVerifyOnly as unknown;
|
||||
const normalizedVerifyOnly = rawVerifyOnly === true ? true : undefined;
|
||||
const rawRelocationCheckPending = download.torrentRelocationCheckPending as unknown;
|
||||
const normalizedRelocationCheckPending = rawRelocationCheckPending === true ? true : undefined;
|
||||
const rawMoveDestination = download.torrentMoveDestination as unknown;
|
||||
const normalizedMoveDestination = typeof rawMoveDestination === 'string'
|
||||
&& rawMoveDestination.trim()
|
||||
? rawMoveDestination
|
||||
: undefined;
|
||||
const rawMoveRestoreStatus = download.torrentMoveRestoreStatus as unknown;
|
||||
const normalizedMoveRestoreStatus: DownloadStatus | undefined = download.status === 'moving' && (
|
||||
rawMoveRestoreStatus === 'paused'
|
||||
|| rawMoveRestoreStatus === 'completed'
|
||||
|| rawMoveRestoreStatus === 'failed'
|
||||
)
|
||||
? rawMoveRestoreStatus as DownloadStatus
|
||||
: undefined;
|
||||
const recoveredMoveStatus = download.status === 'moving'
|
||||
? normalizedMoveRestoreStatus || 'failed'
|
||||
: download.status;
|
||||
const rawVerifyRestoreStatus = download.torrentVerifyRestoreStatus as unknown;
|
||||
const normalizedVerifyRestoreStatus = normalizedVerifyOnly === true
|
||||
&& typeof rawVerifyRestoreStatus === 'string'
|
||||
@@ -704,7 +745,10 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
? rawVerifyRestoreStatus
|
||||
: undefined;
|
||||
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
|
||||
rawUploadedBytes !== normalizedUploadedBytes ||
|
||||
rawSeededSeconds !== normalizedSeededSeconds ||
|
||||
rawWebSeeds !== normalizedWebSeeds ||
|
||||
rawNativeWebSeeds !== normalizedNativeWebSeeds ||
|
||||
rawMaxPeers !== normalizedMaxPeers ||
|
||||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
|
||||
rawCheckIntegrity !== normalizedCheckIntegrity ||
|
||||
@@ -719,11 +763,19 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
rawEncryptionPolicy !== normalizedEncryptionPolicy ||
|
||||
rawFileAllocation !== normalizedFileAllocation ||
|
||||
rawVerifyOnly !== normalizedVerifyOnly ||
|
||||
rawRelocationCheckPending !== normalizedRelocationCheckPending ||
|
||||
rawMoveDestination !== normalizedMoveDestination ||
|
||||
rawMoveRestoreStatus !== normalizedMoveRestoreStatus ||
|
||||
recoveredMoveStatus !== download.status ||
|
||||
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus
|
||||
? {
|
||||
? {
|
||||
...download,
|
||||
status: recoveredMoveStatus,
|
||||
torrentSeedRemaining: normalizedSeedRemaining,
|
||||
torrentUploadedBytes: normalizedUploadedBytes,
|
||||
torrentSeededSeconds: normalizedSeededSeconds,
|
||||
torrentWebSeeds: normalizedWebSeeds,
|
||||
torrentWebSeedsNative: normalizedNativeWebSeeds,
|
||||
torrentMaxPeers: normalizedMaxPeers,
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
|
||||
torrentCheckIntegrity: normalizedCheckIntegrity,
|
||||
@@ -738,9 +790,14 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
torrentEncryptionPolicy: normalizedEncryptionPolicy,
|
||||
torrentFileAllocation: normalizedFileAllocation,
|
||||
torrentVerifyOnly: normalizedVerifyOnly,
|
||||
torrentRelocationCheckPending: normalizedRelocationCheckPending,
|
||||
torrentMoveDestination: normalizedMoveDestination,
|
||||
torrentMoveRestoreStatus: normalizedMoveRestoreStatus,
|
||||
torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus
|
||||
}
|
||||
: download;
|
||||
: recoveredMoveStatus !== download.status
|
||||
? { ...download, status: recoveredMoveStatus, torrentMoveRestoreStatus: normalizedMoveRestoreStatus }
|
||||
: download;
|
||||
|
||||
return hasStaleTemporaryMediaEstimate(normalizedOptions)
|
||||
? {
|
||||
@@ -2300,7 +2357,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
torrent_check_integrity: item.torrentCheckIntegrity,
|
||||
torrent_check_integrity: item.torrentCheckIntegrity || item.torrentRelocationCheckPending,
|
||||
torrent_trackers: item.torrentTrackers || undefined,
|
||||
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from './downloads';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
import type { TorrentFile } from '../bindings/TorrentFile';
|
||||
import type { TorrentWebSeedDraft } from './downloads';
|
||||
import i18n from '../i18n';
|
||||
import { localePluralVariant } from '../i18n/locales';
|
||||
|
||||
@@ -67,6 +68,7 @@ export interface AddDownloadDraftRow {
|
||||
torrentCheckIntegrity?: boolean;
|
||||
torrentTrackers?: string;
|
||||
torrentExcludeTrackers?: string;
|
||||
torrentWebSeedRows?: TorrentWebSeedDraft[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,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 === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying';
|
||||
status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying' || status === 'moving';
|
||||
|
||||
export const isIdentityLocked = (status: DownloadStatus): boolean =>
|
||||
isTransferLocked(status) || status === 'completed';
|
||||
|
||||
@@ -34,6 +34,30 @@ export const formatDownloadBytes = (bytes: number): string => {
|
||||
return `${formatDownloadBytesInUnit(bytes, unitIndex)} ${BYTE_UNITS[unitIndex]}`;
|
||||
};
|
||||
|
||||
export const formatTorrentDuration = (seconds: number, locale: string): string => {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '—';
|
||||
const rounded = Math.round(seconds);
|
||||
const unit = (value: number, name: 'hour' | 'minute' | 'second') =>
|
||||
new Intl.NumberFormat(locale, { style: 'unit', unit: name, unitDisplay: 'short' }).format(value);
|
||||
if (rounded >= 3600) {
|
||||
return `${unit(Math.floor(rounded / 3600), 'hour')} ${unit(Math.floor((rounded % 3600) / 60), 'minute')}`;
|
||||
}
|
||||
if (rounded >= 60) {
|
||||
return `${unit(Math.floor(rounded / 60), 'minute')} ${unit(rounded % 60, 'second')}`;
|
||||
}
|
||||
return unit(rounded, 'second');
|
||||
};
|
||||
|
||||
export const formatTorrentRatio = (uploadedBytes: number, denominatorBytes: number, locale: string): string => {
|
||||
if (!Number.isFinite(uploadedBytes) || uploadedBytes < 0 || !Number.isFinite(denominatorBytes) || denominatorBytes <= 0) {
|
||||
return '—';
|
||||
}
|
||||
return new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2
|
||||
}).format(uploadedBytes / denominatorBytes);
|
||||
};
|
||||
|
||||
export const formatDownloadTotal = (display: DownloadSizeDisplay): string =>
|
||||
display.total && display.unit
|
||||
? `${display.totalIsEstimate ? '~' : ''}${display.total} ${display.unit}`
|
||||
@@ -72,6 +96,7 @@ export const downloadProgressColorClass = (status: string): string => {
|
||||
case 'failed':
|
||||
return 'download-status-failed';
|
||||
case 'processing':
|
||||
case 'moving':
|
||||
return 'download-status-processing';
|
||||
case 'verifying':
|
||||
return 'download-status-processing';
|
||||
|
||||
@@ -23,7 +23,8 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
|
||||
status === 'seeding' ||
|
||||
status === 'processing' ||
|
||||
status === 'verifying' ||
|
||||
status === 'retrying';
|
||||
status === 'retrying' ||
|
||||
status === 'moving';
|
||||
|
||||
const hasPositiveProgress = (download: DownloadItem): boolean =>
|
||||
typeof download.fraction === 'number' &&
|
||||
@@ -81,7 +82,7 @@ export const summarizeDownloads = (
|
||||
|
||||
for (const download of downloads) {
|
||||
const state = effectiveByteState(download, progressMap[download.id]);
|
||||
if (isTransferActiveStatus(download.status)) activeCount += 1;
|
||||
if (isTransferActiveStatus(download.status) || download.status === 'moving') activeCount += 1;
|
||||
if (state.downloadedBytes === undefined) {
|
||||
downloadedKnown = false;
|
||||
} else {
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
normalizeTorrentEncryptionPolicy,
|
||||
normalizeTorrentMaxOpenFiles,
|
||||
normalizeTorrentPrioritizePiece,
|
||||
normalizeTorrentWebSeedDrafts,
|
||||
parseTorrentPreviewPriority,
|
||||
serializeTorrentPreviewPriority,
|
||||
torrentWebSeedDraftsFromSeeds,
|
||||
normalizeTorrentTrackerInterval,
|
||||
normalizeTorrentTrackerTimeout,
|
||||
redactDownloadForPersistence,
|
||||
@@ -104,6 +108,52 @@ describe('Torrent piece priority validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Torrent preview controls', () => {
|
||||
it('hydrates legacy head and tail syntax into independent controls', () => {
|
||||
expect(parseTorrentPreviewPriority('tail=64k, HEAD')).toEqual({ head: '1M', tail: '64K' });
|
||||
});
|
||||
|
||||
it('serializes enabled controls with native-compatible defaults', () => {
|
||||
expect(serializeTorrentPreviewPriority(true, '', true, '2m')).toBe('head=1M,tail=2M');
|
||||
expect(serializeTorrentPreviewPriority(false, '1M', false, '1M')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Torrent web-seed row normalization', () => {
|
||||
const files = [{ index: 1 }, { index: 2 }];
|
||||
|
||||
it('round-trips rows and removes exact duplicates', () => {
|
||||
const rows = torrentWebSeedDraftsFromSeeds([
|
||||
{ fileIndex: 1, uri: 'https://mirror.example/a' },
|
||||
{ fileIndex: 1, uri: 'https://mirror.example/a' }
|
||||
]);
|
||||
expect(normalizeTorrentWebSeedDrafts(rows, files)).toEqual([
|
||||
{ fileIndex: 1, uri: 'https://mirror.example/a' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the only file for a fixed single-file selector', () => {
|
||||
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: null, uri: 'https://mirror.example/a' }], [{ index: 1 }])).toEqual([
|
||||
{ fileIndex: 1, uri: 'https://mirror.example/a' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects unsafe, incomplete, and out-of-range rows', () => {
|
||||
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: 1, uri: 'ftp://mirror.example/a' }], files)).toBeNull();
|
||||
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: null, uri: 'https://mirror.example/a' }], files)).toBeNull();
|
||||
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: 9, uri: 'https://mirror.example/a' }], files)).toBeNull();
|
||||
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: 1, uri: 'https://user:pass@mirror.example/a' }], files)).toBeNull();
|
||||
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: 1, uri: `https://mirror.example/${'é'.repeat(1100)}` }], files)).toBeNull();
|
||||
});
|
||||
|
||||
it('bounds the number of rows before they reach the native boundary', () => {
|
||||
expect(normalizeTorrentWebSeedDrafts(
|
||||
Array.from({ length: 65 }, (_, index) => ({ fileIndex: 1, uri: `https://mirror.example/${index}` })),
|
||||
files
|
||||
)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Torrent encryption policy validation', () => {
|
||||
it('accepts only the canonical policy states', () => {
|
||||
expect(normalizeTorrentEncryptionPolicy('disabled')).toBe('disabled');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DownloadCategory } from '../bindings/DownloadCategory';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { TorrentWebSeed } from '../bindings/TorrentWebSeed';
|
||||
export type { DownloadCategory } from '../bindings/DownloadCategory';
|
||||
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
@@ -34,6 +35,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
||||
'seeding',
|
||||
'waitingToSeed',
|
||||
'retrying',
|
||||
'moving',
|
||||
]);
|
||||
|
||||
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
||||
@@ -214,6 +216,8 @@ const MAX_TORRENT_TRACKERS = 64;
|
||||
const MAX_TORRENT_TRACKER_BYTES = 16 * 1024;
|
||||
export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60;
|
||||
const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB = 1024;
|
||||
const MAX_TORRENT_WEB_SEEDS = 64;
|
||||
const MAX_TORRENT_WEB_SEED_URI_BYTES = 2048;
|
||||
|
||||
const normalizeTorrentPiecePrioritySize = (value: string): string | null => {
|
||||
const match = value.trim().match(/^(\d+)\s*([km])$/i);
|
||||
@@ -267,6 +271,85 @@ export const normalizeTorrentPrioritizePiece = (value?: string | null): string |
|
||||
return [head, tail].filter((part): part is string => Boolean(part)).join(',') || null;
|
||||
};
|
||||
|
||||
export type TorrentPreviewPriority = {
|
||||
head: string;
|
||||
tail: string;
|
||||
};
|
||||
|
||||
export const parseTorrentPreviewPriority = (value?: string | null): TorrentPreviewPriority => {
|
||||
const normalized = normalizeTorrentPrioritizePiece(value);
|
||||
const result: TorrentPreviewPriority = { head: '', tail: '' };
|
||||
for (const token of normalized?.split(',') ?? []) {
|
||||
const [keyword, size] = token.split('=', 2);
|
||||
result[keyword as 'head' | 'tail'] = size || '1M';
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const serializeTorrentPreviewPriority = (
|
||||
headEnabled: boolean,
|
||||
headSize: string,
|
||||
tailEnabled: boolean,
|
||||
tailSize: string
|
||||
): string | null => normalizeTorrentPrioritizePiece([
|
||||
headEnabled ? `head=${headSize.trim() || '1M'}` : '',
|
||||
tailEnabled ? `tail=${tailSize.trim() || '1M'}` : ''
|
||||
].filter(Boolean).join(','));
|
||||
|
||||
export type TorrentWebSeedDraft = {
|
||||
fileIndex: number | null;
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export const torrentWebSeedDraftsFromSeeds = (
|
||||
seeds: readonly TorrentWebSeed[] | undefined
|
||||
): TorrentWebSeedDraft[] => (seeds ?? []).map(seed => ({
|
||||
fileIndex: seed.fileIndex,
|
||||
uri: seed.uri
|
||||
}));
|
||||
|
||||
type TorrentWebSeedFile = { index: number };
|
||||
|
||||
export const normalizeTorrentWebSeedDrafts = (
|
||||
drafts: readonly TorrentWebSeedDraft[],
|
||||
files: readonly TorrentWebSeedFile[]
|
||||
): TorrentWebSeed[] | null => {
|
||||
if (drafts.length > MAX_TORRENT_WEB_SEEDS) return null;
|
||||
const fileIndices = new Set(files.map(file => file.index));
|
||||
const normalized: TorrentWebSeed[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const draft of drafts) {
|
||||
const fileIndex = files.length === 1 ? files[0].index : draft.fileIndex;
|
||||
const uri = draft.uri.trim();
|
||||
if (
|
||||
fileIndex === null
|
||||
|| !fileIndices.has(fileIndex)
|
||||
|| !uri
|
||||
|| new TextEncoder().encode(uri).length > MAX_TORRENT_WEB_SEED_URI_BYTES
|
||||
) return null;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(uri);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(parsed.protocol)
|
||||
|| !parsed.hostname
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.hash
|
||||
|| /[\u0000-\u001f\u007f]/u.test(uri)
|
||||
) return null;
|
||||
const normalizedUri = parsed.toString();
|
||||
const key = `${fileIndex}\u0000${normalizedUri}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push({ fileIndex, uri: normalizedUri });
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs the same user-facing safety checks as the native tracker boundary.
|
||||
* The Rust validator remains authoritative because persisted data can bypass
|
||||
|
||||
Reference in New Issue
Block a user