feat(torrents): add seeding lifecycle and upload controls

This commit is contained in:
NimBold
2026-08-01 21:27:49 +03:30
parent bb64c4cd52
commit dea6ad1974
26 changed files with 663 additions and 25 deletions
+78
View File
@@ -125,6 +125,84 @@ describe('useDownloadProgressStore', () => {
release();
});
it('projects torrent seeding state and upload telemetry', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'torrent-seeding',
url: 'magnet:?xt=urn:btih:test',
fileName: 'ubuntu.iso',
status: 'downloading',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'torrent-seeding',
status: 'seeding'
} });
handlers['download-progress']({ payload: {
id: 'torrent-seeding',
fraction: 1,
speed: '0 B/s',
eta: '-',
size: '2 GB',
size_is_final: false,
uploaded_bytes: 1048576,
upload_speed: '512 KiB/s',
num_seeders: 4,
active_connections: 6,
requested_connections: 8
} });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'seeding',
fraction: 1,
speed: '512 KiB/s',
eta: '-'
});
expect(useDownloadProgressStore.getState().progressMap['torrent-seeding'])
.toMatchObject({ uploaded_bytes: 1048576, upload_speed: '512 KiB/s', num_seeders: 4 });
release();
});
it('does not regress a seeding row from a delayed active state event', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'torrent-seeding-race',
url: 'magnet:?xt=urn:btih:test',
fileName: 'ubuntu.iso',
status: 'seeding',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'torrent-seeding-race',
status: 'downloading'
} });
handlers['download-state']({ payload: {
id: 'torrent-seeding-race',
status: 'queued'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('seeding');
release();
});
it('clears progress when events arrive after a download row was removed', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
+16 -7
View File
@@ -45,17 +45,19 @@ const startDownloadListeners = async () => {
// A sidecar can flush one last progress chunk after a pause, failure,
// completion, or lifecycle reset. Do not let that stale chunk repopulate
// the live progress map or overwrite a later lifecycle's first frame.
if (!['downloading', 'processing'].includes(current.status)) {
if (!['downloading', 'processing', 'seeding'].includes(current.status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
return;
}
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {};
if (current.status === 'downloading' || current.status === 'processing') {
if (current.status === 'downloading' || current.status === 'processing' || current.status === 'seeding') {
updates.fraction = payload.fraction;
updates.speed = payload.speed;
updates.eta = payload.eta;
updates.speed = current.status === 'seeding'
? payload.upload_speed ?? '-'
: payload.speed;
updates.eta = current.status === 'seeding' ? '-' : payload.eta;
}
if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!;
@@ -119,7 +121,7 @@ const startDownloadListeners = async () => {
return;
}
if (status === 'downloading' || status === 'processing' ||
status === 'completed' || status === 'failed') {
status === 'seeding' || status === 'completed' || status === 'failed') {
clearDownloadControlIntent(payload.id, 'resume');
}
if (status === 'paused') {
@@ -142,6 +144,13 @@ const startDownloadListeners = async () => {
status !== 'failed') {
return;
}
if (current.status === 'seeding' &&
status !== 'seeding' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
return;
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
@@ -179,7 +188,7 @@ const startDownloadListeners = async () => {
}
mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused') {
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding') {
useDownloadStore.setState(state => ({
pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
}));
@@ -189,7 +198,7 @@ const startDownloadListeners = async () => {
: { pendingOrder: [...state.pendingOrder, payload.id] });
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying') {
mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
+9 -3
View File
@@ -345,6 +345,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_path: item.torrentPath || undefined,
torrent_file_indices: item.torrentFileIndices || undefined,
torrent_info_hash: item.torrentInfoHash || undefined,
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_upload_limit: item.torrentUploadLimit || undefined,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -817,7 +820,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? updates
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) };
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'seeding' || item.status === 'retrying') {
throw new Error(i18n.t($ => $.downloadTable.transferActive));
}
@@ -1411,8 +1414,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) {
info(`Download ${id} status changed to ${updates.status}`);
syncSystemIntegrations();
} else if (updates.status === 'downloading') {
info(`Download ${id} status changed to downloading`);
} else if (updates.status === 'downloading' || updates.status === 'seeding') {
info(`Download ${id} status changed to ${updates.status}`);
syncSystemIntegrations();
}
},
@@ -2048,6 +2051,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_path: item.torrentPath || undefined,
torrent_file_indices: item.torrentFileIndices || undefined,
torrent_info_hash: item.torrentInfoHash || undefined,
torrent_seed_time: item.torrentSeedTime,
torrent_seed_ratio: item.torrentSeedRatio,
torrent_upload_limit: item.torrentUploadLimit || undefined,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}