mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 10:49:29 +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:
@@ -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