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:
NimBold
2026-08-04 01:18:24 +03:30
parent 55a905df14
commit 579a8f7f80
33 changed files with 3505 additions and 139 deletions
+2
View File
@@ -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[];
}
/**
+1 -1
View File
@@ -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';
+25
View File
@@ -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';
+3 -2
View File
@@ -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 {
+50
View File
@@ -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');
+83
View File
@@ -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