fix(torrent): use live peer telemetry in properties

- source the Properties peer card from live Aria2 status counts
- distinguish connected peers from unavailable peer details
- remove redundant peer-summary IPC and harden count parsing
- add responsive, accessible peer/seeder presentation and regressions
This commit is contained in:
NimBold
2026-08-15 06:26:25 +03:30
parent de41dd55d6
commit ca32b772a2
23 changed files with 204 additions and 252 deletions
-3
View File
@@ -1,3 +0,0 @@
// 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, };
+59 -83
View File
@@ -9,7 +9,6 @@ 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,
@@ -48,11 +47,12 @@ import {
formatPropertiesDiagnosticCount,
getPropertiesAvailabilityDiagnosticState,
getPropertiesPeerDiagnosticState,
hasLiveTorrentPeerWithoutDetails,
} from '../utils/propertiesDiagnostics';
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 { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle';
import { WindowControls } from './WindowControls';
import {
TORRENT_ENCRYPTION_POLICY_DISABLED,
@@ -68,7 +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 = isTorrentPeerSummaryStatus;
const isTorrentPollingStatus = isTorrentLiveStatus;
const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status);
@@ -215,7 +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 [peerDetailsUnavailable, setPeerDetailsUnavailable] = useState(false);
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
const [details, setDetails] = useState<TorrentDetails | null>(null);
const [diagnosticError, setDiagnosticError] = useState('');
@@ -269,13 +269,11 @@ 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>());
@@ -293,7 +291,6 @@ export const PropertiesWindowApp = () => {
downloadIdRef.current = downloadId;
fileProgressRef.current = fileProgress;
peersRef.current = peers;
peerSummaryRef.current = peerSummary;
availabilityRef.current = availability;
detailsRef.current = details;
@@ -513,16 +510,15 @@ export const PropertiesWindowApp = () => {
if (isCurrent()) {
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);
}
setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails(
snapshotRef.current?.torrentConnectedPeers,
peerResult.value.totalPeers,
));
} else if (isExpectedPropertiesDiagnosticUnavailable(peerResult.reason)) {
peerSummaryRef.current = null;
setPeerSummary(null);
setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails(
snapshotRef.current?.torrentConnectedPeers,
0,
));
}
if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value);
const peerOutcome = peerResult.status === 'fulfilled'
@@ -588,40 +584,6 @@ 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;
@@ -646,8 +608,6 @@ 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';
@@ -689,9 +649,8 @@ export const PropertiesWindowApp = () => {
setDetails(null);
setFileProgress(null);
setPeers(null);
setPeerDetailsUnavailable(false);
setAvailability(null);
peerSummaryRef.current = null;
setPeerSummary(null);
setDiagnosticError('');
setDiagnosticsLoading(false);
setDiagnosticsRefreshing(false);
@@ -778,8 +737,6 @@ export const PropertiesWindowApp = () => {
diagnosticLifecycleEpochRef.current += 1;
diagnosticLifecycleKeyRef.current = '';
diagnosticAttemptsRef.current.clear();
peerSummaryRef.current = null;
setPeerSummary(null);
setSnapshot(null);
draftTabRef.current = null;
isDirtyRef.current = false;
@@ -853,9 +810,8 @@ export const PropertiesWindowApp = () => {
setDetails(null);
setFileProgress(null);
setPeers(null);
setPeerDetailsUnavailable(false);
setAvailability(null);
peerSummaryRef.current = null;
setPeerSummary(null);
setDiagnosticError('');
setDiagnosticsLoading(false);
setDiagnosticsRefreshing(false);
@@ -870,9 +826,8 @@ export const PropertiesWindowApp = () => {
if (!isTorrentPollingStatus(snapshot.status)) {
setFileProgress(null);
setPeers(null);
setPeerDetailsUnavailable(false);
setAvailability(null);
peerSummaryRef.current = null;
setPeerSummary(null);
diagnosticLifecycleEpochRef.current += 1;
diagnosticAttemptsRef.current.clear();
setDiagnosticPhase('idle');
@@ -880,21 +835,16 @@ export const PropertiesWindowApp = () => {
setAvailabilityDiagnosticPhase('idle');
}
void refreshDiagnostics(activeTab, downloadId);
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;
if (!isTorrentPollingStatus(snapshot.status) || !shouldPollDiagnostics) 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(() => {
if (shouldPollDiagnostics) void refreshDiagnostics(activeTab, downloadId);
if (shouldPollSummary) void refreshPeerSummary(downloadId);
}, 1000);
return () => window.clearInterval(interval);
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, refreshPeerSummary, snapshot?.status]);
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]);
useEffect(() => {
let disposed = false;
@@ -1194,18 +1144,40 @@ export const PropertiesWindowApp = () => {
? t($ => $.addDownloads.unknownSize)
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
const statusLabel = t($ => $.downloads.status[snapshot.status]);
const connectionPresentation = getPropertiesConnectionPresentation(snapshot, peerSummary);
const connectionLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
const connectionPresentation = getPropertiesConnectionPresentation(snapshot);
const connectionHeaderLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
? t($ => $.properties.fragmentConcurrency)
: connectionPresentation.labelKey === 'torrentConnectedPeers'
? t($ => $.properties.torrentConnectedPeers)
: connectionPresentation.labelKey === 'torrentPeersSeeders'
? t($ => $.properties.torrentPeersSeeders)
: t($ => $.properties.connections);
const connectionValue = connectionPresentation.torrentPeerSummary
? t($ => $.properties.torrentPeerSummary, {
total: connectionPresentation.torrentPeerSummary.totalPeers,
seeders: connectionPresentation.torrentPeerSummary.totalSeeders,
})
: connectionPresentation.value;
const connectionControlLabel = snapshot.isTorrent === true
? t($ => $.properties.torrentConnectedPeers)
: connectionHeaderLabel;
const connectionValue: ReactNode = connectionPresentation.torrentPeerCounts
? (() => {
const peersValue = formatPropertiesDiagnosticCount(
connectionPresentation.torrentPeerCounts.connectedPeers ?? Number.NaN,
snapshot.appearance.locale,
);
const seedersValue = formatPropertiesDiagnosticCount(
connectionPresentation.torrentPeerCounts.connectedSeeders ?? Number.NaN,
snapshot.appearance.locale,
);
return <strong
className="properties-torrent-peer-count"
aria-label={t($ => $.properties.torrentConnectedPeerMetric, {
peers: peersValue,
seeders: seedersValue,
})}
>
<span className="properties-torrent-peer-count-primary">{peersValue}</span>
<span aria-hidden="true"> / </span>
<span>{seedersValue}</span>
</strong>;
})()
: <strong>{connectionPresentation.value}</strong>;
const peerDetailsNotice = peerDetailsUnavailable
&& (snapshot.torrentConnectedPeers ?? 0) > 0;
const queuePlacement = formatPropertiesQueuePlacement(
snapshot.queueName,
snapshot.queuePosition,
@@ -1297,7 +1269,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>{connectionValue}</strong></div></div>}
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span className={connectionPresentation.labelKey === 'torrentPeersSeeders' ? 'properties-metric-label--wide' : undefined}>{connectionHeaderLabel}</span>{connectionValue}</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>
@@ -1449,8 +1421,12 @@ export const PropertiesWindowApp = () => {
<div className="properties-diagnostic-heading">
<div className="min-w-0">
<span className="properties-diagnostic-label">{t($ => $.properties.torrentPeerDiagnostics)}</span>
<p className="properties-diagnostic-value" data-value-state={peerDiagnosticState} role="status">
{peers
<p className="properties-diagnostic-value" data-value-state={peerDetailsNotice ? 'unavailable' : peerDiagnosticState} role="status">
{peerDetailsNotice
? t($ => $.properties.torrentPeerDetailsUnavailable, {
connected: formatPropertiesDiagnosticCount(snapshot.torrentConnectedPeers ?? 0, snapshot.appearance.locale),
})
: peers
? t($ => $.properties.torrentPeerCount, {
total: formatPropertiesDiagnosticCount(peers.totalPeers, snapshot.appearance.locale),
seeders: formatPropertiesDiagnosticCount(peers.totalSeeders, snapshot.appearance.locale),
@@ -1491,13 +1467,13 @@ export const PropertiesWindowApp = () => {
<input id="properties-transfer-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
</PropertiesField>
<PropertiesField
label={connectionLabel}
label={connectionControlLabel}
controlId="properties-transfer-concurrency"
hint={snapshot.isMedia === true ? t($ => $.properties.fragmentConcurrencyHint) : undefined}
className="max-w-md"
>
<div className="mt-2 flex items-center gap-3" dir="ltr">
<input id="properties-transfer-concurrency" type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionLabel} />
<input id="properties-transfer-concurrency" type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionControlLabel} />
<span className="w-8 text-center font-mono text-text-primary">{connections || '1'}</span>
</div>
</PropertiesField>
@@ -1655,7 +1631,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">{connectionValue}</p></div>
<div><span className="text-text-muted">{connectionHeaderLabel}</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>
+3 -1
View File
@@ -345,7 +345,6 @@ 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',
@@ -358,6 +357,9 @@ const common = {
torrentSeededDuration: 'Seeded',
torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.',
torrentConnectedPeers: 'Peers',
torrentPeersSeeders: 'Peers / Seeders',
torrentConnectedPeerMetric: '{{peers}} connected peers / {{seeders}} connected seeders',
torrentPeerDetailsUnavailable: '{{connected}} connected peers reported, but peer details are not available yet.',
torrentSeeders: 'Seeders',
torrentUploadSpeed: 'Upload speed',
seconds: 'seconds',
+3 -1
View File
@@ -345,7 +345,6 @@ const fa = {
torrentWebSeedsRemove: 'حذف وب‌سید',
torrentWebSeedsInvalid: 'هر ردیف وب‌سید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
torrentPeerCount: '{{total}} همتا — {{seeders}} سید',
torrentPeerSummary: '{{total}} همتا · {{seeders}} سید',
torrentPeerDownload: 'دریافت',
torrentPeerUpload: 'آپلود',
torrentPeerSeeder: 'سید',
@@ -358,6 +357,9 @@ const fa = {
torrentSeededDuration: 'مدت سید',
torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه می‌دهد. برای استفاده از پیش‌فرض خالی بگذارید.',
torrentConnectedPeers: 'همتاها',
torrentPeersSeeders: 'همتاهای متصل / سیدهای متصل',
torrentConnectedPeerMetric: '{{peers}} همتای متصل / {{seeders}} سید متصل',
torrentPeerDetailsUnavailable: '{{connected}} همتای متصل گزارش شده، اما جزئیات همتاها هنوز در دسترس نیست.',
torrentSeeders: 'سیدها',
torrentUploadSpeed: 'سرعت آپلود',
seconds: 'ثانیه',
+3 -1
View File
@@ -345,7 +345,6 @@ const he = {
torrentWebSeedsRemove: 'הסר זריעת Web',
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים',
torrentPeerSummary: '{{total}} עמיתים · {{seeders}} משתפים',
torrentPeerDownload: 'הורדה',
torrentPeerUpload: 'העלאה',
torrentPeerSeeder: 'משתף',
@@ -358,6 +357,9 @@ const he = {
torrentSeededDuration: 'משך שיתוף',
torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.',
torrentConnectedPeers: 'עמיתים',
torrentPeersSeeders: 'עמיתים / משתפים',
torrentConnectedPeerMetric: '{{peers}} עמיתים מחוברים / {{seeders}} משתפים מחוברים',
torrentPeerDetailsUnavailable: 'דווחו {{connected}} עמיתים מחוברים, אך פרטי העמיתים עדיין אינם זמינים.',
torrentSeeders: 'משתפים',
torrentUploadSpeed: 'מהירות העלאה',
seconds: 'שניות',
+3 -1
View File
@@ -345,7 +345,6 @@ const ru = {
torrentWebSeedsRemove: 'Удалить веб-сид',
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров',
torrentPeerSummary: '{{total}} пиров · {{seeders}} сидеров',
torrentPeerDownload: 'Загрузка',
torrentPeerUpload: 'Отдача',
torrentPeerSeeder: 'Сидер',
@@ -358,6 +357,9 @@ const ru = {
torrentSeededDuration: 'Время раздачи',
torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.',
torrentConnectedPeers: 'Пиры',
torrentPeersSeeders: 'Пиры / Сиды',
torrentConnectedPeerMetric: '{{peers}} подключённых пиров / {{seeders}} подключённых сидов',
torrentPeerDetailsUnavailable: 'Подключённых пиров: {{connected}}, но сведения о них пока недоступны.',
torrentSeeders: 'Сиды',
torrentUploadSpeed: 'Скорость отдачи',
seconds: 'секунд',
+3 -1
View File
@@ -345,7 +345,6 @@ const uk = {
torrentWebSeedsRemove: 'Видалити вебсід',
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
torrentPeerCount: '{{total}} пірів — {{seeders}} сідів',
torrentPeerSummary: '{{total}} пірів · {{seeders}} сідів',
torrentPeerDownload: 'Завантаження',
torrentPeerUpload: 'Віддача',
torrentPeerSeeder: 'Сідер',
@@ -358,6 +357,9 @@ const uk = {
torrentSeededDuration: 'Час роздачі',
torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.',
torrentConnectedPeers: 'Піри',
torrentPeersSeeders: 'Піри / Сіди',
torrentConnectedPeerMetric: '{{peers}} підключених пірів / {{seeders}} підключених сідів',
torrentPeerDetailsUnavailable: 'Підключених пірів: {{connected}}, але відомості про них поки недоступні.',
torrentSeeders: 'Сіди',
torrentUploadSpeed: 'Швидкість віддачі',
seconds: 'секунд',
+3 -1
View File
@@ -345,7 +345,6 @@ const zhCN = {
torrentWebSeedsRemove: '移除 Web 做种',
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点',
torrentPeerSummary: '{{total}} 个节点 · {{seeders}} 个做种节点',
torrentPeerDownload: '下载',
torrentPeerUpload: '上传',
torrentPeerSeeder: '做种',
@@ -358,6 +357,9 @@ const zhCN = {
torrentSeededDuration: '做种时长',
torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。',
torrentConnectedPeers: '连接数',
torrentPeersSeeders: '节点 / 做种',
torrentConnectedPeerMetric: '{{peers}} 个已连接节点 / {{seeders}} 个已连接做种节点',
torrentPeerDetailsUnavailable: '检测到 {{connected}} 个已连接节点,但其详细信息暂时不可用。',
torrentSeeders: '种子数',
torrentUploadSpeed: '上传速度',
seconds: '秒',
+22
View File
@@ -835,6 +835,16 @@ html[data-list-density="relaxed"] {
white-space: nowrap;
}
.properties-metric-card .properties-metric-label--wide {
overflow: visible;
font-size: 9px;
letter-spacing: 0.01em;
line-height: 1.15;
min-height: 20px;
text-overflow: clip;
white-space: normal;
}
.properties-metric-card strong {
overflow: hidden;
color: hsl(var(--text-primary));
@@ -845,6 +855,18 @@ html[data-list-density="relaxed"] {
white-space: nowrap;
}
.properties-metric-card .properties-torrent-peer-count {
display: inline-flex;
overflow: visible;
align-items: baseline;
gap: 1px;
text-overflow: clip;
}
.properties-metric-card .properties-torrent-peer-count-primary {
color: hsl(var(--accent-color));
}
.properties-window-destination {
display: flex;
min-width: 0;
-2
View File
@@ -20,7 +20,6 @@ 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';
@@ -95,7 +94,6 @@ 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 };
+4 -3
View File
@@ -173,11 +173,11 @@ describe('Properties window bridge', () => {
downloaded_bytes: 3,
total_bytes: 4,
total_is_estimate: false,
active_connections: 4,
active_connections: 0,
requested_connections: 8,
uploaded_bytes: 9,
upload_speed: '1 MiB/s',
num_seeders: 6,
num_seeders: 0,
torrent_seeded_seconds: 12,
},
moveProgress: 0.5,
@@ -193,7 +193,8 @@ describe('Properties window bridge', () => {
totalIsEstimate: false,
torrentUploadedBytes: 9,
uploadSpeed: '1 MiB/s',
torrentSeeders: 6,
torrentConnectedPeers: 0,
torrentConnectedSeeders: 0,
torrentSeededSeconds: 12,
moveProgress: 0.5,
});
+9 -6
View File
@@ -180,7 +180,8 @@ export type PropertiesSnapshot = SafePropertiesFields & {
activeConnections?: number;
requestedConnections?: number;
uploadSpeed?: string;
torrentSeeders?: number;
torrentConnectedPeers?: number;
torrentConnectedSeeders?: number;
moveProgress?: number;
hasPassword: boolean;
hasCookies: boolean;
@@ -425,9 +426,11 @@ const copyWithoutSecrets = (
? { totalIsEstimate: live.progress.total_is_estimate }
: {}),
...(live.progress.active_connections !== undefined
&& item.isTorrent !== true
&& item.isMedia !== true
? { activeConnections: live.progress.active_connections }
? item.isTorrent === true
? { torrentConnectedPeers: live.progress.active_connections }
: item.isMedia !== true
? { activeConnections: live.progress.active_connections }
: {}
: {}),
...(item.isTorrent !== true
&& item.isMedia !== true
@@ -440,8 +443,8 @@ const copyWithoutSecrets = (
...(live.progress.upload_speed !== undefined
? { uploadSpeed: live.progress.upload_speed }
: {}),
...(live.progress.num_seeders !== undefined
? { torrentSeeders: live.progress.num_seeders }
...(live.progress.num_seeders !== undefined && item.isTorrent === true
? { torrentConnectedSeeders: live.progress.num_seeders }
: {}),
...(live.progress.torrent_seeded_seconds !== undefined
? { torrentSeededSeconds: live.progress.torrent_seeded_seconds }
+8
View File
@@ -4,6 +4,7 @@ import {
formatPropertiesDiagnosticCount,
getPropertiesAvailabilityDiagnosticState,
getPropertiesPeerDiagnosticState,
hasLiveTorrentPeerWithoutDetails,
} from './propertiesDiagnostics';
const emptyPeerDiagnostics = {
@@ -49,4 +50,11 @@ describe('Properties peer diagnostics presentation state', () => {
expect(getPropertiesAvailabilityDiagnosticState(null, false, 'idle')).toBe('unavailable');
expect(getPropertiesAvailabilityDiagnosticState(null, false, 'error')).toBe('error');
});
it('distinguishes a live connection from an empty peer-detail snapshot', () => {
expect(hasLiveTorrentPeerWithoutDetails(1, 0)).toBe(true);
expect(hasLiveTorrentPeerWithoutDetails(0, 0)).toBe(false);
expect(hasLiveTorrentPeerWithoutDetails(undefined, 0)).toBe(false);
expect(hasLiveTorrentPeerWithoutDetails(2, 1)).toBe(false);
});
});
+8
View File
@@ -10,6 +10,14 @@ export const formatPropertiesDiagnosticCount = (value: number, locale: string):
return new Intl.NumberFormat(resolveAppLocale(locale)).format(value);
};
export const hasLiveTorrentPeerWithoutDetails = (
connectedPeers: number | undefined,
detailedPeers: number,
): boolean => Number.isSafeInteger(connectedPeers)
&& (connectedPeers ?? 0) > 0
&& Number.isSafeInteger(detailedPeers)
&& detailedPeers === 0;
export const formatPropertiesAvailability = (availability: number, locale: string): string => {
if (!Number.isFinite(availability) || availability < 0) return '—';
return new Intl.NumberFormat(resolveAppLocale(locale), { maximumFractionDigits: 2 }).format(availability);
-23
View File
@@ -1,23 +0,0 @@
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);
});
});
-26
View File
@@ -1,26 +0,0 @@
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);
+12 -9
View File
@@ -65,26 +65,29 @@ describe('Properties connection presentation', () => {
})).toEqual({
kind: 'torrent',
showHeaderMetric: true,
labelKey: 'torrentConnectedPeers',
labelKey: 'torrentPeersSeeders',
value: '—',
torrentPeerCounts: {
connectedPeers: undefined,
connectedSeeders: undefined,
},
});
});
it('exposes the explicit live peer and seeder summary values', () => {
it('uses live connected peer and seeder counts from the Properties snapshot', () => {
expect(getPropertiesConnectionPresentation({
isMedia: false,
isTorrent: true,
}, {
totalPeers: 41,
totalSeeders: 2,
torrentConnectedPeers: 10,
torrentConnectedSeeders: 2,
})).toEqual({
kind: 'torrent',
showHeaderMetric: true,
labelKey: 'torrentConnectedPeers',
labelKey: 'torrentPeersSeeders',
value: '—',
torrentPeerSummary: {
totalPeers: 41,
totalSeeders: 2,
torrentPeerCounts: {
connectedPeers: 10,
connectedSeeders: 2,
},
});
});
+11 -9
View File
@@ -2,10 +2,10 @@ import type { PropertiesSnapshot } from '../propertiesBridge';
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 PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentPeersSeeders' | 'connections';
export type PropertiesTorrentPeerCounts = {
connectedPeers?: number;
connectedSeeders?: number;
};
export type PropertiesConnectionPresentation = {
@@ -13,7 +13,7 @@ export type PropertiesConnectionPresentation = {
showHeaderMetric: boolean;
labelKey: PropertiesConnectionLabelKey;
value: string;
torrentPeerSummary?: PropertiesTorrentPeerSummary;
torrentPeerCounts?: PropertiesTorrentPeerCounts;
};
const displayCount = (value: number | undefined): string => value == null ? '—' : String(value);
@@ -25,8 +25,7 @@ export const getPropertiesProgress = (
: resolveDownloadFraction(snapshot);
export const getPropertiesConnectionPresentation = (
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections'>,
torrentPeerSummary?: PropertiesTorrentPeerSummary | null,
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'torrentConnectedPeers' | 'torrentConnectedSeeders'>,
): PropertiesConnectionPresentation => {
if (snapshot.isMedia === true) {
return {
@@ -41,9 +40,12 @@ export const getPropertiesConnectionPresentation = (
return {
kind: 'torrent',
showHeaderMetric: true,
labelKey: 'torrentConnectedPeers',
labelKey: 'torrentPeersSeeders',
value: '—',
...(torrentPeerSummary ? { torrentPeerSummary } : {}),
torrentPeerCounts: {
connectedPeers: snapshot.torrentConnectedPeers,
connectedSeeders: snapshot.torrentConnectedSeeders,
},
};
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { isTorrentLiveStatus } from './propertiesTorrentLifecycle';
describe('Torrent live lifecycle', () => {
it('identifies statuses with live Aria2 telemetry', () => {
expect(isTorrentLiveStatus('downloading')).toBe(true);
expect(isTorrentLiveStatus('retrying')).toBe(true);
expect(isTorrentLiveStatus('paused')).toBe(false);
expect(isTorrentLiveStatus('completed')).toBe(false);
expect(isTorrentLiveStatus('seeding')).toBe(true);
});
});
+10
View File
@@ -0,0 +1,10 @@
const TORRENT_LIVE_STATUSES = [
'downloading',
'verifying',
'seeding',
'waitingToSeed',
'retrying',
] as const;
export const isTorrentLiveStatus = (status: string): boolean =>
(TORRENT_LIVE_STATUSES as readonly string[]).includes(status);