mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-22 08:56:44 +00:00
fix(torrent): harden metadata reuse and live peer telemetry
- reuse validated tracker-bearing metainfo without restoring direct web seeds - preserve Torrent file selection while making long paths scrollable and copyable - add lifecycle-fenced peer and seeder summaries to Properties telemetry - validate cache tracker metadata and cover malformed, stale, and path-copy cases
This commit is contained in:
@@ -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 TorrentPeerSummary = { totalPeers: number, totalSeeders: number, };
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||
import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
|
||||
import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, Copy, type LucideIcon } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
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, 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';
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
} from '../utils/addDownloadMetadata';
|
||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
|
||||
import { copyTorrentFilePath as writeTorrentFilePath } from '../utils/torrentFilePath';
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const k = 1024;
|
||||
@@ -187,6 +189,16 @@ export const AddDownloadsModal = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const copyTorrentFilePath = useCallback(async (path: string) => {
|
||||
try {
|
||||
await writeTorrentFilePath(path, writeClipboardText);
|
||||
addToast({ message: t($ => $.logs.copied), variant: 'success' });
|
||||
} catch (error) {
|
||||
console.warn('Failed to copy Torrent file path:', error);
|
||||
addToast({ message: t($ => $.downloadTable.copyPathFailed), variant: 'error', isActionable: true });
|
||||
}
|
||||
}, [addToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddModalOpen) cleanupDraftTorrentCache();
|
||||
}, [cleanupDraftTorrentCache, isAddModalOpen]);
|
||||
@@ -2219,20 +2231,35 @@ export const AddDownloadsModal = () => {
|
||||
const selectedIndices = parsedItems[selectedItemIndex!].selectedTorrentFileIndices;
|
||||
const checked = !selectedIndices || selectedIndices.includes(file.index);
|
||||
return (
|
||||
<label
|
||||
<div
|
||||
key={file.index}
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-xs text-text-secondary hover:bg-surface-hover rounded"
|
||||
className="flex min-w-0 items-center gap-2 rounded px-2 py-1.5 text-xs text-text-secondary hover:bg-surface-hover"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTorrentFile(file.index)}
|
||||
aria-label={file.path}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
<span className="truncate flex-1" title={file.path}>{file.path}</span>
|
||||
<span className="font-mono text-text-muted shrink-0">{formatBytes(file.length)}</span>
|
||||
</label>
|
||||
<label className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTorrentFile(file.index)}
|
||||
aria-label={file.path}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap" dir="ltr" title={file.path}>{file.path}</span>
|
||||
<span className="font-mono text-text-muted shrink-0">{formatBytes(file.length)}</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="app-icon-button shrink-0"
|
||||
aria-label={t($ => $.downloadTable.copyFilePath)}
|
||||
title={t($ => $.downloadTable.copyFilePath)}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void copyTorrentFilePath(file.path);
|
||||
}}
|
||||
>
|
||||
<Copy size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilit
|
||||
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
||||
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
|
||||
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
|
||||
import type { TorrentPeerSummary } from '../bindings/TorrentPeerSummary';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import {
|
||||
PROPERTIES_WINDOW_ACTION_RESULT,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
|
||||
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
|
||||
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
||||
import { isCurrentTorrentPeerSummary, isTorrentPeerSummaryStatus } from '../utils/propertiesPeerSummary';
|
||||
import { WindowControls } from './WindowControls';
|
||||
import {
|
||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||
@@ -66,8 +68,7 @@ const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'
|
||||
const isTorrentDiagnosticsStatus = (status: string) =>
|
||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status);
|
||||
|
||||
const isTorrentPollingStatus = (status: string) =>
|
||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
|
||||
const isTorrentPollingStatus = isTorrentPeerSummaryStatus;
|
||||
|
||||
const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status);
|
||||
|
||||
@@ -214,6 +215,7 @@ export const PropertiesWindowApp = () => {
|
||||
const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | 'cancel' | null>(null);
|
||||
const [fileProgress, setFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
|
||||
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
|
||||
const [peerSummary, setPeerSummary] = useState<TorrentPeerSummary | null>(null);
|
||||
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
||||
const [details, setDetails] = useState<TorrentDetails | null>(null);
|
||||
const [diagnosticError, setDiagnosticError] = useState('');
|
||||
@@ -267,11 +269,13 @@ export const PropertiesWindowApp = () => {
|
||||
const revealInFlightRef = useRef(false);
|
||||
const readyRetryTimerRef = useRef<number | undefined>(undefined);
|
||||
const diagnosticsInFlightRef = useRef(new Set<string>());
|
||||
const peerSummaryInFlightRef = useRef(new Set<string>());
|
||||
const snapshotRef = useRef(snapshot);
|
||||
const activeTabRef = useRef(activeTab);
|
||||
const downloadIdRef = useRef(downloadId);
|
||||
const fileProgressRef = useRef(fileProgress);
|
||||
const peersRef = useRef(peers);
|
||||
const peerSummaryRef = useRef(peerSummary);
|
||||
const availabilityRef = useRef(availability);
|
||||
const detailsRef = useRef(details);
|
||||
const diagnosticAttemptsRef = useRef(new Set<string>());
|
||||
@@ -289,6 +293,7 @@ export const PropertiesWindowApp = () => {
|
||||
downloadIdRef.current = downloadId;
|
||||
fileProgressRef.current = fileProgress;
|
||||
peersRef.current = peers;
|
||||
peerSummaryRef.current = peerSummary;
|
||||
availabilityRef.current = availability;
|
||||
detailsRef.current = details;
|
||||
|
||||
@@ -506,7 +511,19 @@ export const PropertiesWindowApp = () => {
|
||||
invoke('get_torrent_availability', { id }),
|
||||
]);
|
||||
if (isCurrent()) {
|
||||
if (peerResult.status === 'fulfilled') setPeers(peerResult.value);
|
||||
if (peerResult.status === 'fulfilled') {
|
||||
setPeers(peerResult.value);
|
||||
if (isTorrentPollingStatus(snapshotRef.current?.status ?? '')) {
|
||||
peerSummaryRef.current = {
|
||||
totalPeers: peerResult.value.totalPeers,
|
||||
totalSeeders: peerResult.value.totalSeeders,
|
||||
};
|
||||
setPeerSummary(peerSummaryRef.current);
|
||||
}
|
||||
} else if (isExpectedPropertiesDiagnosticUnavailable(peerResult.reason)) {
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
}
|
||||
if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value);
|
||||
const peerOutcome = peerResult.status === 'fulfilled'
|
||||
? 'success'
|
||||
@@ -571,6 +588,40 @@ export const PropertiesWindowApp = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshPeerSummary = useCallback(async (id: string) => {
|
||||
if (!isTorrentPollingStatus(snapshotRef.current?.status ?? '')) return;
|
||||
const requestLifecycleEpoch = diagnosticLifecycleEpochRef.current;
|
||||
const requestKey = `${id}:${requestLifecycleEpoch}`;
|
||||
if (peerSummaryInFlightRef.current.has(requestKey)) return;
|
||||
peerSummaryInFlightRef.current.add(requestKey);
|
||||
const isCurrent = () => isCurrentTorrentPeerSummary({
|
||||
currentDownloadId: downloadIdRef.current,
|
||||
requestDownloadId: id,
|
||||
currentLifecycleEpoch: diagnosticLifecycleEpochRef.current,
|
||||
requestLifecycleEpoch,
|
||||
currentStatus: snapshotRef.current?.status ?? '',
|
||||
});
|
||||
try {
|
||||
const nextSummary = await invoke('get_torrent_peer_summary', { id });
|
||||
if (isCurrent()) {
|
||||
peerSummaryRef.current = nextSummary;
|
||||
setPeerSummary(nextSummary);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
// A GID replacement or terminal transition can invalidate an in-flight
|
||||
// summary after Aria2 has already answered. The next fenced poll will
|
||||
// acquire the new GID; do not turn that expected transition into a
|
||||
// repeating Properties error.
|
||||
if (isExpectedPropertiesDiagnosticUnavailable(error)) {
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
}
|
||||
} finally {
|
||||
peerSummaryInFlightRef.current.delete(requestKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let readyHeartbeatTimer: number | undefined;
|
||||
@@ -595,6 +646,8 @@ export const PropertiesWindowApp = () => {
|
||||
diagnosticLifecycleEpochRef.current += 1;
|
||||
diagnosticLifecycleKeyRef.current = '';
|
||||
diagnosticAttemptsRef.current.clear();
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
const lostAction = pendingActionRef.current;
|
||||
const lostDraftAction = lostAction === 'apply-properties'
|
||||
|| lostAction === 'set-torrent-file-selection';
|
||||
@@ -637,6 +690,8 @@ export const PropertiesWindowApp = () => {
|
||||
setFileProgress(null);
|
||||
setPeers(null);
|
||||
setAvailability(null);
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
setDiagnosticError('');
|
||||
setDiagnosticsLoading(false);
|
||||
setDiagnosticsRefreshing(false);
|
||||
@@ -723,6 +778,8 @@ export const PropertiesWindowApp = () => {
|
||||
diagnosticLifecycleEpochRef.current += 1;
|
||||
diagnosticLifecycleKeyRef.current = '';
|
||||
diagnosticAttemptsRef.current.clear();
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
setSnapshot(null);
|
||||
draftTabRef.current = null;
|
||||
isDirtyRef.current = false;
|
||||
@@ -797,6 +854,8 @@ export const PropertiesWindowApp = () => {
|
||||
setFileProgress(null);
|
||||
setPeers(null);
|
||||
setAvailability(null);
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
setDiagnosticError('');
|
||||
setDiagnosticsLoading(false);
|
||||
setDiagnosticsRefreshing(false);
|
||||
@@ -812,19 +871,30 @@ export const PropertiesWindowApp = () => {
|
||||
setFileProgress(null);
|
||||
setPeers(null);
|
||||
setAvailability(null);
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
diagnosticLifecycleEpochRef.current += 1;
|
||||
diagnosticAttemptsRef.current.clear();
|
||||
setDiagnosticPhase('idle');
|
||||
setPeerDiagnosticPhase('idle');
|
||||
setAvailabilityDiagnosticPhase('idle');
|
||||
}
|
||||
void refreshDiagnostics(activeTab, downloadId);
|
||||
if (!isTorrentPollingStatus(snapshot.status) || !['files', 'peers'].includes(activeTab)) return;
|
||||
if (isTorrentPollingStatus(snapshot.status) && activeTab !== 'peers') {
|
||||
void refreshPeerSummary(downloadId);
|
||||
}
|
||||
const shouldPollDiagnostics = ['files', 'peers'].includes(activeTab);
|
||||
const shouldPollSummary = activeTab !== 'peers';
|
||||
if (!isTorrentPollingStatus(snapshot.status) || (!shouldPollDiagnostics && !shouldPollSummary)) return;
|
||||
// Match the 1-second cadence of the normal Aria2 progress poll. The
|
||||
// diagnostics request itself is still single-flight, so a slow RPC cannot
|
||||
// create overlapping refreshes.
|
||||
const interval = window.setInterval(() => void refreshDiagnostics(activeTab, downloadId), 1000);
|
||||
const interval = window.setInterval(() => {
|
||||
if (shouldPollDiagnostics) void refreshDiagnostics(activeTab, downloadId);
|
||||
if (shouldPollSummary) void refreshPeerSummary(downloadId);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]);
|
||||
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, refreshPeerSummary, snapshot?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -1124,12 +1194,18 @@ export const PropertiesWindowApp = () => {
|
||||
? t($ => $.addDownloads.unknownSize)
|
||||
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
||||
const statusLabel = t($ => $.downloads.status[snapshot.status]);
|
||||
const connectionPresentation = getPropertiesConnectionPresentation(snapshot);
|
||||
const connectionPresentation = getPropertiesConnectionPresentation(snapshot, peerSummary);
|
||||
const connectionLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
|
||||
? t($ => $.properties.fragmentConcurrency)
|
||||
: connectionPresentation.labelKey === 'torrentConnectedPeers'
|
||||
? t($ => $.properties.torrentConnectedPeers)
|
||||
: t($ => $.properties.connections);
|
||||
const connectionValue = connectionPresentation.torrentPeerSummary
|
||||
? t($ => $.properties.torrentPeerSummary, {
|
||||
total: connectionPresentation.torrentPeerSummary.totalPeers,
|
||||
seeders: connectionPresentation.torrentPeerSummary.totalSeeders,
|
||||
})
|
||||
: connectionPresentation.value;
|
||||
const queuePlacement = formatPropertiesQueuePlacement(
|
||||
snapshot.queueName,
|
||||
snapshot.queuePosition,
|
||||
@@ -1221,7 +1297,7 @@ export const PropertiesWindowApp = () => {
|
||||
<div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
||||
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{snapshot.speed || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{snapshot.eta || '—'}</strong></div></div>
|
||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span>{connectionLabel}</span><strong>{connectionPresentation.value}</strong></div></div>}
|
||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span>{connectionLabel}</span><strong>{connectionValue}</strong></div></div>}
|
||||
{isTorrent && <>
|
||||
<div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
||||
<div className="properties-metric-card"><Activity size={14} /><div><span>{t($ => $.properties.torrentRatio)}</span><strong>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
|
||||
@@ -1579,7 +1655,7 @@ export const PropertiesWindowApp = () => {
|
||||
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
||||
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
||||
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<div><span className="text-text-muted">{connectionLabel}</span><p className="mt-1">{connectionPresentation.value}</p></div>
|
||||
<div><span className="text-text-muted">{connectionLabel}</span><p className="mt-1">{connectionValue}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.speedCap)}</span><p className="mt-1">{snapshot.speedLimit || '—'}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.username)}</span><p className="mt-1">{snapshot.hasUsername ? '✓' : '—'}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.password)}</span><p className="mt-1">{snapshot.hasPassword ? '✓' : '—'}</p></div>
|
||||
|
||||
@@ -345,6 +345,7 @@ const common = {
|
||||
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',
|
||||
torrentPeerSummary: '{{total}} peers · {{seeders}} seeders',
|
||||
torrentPeerDownload: 'Download',
|
||||
torrentPeerUpload: 'Upload',
|
||||
torrentPeerSeeder: 'Seeder',
|
||||
|
||||
@@ -345,6 +345,7 @@ const fa = {
|
||||
torrentWebSeedsRemove: 'حذف وبسید',
|
||||
torrentWebSeedsInvalid: 'هر ردیف وبسید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
|
||||
torrentPeerCount: '{{total}} همتا — {{seeders}} سید',
|
||||
torrentPeerSummary: '{{total}} همتا · {{seeders}} سید',
|
||||
torrentPeerDownload: 'دریافت',
|
||||
torrentPeerUpload: 'آپلود',
|
||||
torrentPeerSeeder: 'سید',
|
||||
|
||||
@@ -345,6 +345,7 @@ const he = {
|
||||
torrentWebSeedsRemove: 'הסר זריעת Web',
|
||||
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
|
||||
torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים',
|
||||
torrentPeerSummary: '{{total}} עמיתים · {{seeders}} משתפים',
|
||||
torrentPeerDownload: 'הורדה',
|
||||
torrentPeerUpload: 'העלאה',
|
||||
torrentPeerSeeder: 'משתף',
|
||||
|
||||
@@ -345,6 +345,7 @@ const ru = {
|
||||
torrentWebSeedsRemove: 'Удалить веб-сид',
|
||||
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
|
||||
torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров',
|
||||
torrentPeerSummary: '{{total}} пиров · {{seeders}} сидеров',
|
||||
torrentPeerDownload: 'Загрузка',
|
||||
torrentPeerUpload: 'Отдача',
|
||||
torrentPeerSeeder: 'Сидер',
|
||||
|
||||
@@ -345,6 +345,7 @@ const uk = {
|
||||
torrentWebSeedsRemove: 'Видалити вебсід',
|
||||
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
|
||||
torrentPeerCount: '{{total}} пірів — {{seeders}} сідів',
|
||||
torrentPeerSummary: '{{total}} пірів · {{seeders}} сідів',
|
||||
torrentPeerDownload: 'Завантаження',
|
||||
torrentPeerUpload: 'Віддача',
|
||||
torrentPeerSeeder: 'Сідер',
|
||||
|
||||
@@ -345,6 +345,7 @@ const zhCN = {
|
||||
torrentWebSeedsRemove: '移除 Web 做种',
|
||||
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
|
||||
torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点',
|
||||
torrentPeerSummary: '{{total}} 个节点 · {{seeders}} 个做种节点',
|
||||
torrentPeerDownload: '下载',
|
||||
torrentPeerUpload: '上传',
|
||||
torrentPeerSeeder: '做种',
|
||||
|
||||
@@ -1446,6 +1446,11 @@ html[data-list-density="relaxed"] {
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.app-icon-button:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-icon-button:active:not(:disabled) {
|
||||
background: hsl(var(--border-color));
|
||||
transform: scale(0.94);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
|
||||
import type { TorrentPeerSummary } from './bindings/TorrentPeerSummary';
|
||||
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
|
||||
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
|
||||
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
||||
@@ -94,6 +95,7 @@ type CommandMap = {
|
||||
result: void;
|
||||
};
|
||||
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
||||
get_torrent_peer_summary: { args: { id: string }; result: TorrentPeerSummary };
|
||||
get_torrent_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot };
|
||||
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
|
||||
get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
|
||||
|
||||
@@ -191,7 +191,6 @@ describe('Properties window bridge', () => {
|
||||
downloadedBytes: 3,
|
||||
totalBytes: 4,
|
||||
totalIsEstimate: false,
|
||||
connectedPeers: 4,
|
||||
torrentUploadedBytes: 9,
|
||||
uploadSpeed: '1 MiB/s',
|
||||
torrentSeeders: 6,
|
||||
@@ -403,6 +402,7 @@ describe('Properties window bridge', () => {
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has a stale control epoch'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent has a stale control epoch'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer diagnostics'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer summary'))).toBe(true);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getPeers failed: unavailable response'))).toBe(false);
|
||||
expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getFiles failed: connection refused'))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -179,7 +179,6 @@ export type PropertiesSnapshot = SafePropertiesFields & {
|
||||
lastResolverFallback?: boolean;
|
||||
activeConnections?: number;
|
||||
requestedConnections?: number;
|
||||
connectedPeers?: number;
|
||||
uploadSpeed?: string;
|
||||
torrentSeeders?: number;
|
||||
moveProgress?: number;
|
||||
@@ -425,9 +424,6 @@ const copyWithoutSecrets = (
|
||||
...(live.progress.total_is_estimate !== undefined
|
||||
? { totalIsEstimate: live.progress.total_is_estimate }
|
||||
: {}),
|
||||
...(live.progress.active_connections !== undefined && item.isTorrent === true
|
||||
? { connectedPeers: live.progress.active_connections }
|
||||
: {}),
|
||||
...(live.progress.active_connections !== undefined
|
||||
&& item.isTorrent !== true
|
||||
&& item.isMedia !== true
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isCurrentTorrentPeerSummary, isTorrentPeerSummaryStatus } from './propertiesPeerSummary';
|
||||
|
||||
describe('Torrent peer summary lifecycle', () => {
|
||||
it('accepts only the current active Torrent lifecycle', () => {
|
||||
const request = {
|
||||
currentDownloadId: 'torrent-1',
|
||||
requestDownloadId: 'torrent-1',
|
||||
currentLifecycleEpoch: 8,
|
||||
requestLifecycleEpoch: 8,
|
||||
currentStatus: 'downloading',
|
||||
};
|
||||
expect(isCurrentTorrentPeerSummary(request)).toBe(true);
|
||||
expect(isCurrentTorrentPeerSummary({ ...request, currentLifecycleEpoch: 9 })).toBe(false);
|
||||
expect(isCurrentTorrentPeerSummary({ ...request, requestDownloadId: 'torrent-2' })).toBe(false);
|
||||
});
|
||||
|
||||
it('stops live summary polling for paused and completed lifecycles', () => {
|
||||
expect(isTorrentPeerSummaryStatus('paused')).toBe(false);
|
||||
expect(isTorrentPeerSummaryStatus('completed')).toBe(false);
|
||||
expect(isTorrentPeerSummaryStatus('seeding')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
const TORRENT_PEER_SUMMARY_ACTIVE_STATUSES = [
|
||||
'downloading',
|
||||
'verifying',
|
||||
'seeding',
|
||||
'waitingToSeed',
|
||||
'retrying',
|
||||
] as const;
|
||||
|
||||
export const isTorrentPeerSummaryStatus = (status: string): boolean =>
|
||||
(TORRENT_PEER_SUMMARY_ACTIVE_STATUSES as readonly string[]).includes(status);
|
||||
|
||||
export const isCurrentTorrentPeerSummary = ({
|
||||
currentDownloadId,
|
||||
requestDownloadId,
|
||||
currentLifecycleEpoch,
|
||||
requestLifecycleEpoch,
|
||||
currentStatus,
|
||||
}: {
|
||||
currentDownloadId: string | null;
|
||||
requestDownloadId: string;
|
||||
currentLifecycleEpoch: number;
|
||||
requestLifecycleEpoch: number;
|
||||
currentStatus: string;
|
||||
}): boolean => currentDownloadId === requestDownloadId
|
||||
&& currentLifecycleEpoch === requestLifecycleEpoch
|
||||
&& isTorrentPeerSummaryStatus(currentStatus);
|
||||
@@ -58,16 +58,34 @@ describe('Properties connection presentation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses connected peers for Torrents', () => {
|
||||
it('does not use tellActive connections for the Torrent header', () => {
|
||||
expect(getPropertiesConnectionPresentation({
|
||||
isMedia: false,
|
||||
isTorrent: true,
|
||||
connectedPeers: 4,
|
||||
})).toEqual({
|
||||
kind: 'torrent',
|
||||
showHeaderMetric: true,
|
||||
labelKey: 'torrentConnectedPeers',
|
||||
value: '4',
|
||||
value: '—',
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes the explicit live peer and seeder summary values', () => {
|
||||
expect(getPropertiesConnectionPresentation({
|
||||
isMedia: false,
|
||||
isTorrent: true,
|
||||
}, {
|
||||
totalPeers: 41,
|
||||
totalSeeders: 2,
|
||||
})).toEqual({
|
||||
kind: 'torrent',
|
||||
showHeaderMetric: true,
|
||||
labelKey: 'torrentConnectedPeers',
|
||||
value: '—',
|
||||
torrentPeerSummary: {
|
||||
totalPeers: 41,
|
||||
totalSeeders: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,17 @@ import { resolveDownloadFraction } from './downloadProgress';
|
||||
|
||||
export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2';
|
||||
export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections';
|
||||
export type PropertiesTorrentPeerSummary = {
|
||||
totalPeers: number;
|
||||
totalSeeders: number;
|
||||
};
|
||||
|
||||
export type PropertiesConnectionPresentation = {
|
||||
kind: PropertiesConnectionKind;
|
||||
showHeaderMetric: boolean;
|
||||
labelKey: PropertiesConnectionLabelKey;
|
||||
value: string;
|
||||
torrentPeerSummary?: PropertiesTorrentPeerSummary;
|
||||
};
|
||||
|
||||
const displayCount = (value: number | undefined): string => value == null ? '—' : String(value);
|
||||
@@ -20,7 +25,8 @@ export const getPropertiesProgress = (
|
||||
: resolveDownloadFraction(snapshot);
|
||||
|
||||
export const getPropertiesConnectionPresentation = (
|
||||
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'connectedPeers'>,
|
||||
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections'>,
|
||||
torrentPeerSummary?: PropertiesTorrentPeerSummary | null,
|
||||
): PropertiesConnectionPresentation => {
|
||||
if (snapshot.isMedia === true) {
|
||||
return {
|
||||
@@ -36,7 +42,8 @@ export const getPropertiesConnectionPresentation = (
|
||||
kind: 'torrent',
|
||||
showHeaderMetric: true,
|
||||
labelKey: 'torrentConnectedPeers',
|
||||
value: displayCount(snapshot.connectedPeers),
|
||||
value: '—',
|
||||
...(torrentPeerSummary ? { torrentPeerSummary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { copyTorrentFilePath } from './torrentFilePath';
|
||||
|
||||
describe('Torrent file paths', () => {
|
||||
it('copies the complete long path without changing its separators or characters', async () => {
|
||||
const path = 'Season 03/Scenes/This-is-a-deliberately-long-file-name-with-unicode-字幕.mkv';
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
|
||||
await copyTorrentFilePath(path, writeText);
|
||||
|
||||
expect(writeText).toHaveBeenCalledOnce();
|
||||
expect(writeText).toHaveBeenCalledWith(path);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export type ClipboardWriter = (text: string) => Promise<void>;
|
||||
|
||||
/** Copy the exact Torrent-relative path without normalizing or truncating it. */
|
||||
export const copyTorrentFilePath = async (
|
||||
path: string,
|
||||
writeText: ClipboardWriter,
|
||||
): Promise<void> => {
|
||||
await writeText(path);
|
||||
};
|
||||
Reference in New Issue
Block a user