mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 21:48:00 +00:00
fix(downloads): surface allocation and retain terminal progress
- emit native allocation state around Aria2 preallocation - preflight batch destinations before backend admission - preserve exact progress across retries and terminal states - fence allocation and progress across pauses, retries, and stale GIDs
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 DownloadAllocationEvent = { id: string, pending: boolean, lifecycleGeneration: string, };
|
||||
@@ -1,4 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DownloadErrorKind } from "./DownloadErrorKind";
|
||||
import type { DownloadStateProgress } from "./DownloadStateProgress";
|
||||
|
||||
export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, destination?: string, torrentSeedRemaining?: number, };
|
||||
export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, destination?: string, torrentSeedRemaining?: number, progress?: DownloadStateProgress, };
|
||||
|
||||
@@ -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 DownloadStateProgress = { fraction: number, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, };
|
||||
@@ -4,6 +4,7 @@ import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn
|
||||
import type { DownloadCategory } from './bindings/DownloadCategory';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
|
||||
import type { DownloadAllocationEvent } from './bindings/DownloadAllocationEvent';
|
||||
import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
||||
import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope';
|
||||
import type { MediaMetadata } from './bindings/MediaMetadata';
|
||||
@@ -200,6 +201,7 @@ export function invokeCommand<K extends CommandName>(
|
||||
type EventMap = {
|
||||
'schedule-trigger': { action: 'start' | 'stop'; key: string };
|
||||
'download-progress': DownloadProgressEvent;
|
||||
'download-allocation': DownloadAllocationEvent;
|
||||
'download-state': DownloadStateEvent;
|
||||
'torrent-move-progress': import('./bindings/TorrentMoveProgressEvent').TorrentMoveProgressEvent;
|
||||
'download-complete': string;
|
||||
|
||||
@@ -3,15 +3,67 @@ import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
retainedProgressMap: Record<string, DownloadProgressEvent>;
|
||||
moveProgressMap: Record<string, number>;
|
||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||
clearDownloadProgress: (id: string) => void;
|
||||
resetDownloadProgress: (id: string) => void;
|
||||
setMoveProgress: (id: string, fraction: number) => void;
|
||||
clearMoveProgress: (id: string) => void;
|
||||
}
|
||||
|
||||
const finiteNonNegative = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
|
||||
const retainProgressSnapshot = (
|
||||
previous: DownloadProgressEvent | undefined,
|
||||
next: DownloadProgressEvent,
|
||||
): DownloadProgressEvent => {
|
||||
const previousDownloaded = finiteNonNegative(previous?.downloaded_bytes)
|
||||
? previous.downloaded_bytes
|
||||
: undefined;
|
||||
const nextDownloaded = finiteNonNegative(next.downloaded_bytes)
|
||||
? next.downloaded_bytes
|
||||
: undefined;
|
||||
const downloadedBytes = previousDownloaded === undefined
|
||||
? nextDownloaded
|
||||
: nextDownloaded === undefined
|
||||
? previousDownloaded
|
||||
: Math.max(previousDownloaded, nextDownloaded);
|
||||
|
||||
const exactTotal = [next, previous]
|
||||
.find(snapshot => snapshot?.total_is_estimate === false
|
||||
&& finiteNonNegative(snapshot.total_bytes))
|
||||
?.total_bytes;
|
||||
const totalBytes = exactTotal
|
||||
?? (finiteNonNegative(next.total_bytes)
|
||||
? next.total_bytes
|
||||
: finiteNonNegative(previous?.total_bytes)
|
||||
? previous.total_bytes
|
||||
: undefined);
|
||||
const totalIsEstimate = exactTotal !== undefined
|
||||
? false
|
||||
: next.total_is_estimate ?? previous?.total_is_estimate;
|
||||
const fractions = [previous?.fraction, next.fraction]
|
||||
.filter(finiteNonNegative);
|
||||
if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) {
|
||||
fractions.push(Math.min(downloadedBytes, totalBytes) / totalBytes);
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
fraction: fractions.length > 0
|
||||
? Math.min(1, Math.max(0, Math.max(...fractions)))
|
||||
: next.fraction,
|
||||
...(downloadedBytes !== undefined ? { downloaded_bytes: downloadedBytes } : {}),
|
||||
...(totalBytes !== undefined ? { total_bytes: totalBytes } : {}),
|
||||
...(totalIsEstimate !== undefined ? { total_is_estimate: totalIsEstimate } : {})
|
||||
};
|
||||
};
|
||||
|
||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
||||
progressMap: {},
|
||||
retainedProgressMap: {},
|
||||
moveProgressMap: {},
|
||||
updateDownloadProgress: (id, payload) =>
|
||||
set((state) => ({
|
||||
@@ -19,6 +71,10 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
|
||||
...state.progressMap,
|
||||
[id]: payload,
|
||||
},
|
||||
retainedProgressMap: {
|
||||
...state.retainedProgressMap,
|
||||
[id]: retainProgressSnapshot(state.retainedProgressMap[id], payload),
|
||||
},
|
||||
})),
|
||||
clearDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
@@ -29,6 +85,23 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
|
||||
delete nextMove[id];
|
||||
return { progressMap: next, moveProgressMap: nextMove };
|
||||
}),
|
||||
resetDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.progressMap)
|
||||
&& !(id in state.retainedProgressMap)
|
||||
&& !(id in state.moveProgressMap)) return state;
|
||||
const next = { ...state.progressMap };
|
||||
delete next[id];
|
||||
const nextRetained = { ...state.retainedProgressMap };
|
||||
delete nextRetained[id];
|
||||
const nextMove = { ...state.moveProgressMap };
|
||||
delete nextMove[id];
|
||||
return {
|
||||
progressMap: next,
|
||||
retainedProgressMap: nextRetained,
|
||||
moveProgressMap: nextMove
|
||||
};
|
||||
}),
|
||||
setMoveProgress: (id, fraction) =>
|
||||
set((state) => ({
|
||||
moveProgressMap: { ...state.moveProgressMap, [id]: fraction }
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('useDownloadProgressStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
||||
useDownloadProgressStore.setState({ progressMap: {}, moveProgressMap: {} });
|
||||
useDownloadProgressStore.setState({ progressMap: {}, retainedProgressMap: {}, moveProgressMap: {} });
|
||||
clearDownloadControlIntents();
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('useDownloadProgressStore', () => {
|
||||
const first = initDownloadListener();
|
||||
const second = initDownloadListener();
|
||||
|
||||
expect(ipc.listenEvent).toHaveBeenCalledTimes(4);
|
||||
expect(ipc.listenEvent).toHaveBeenCalledTimes(5);
|
||||
|
||||
const releaseFirst = await first;
|
||||
const releaseSecond = await second;
|
||||
@@ -52,7 +52,7 @@ describe('useDownloadProgressStore', () => {
|
||||
expect(unlisten).not.toHaveBeenCalled();
|
||||
|
||||
releaseSecond();
|
||||
expect(unlisten).toHaveBeenCalledTimes(4);
|
||||
expect(unlisten).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
|
||||
@@ -92,6 +92,88 @@ describe('useDownloadProgressStore', () => {
|
||||
release();
|
||||
});
|
||||
|
||||
it('projects native allocation events after admission and ignores stale generations', 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: 'native-allocation',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'native-allocation',
|
||||
pending: true,
|
||||
lifecycleGeneration: '0'
|
||||
} });
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(true);
|
||||
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'native-allocation',
|
||||
pending: false,
|
||||
lifecycleGeneration: '1'
|
||||
} });
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(true);
|
||||
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'native-allocation',
|
||||
pending: false,
|
||||
lifecycleGeneration: '0'
|
||||
} });
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(false);
|
||||
release();
|
||||
});
|
||||
|
||||
it('retains a native allocation marker received before row hydration', 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: [],
|
||||
allocationPendingIds: new Set()
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'hydrating-allocation',
|
||||
pending: true,
|
||||
lifecycleGeneration: '0'
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('hydrating-allocation')).toBe(true);
|
||||
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'hydrating-allocation',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('hydrating-allocation')).toBe(true);
|
||||
|
||||
handlers['download-allocation']({ payload: {
|
||||
id: 'hydrating-allocation',
|
||||
pending: false,
|
||||
lifecycleGeneration: '0'
|
||||
} });
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('hydrating-allocation')).toBe(false);
|
||||
release();
|
||||
});
|
||||
|
||||
it('applies the authoritative destination carried by Torrent move completion', async () => {
|
||||
const handlers: Record<string, (event: any) => void> = {};
|
||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||
@@ -470,6 +552,182 @@ describe('useDownloadProgressStore', () => {
|
||||
expect(row.totalBytes).toBe(10240);
|
||||
expect(row.totalIsEstimate).toBe(true);
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
expect(useDownloadProgressStore.getState().retainedProgressMap.snapshot).toMatchObject({
|
||||
fraction: 0.8,
|
||||
downloaded_bytes: 8192,
|
||||
total_bytes: 10240
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
it('retains progress for failed and paused rows when the live entry is absent', 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: 'terminal-progress',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'terminal-progress',
|
||||
fraction: 0.7,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '10 MB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 7000,
|
||||
total_bytes: 10000,
|
||||
total_is_estimate: false
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'terminal-progress',
|
||||
status: 'paused',
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'paused',
|
||||
fraction: 0.7,
|
||||
downloadedBytes: 7000
|
||||
});
|
||||
|
||||
useDownloadStore.setState(state => ({
|
||||
downloads: state.downloads.map(download => ({ ...download, status: 'downloading' as const }))
|
||||
}));
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'terminal-progress',
|
||||
status: 'failed',
|
||||
error: 'network stopped',
|
||||
progress: {
|
||||
fraction: 0.8,
|
||||
downloadedBytes: 8000,
|
||||
totalBytes: 10000,
|
||||
totalIsEstimate: false
|
||||
}
|
||||
} });
|
||||
expect(useDownloadProgressStore.getState().progressMap['terminal-progress']).toBeUndefined();
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
fraction: 0.8,
|
||||
downloadedBytes: 8000,
|
||||
totalBytes: 10000,
|
||||
totalIsEstimate: false
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
it('keeps retained bytes when a paused GID resumes through a queued state', 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: 'same-gid-resume',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'same-gid-resume',
|
||||
fraction: 0.6,
|
||||
speed: '1 MB/s',
|
||||
eta: '4s',
|
||||
size: '10 KB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 6000,
|
||||
total_bytes: 10000,
|
||||
total_is_estimate: false
|
||||
} });
|
||||
useDownloadStore.setState(state => ({
|
||||
downloads: state.downloads.map(download => ({
|
||||
...download,
|
||||
status: 'queued' as const
|
||||
}))
|
||||
}));
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'same-gid-resume',
|
||||
status: 'queued'
|
||||
} });
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap['same-gid-resume']).toBeUndefined();
|
||||
expect(useDownloadProgressStore.getState().retainedProgressMap['same-gid-resume']).toMatchObject({
|
||||
downloaded_bytes: 6000,
|
||||
total_bytes: 10000
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
it('keeps the greatest retained byte count across retry frames', 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: 'retry-progress',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
const progress = (fraction: number, downloadedBytes: number) => handlers['download-progress']({ payload: {
|
||||
id: 'retry-progress',
|
||||
fraction,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '10 KB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: downloadedBytes,
|
||||
total_bytes: 10000,
|
||||
total_is_estimate: false
|
||||
} });
|
||||
progress(0.8, 8000);
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'retry-progress',
|
||||
status: 'retrying',
|
||||
error: 'network dropped'
|
||||
} });
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'retrying',
|
||||
fraction: 0.8,
|
||||
downloadedBytes: 8000,
|
||||
totalBytes: 10000,
|
||||
totalIsEstimate: false
|
||||
});
|
||||
useDownloadStore.getState().updateDownload('retry-progress', { status: 'downloading' });
|
||||
progress(0.1, 1000);
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'retry-progress',
|
||||
status: 'failed',
|
||||
error: 'retry exhausted'
|
||||
} });
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
fraction: 0.8,
|
||||
downloadedBytes: 8000,
|
||||
totalBytes: 10000,
|
||||
totalIsEstimate: false
|
||||
});
|
||||
release();
|
||||
});
|
||||
|
||||
|
||||
+142
-13
@@ -8,6 +8,7 @@ import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import {
|
||||
clearDownloadControlIntent,
|
||||
commitDownloadState,
|
||||
currentDownloadLifecycleGeneration,
|
||||
downloadControlIntentFor,
|
||||
hasStaleTemporaryMediaEstimate,
|
||||
useDownloadStore
|
||||
@@ -16,15 +17,94 @@ import {
|
||||
export { useDownloadProgressStore } from './downloadProgressStore';
|
||||
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
let unlistenAllocation: UnlistenFn | null = null;
|
||||
let unlistenState: UnlistenFn | null = null;
|
||||
let unlistenMoveProgress: UnlistenFn | null = null;
|
||||
let unlistenTray: UnlistenFn | null = null;
|
||||
let listenerSetup: Promise<void> | null = null;
|
||||
let listenerConsumers = 0;
|
||||
|
||||
type ProgressFields = {
|
||||
fraction?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
totalIsEstimate?: boolean;
|
||||
};
|
||||
|
||||
const finiteNonNegative = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
|
||||
const progressFields = (source: unknown): ProgressFields => {
|
||||
if (!source || typeof source !== 'object') return {};
|
||||
const value = source as Record<string, unknown>;
|
||||
const downloadedBytes = value.downloadedBytes ?? value.downloaded_bytes;
|
||||
const totalBytes = value.totalBytes ?? value.total_bytes;
|
||||
const totalIsEstimate = value.totalIsEstimate ?? value.total_is_estimate;
|
||||
return {
|
||||
...(finiteNonNegative(value.fraction) ? { fraction: value.fraction } : {}),
|
||||
...(finiteNonNegative(downloadedBytes) ? { downloadedBytes } : {}),
|
||||
...(finiteNonNegative(totalBytes) ? { totalBytes } : {}),
|
||||
...(typeof totalIsEstimate === 'boolean' ? { totalIsEstimate } : {})
|
||||
};
|
||||
};
|
||||
|
||||
const mergeTerminalProgress = (
|
||||
current: DownloadItem,
|
||||
status: DownloadStatus,
|
||||
nativeSnapshot: unknown,
|
||||
retainedSnapshot: unknown,
|
||||
liveSnapshot: unknown
|
||||
): ProgressFields => {
|
||||
const ordered = [nativeSnapshot, retainedSnapshot, liveSnapshot]
|
||||
.map(progressFields);
|
||||
const row = progressFields({
|
||||
fraction: current.fraction,
|
||||
downloadedBytes: current.downloadedBytes,
|
||||
totalBytes: current.totalBytes,
|
||||
totalIsEstimate: current.totalIsEstimate
|
||||
});
|
||||
const all = [...ordered, row];
|
||||
const downloadedCandidates = all
|
||||
.map(snapshot => snapshot.downloadedBytes)
|
||||
.filter((value): value is number => finiteNonNegative(value));
|
||||
const downloadedBytes = downloadedCandidates.length > 0
|
||||
? Math.max(...downloadedCandidates)
|
||||
: undefined;
|
||||
|
||||
const exactTotals = all
|
||||
.filter(snapshot => snapshot.totalIsEstimate === false && finiteNonNegative(snapshot.totalBytes))
|
||||
.map(snapshot => snapshot.totalBytes!);
|
||||
const anyTotals = all
|
||||
.map(snapshot => snapshot.totalBytes)
|
||||
.filter((value): value is number => finiteNonNegative(value));
|
||||
const totalBytes = exactTotals[0] ?? anyTotals[0];
|
||||
const fractions = all
|
||||
.map(snapshot => snapshot.fraction)
|
||||
.filter((value): value is number => finiteNonNegative(value));
|
||||
if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) {
|
||||
fractions.push(Math.min(downloadedBytes, totalBytes) / totalBytes);
|
||||
}
|
||||
if (status === 'completed') fractions.push(1);
|
||||
const fraction = fractions.length > 0
|
||||
? Math.min(1, Math.max(0, Math.max(...fractions)))
|
||||
: undefined;
|
||||
return {
|
||||
...(fraction !== undefined ? { fraction } : {}),
|
||||
...(downloadedBytes !== undefined ? { downloadedBytes } : {}),
|
||||
...(totalBytes !== undefined ? { totalBytes } : {}),
|
||||
...(exactTotals.length > 0
|
||||
? { totalIsEstimate: false }
|
||||
: ordered.find(snapshot => snapshot.totalIsEstimate !== undefined)?.totalIsEstimate !== undefined
|
||||
? { totalIsEstimate: ordered.find(snapshot => snapshot.totalIsEstimate !== undefined)!.totalIsEstimate }
|
||||
: {})
|
||||
};
|
||||
};
|
||||
|
||||
const disposeDownloadListeners = () => {
|
||||
unlistenProgress?.();
|
||||
unlistenProgress = null;
|
||||
unlistenAllocation?.();
|
||||
unlistenAllocation = null;
|
||||
unlistenState?.();
|
||||
unlistenState = null;
|
||||
unlistenMoveProgress?.();
|
||||
@@ -43,7 +123,7 @@ const startDownloadListeners = async () => {
|
||||
if (!current) {
|
||||
// A removed row can still have one queued sidecar event in flight.
|
||||
// Do not let that event recreate an orphaned progress entry.
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
useDownloadProgressStore.getState().resetDownloadProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
// A sidecar can flush one last progress chunk after a pause, failure,
|
||||
@@ -112,12 +192,39 @@ const startDownloadListeners = async () => {
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
}
|
||||
}),
|
||||
listen('download-allocation', (event) => {
|
||||
const payload = event.payload;
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(download => download.id === payload.id);
|
||||
if (!current) {
|
||||
// Keep a validated native marker until persisted startup state or a
|
||||
// just-admitted row is projected. Dropping it here makes allocation
|
||||
// invisible when the event wins the hydration race.
|
||||
mainStore.setAllocationPending(
|
||||
payload.id,
|
||||
payload.pending,
|
||||
payload.lifecycleGeneration
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Allocation events are native lifecycle markers. A late marker from an
|
||||
// older GID/queue lifecycle must never hide the current lifecycle's
|
||||
// phase or clear its pending state.
|
||||
if (payload.lifecycleGeneration !== currentDownloadLifecycleGeneration(payload.id)) {
|
||||
return;
|
||||
}
|
||||
mainStore.setAllocationPending(
|
||||
payload.id,
|
||||
payload.pending,
|
||||
payload.lifecycleGeneration
|
||||
);
|
||||
}),
|
||||
listen('download-state', async (event) => {
|
||||
const payload = event.payload;
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (!current) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
useDownloadProgressStore.getState().resetDownloadProgress(payload.id);
|
||||
return;
|
||||
}
|
||||
const status = payload.status as DownloadStatus;
|
||||
@@ -184,8 +291,26 @@ const startDownloadListeners = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
|
||||
const progressState = useDownloadProgressStore.getState();
|
||||
const liveProgress = progressState.progressMap[payload.id];
|
||||
const retainedProgress = progressState.retainedProgressMap[payload.id];
|
||||
const isTerminalOrPaused = ['completed', 'failed', 'paused', 'retrying', 'waitingToSeed'].includes(status);
|
||||
const terminalProgress = isTerminalOrPaused
|
||||
? mergeTerminalProgress(
|
||||
current,
|
||||
status,
|
||||
payload.progress,
|
||||
retainedProgress,
|
||||
liveProgress
|
||||
)
|
||||
: undefined;
|
||||
if (status === 'queued') {
|
||||
// A queued event can represent either a genuinely new admission or a
|
||||
// same-GID resume of a paused Aria2 transfer. Lifecycle-changing
|
||||
// callers reset the retained snapshot before admission; this event
|
||||
// only ends the old live frame so a same-GID resume keeps its bytes.
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
} else if (['retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
const moveRestoreStatus = status === 'moving'
|
||||
@@ -196,16 +321,18 @@ const startDownloadListeners = async () => {
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
torrentMoveRestoreStatus: moveRestoreStatus,
|
||||
...(progress ? {
|
||||
fraction: progress.fraction,
|
||||
...(progress.downloaded_bytes != null
|
||||
? { downloadedBytes: progress.downloaded_bytes }
|
||||
...(terminalProgress ? {
|
||||
...(terminalProgress.fraction !== undefined
|
||||
? { fraction: terminalProgress.fraction }
|
||||
: {}),
|
||||
...(progress.total_bytes != null
|
||||
? { totalBytes: progress.total_bytes }
|
||||
...(terminalProgress.downloadedBytes !== undefined
|
||||
? { downloadedBytes: terminalProgress.downloadedBytes }
|
||||
: {}),
|
||||
...(progress.total_is_estimate != null
|
||||
? { totalIsEstimate: progress.total_is_estimate }
|
||||
...(terminalProgress.totalBytes !== undefined
|
||||
? { totalBytes: terminalProgress.totalBytes }
|
||||
: {}),
|
||||
...(terminalProgress.totalIsEstimate !== undefined
|
||||
? { totalIsEstimate: terminalProgress.totalIsEstimate }
|
||||
: {})
|
||||
} : {}),
|
||||
...(payload.error ? {
|
||||
@@ -326,13 +453,15 @@ const startDownloadListeners = async () => {
|
||||
throw failedRegistration.reason;
|
||||
}
|
||||
|
||||
const [progress, state, moveProgress, tray] = registrations as [
|
||||
const [progress, allocation, state, moveProgress, tray] = registrations as [
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
PromiseFulfilledResult<UnlistenFn>,
|
||||
];
|
||||
unlistenProgress = progress.value;
|
||||
unlistenAllocation = allocation.value;
|
||||
unlistenState = state.value;
|
||||
unlistenMoveProgress = moveProgress.value;
|
||||
unlistenTray = tray.value;
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('useDownloadStore', () => {
|
||||
pendingAddRequestContexts: {},
|
||||
pendingAddRequestVersion: 0,
|
||||
});
|
||||
useDownloadProgressStore.setState({ progressMap: {} });
|
||||
useDownloadProgressStore.setState({ progressMap: {}, retainedProgressMap: {}, moveProgressMap: {} });
|
||||
});
|
||||
|
||||
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
|
||||
@@ -1337,7 +1337,7 @@ describe('useDownloadStore', () => {
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('exposes an indeterminate allocation phase while normal enqueue is blocked', async () => {
|
||||
it('does not expose allocation while admission is merely blocked', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'allocation-phase',
|
||||
@@ -1365,15 +1365,19 @@ describe('useDownloadStore', () => {
|
||||
|
||||
const dispatch = dispatchItem('allocation-phase');
|
||||
await vi.waitFor(() => {
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(true);
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({ item: expect.objectContaining({ id: 'allocation-phase' }) })
|
||||
);
|
||||
});
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(false);
|
||||
|
||||
resolveEnqueue({ id: 'allocation-phase', filename: 'file.bin' });
|
||||
await expect(dispatch).resolves.toBe(true);
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes allocation phase for a preallocated Torrent and strips metadata credentials', async () => {
|
||||
it('does not expose Torrent allocation while admission is merely blocked and strips metadata credentials', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'torrent-allocation-phase',
|
||||
@@ -1411,8 +1415,12 @@ describe('useDownloadStore', () => {
|
||||
|
||||
const dispatch = dispatchItem('torrent-allocation-phase');
|
||||
await vi.waitFor(() => {
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('torrent-allocation-phase')).toBe(true);
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({ item: expect.objectContaining({ id: 'torrent-allocation-phase' }) })
|
||||
);
|
||||
});
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('torrent-allocation-phase')).toBe(false);
|
||||
|
||||
resolveEnqueue({ id: 'torrent-allocation-phase', filename: 'payload' });
|
||||
await expect(dispatch).resolves.toBe(true);
|
||||
@@ -2555,7 +2563,46 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows and clears allocation phase for a blocked startup Torrent batch', async () => {
|
||||
it('keeps startup destination permission failures retryable without backend registration', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-destination-access',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
destination: '/protected',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: '00000000-0000-0000-0000-000000000001',
|
||||
hasBeenDispatched: true
|
||||
})];
|
||||
}
|
||||
if (cmd === 'enqueue_many') {
|
||||
return [{
|
||||
id: 'startup-destination-access',
|
||||
success: false,
|
||||
error: 'destination access retryable: grant Firelink access to the selected folder and retry'
|
||||
}];
|
||||
}
|
||||
if (cmd === 'get_pending_order') return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
await useDownloadStore.getState().resumePendingDownloads();
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'ready',
|
||||
hasBeenDispatched: false,
|
||||
lastErrorKind: 'destinationAccess',
|
||||
lastError: 'grant Firelink access to the selected folder and retry'
|
||||
});
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-destination-access')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not show allocation for a startup Torrent batch while it is merely queued', async () => {
|
||||
let releaseEnqueue!: (value: Array<{ id: string; success: boolean; filename: string }>) => void;
|
||||
const enqueue = new Promise<Array<{ id: string; success: boolean; filename: string }>>(resolve => {
|
||||
releaseEnqueue = resolve;
|
||||
@@ -2590,7 +2637,7 @@ describe('useDownloadStore', () => {
|
||||
const resume = useDownloadStore.getState().resumePendingDownloads();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('startup-torrent-allocation')).toBe(true);
|
||||
expect(useDownloadStore.getState().allocationPendingIds.has('startup-torrent-allocation')).toBe(false);
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_many',
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, isActiveDownloadStatus, isAllocationPhaseEligible, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import {
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
@@ -214,14 +214,26 @@ const advanceDownloadLifecycle = (id: string): bigint => {
|
||||
const currentDownloadLifecycle = (id: string): bigint =>
|
||||
downloadLifecycleGenerations.get(id) ?? 0n;
|
||||
|
||||
export const currentDownloadLifecycleGeneration = (id: string): string =>
|
||||
currentDownloadLifecycle(id).toString();
|
||||
|
||||
type DispatchInvalidation = {
|
||||
generation: bigint;
|
||||
pendingDispatch?: Promise<boolean>;
|
||||
};
|
||||
|
||||
const invalidateDispatch = async (id: string): Promise<DispatchInvalidation> => {
|
||||
const invalidateDispatch = async (
|
||||
id: string,
|
||||
resetRetainedProgress = false,
|
||||
): Promise<DispatchInvalidation> => {
|
||||
const generation = currentDownloadLifecycle(id);
|
||||
const nextGeneration = advanceDownloadLifecycle(id);
|
||||
// A new lifecycle cannot inherit the previous native allocation phase. The
|
||||
// backend will emit a fresh marker for the new generation after admission.
|
||||
useDownloadStore.getState().clearAllocationPending(id);
|
||||
if (resetRetainedProgress) {
|
||||
useDownloadProgressStore.getState().resetDownloadProgress(id);
|
||||
}
|
||||
try {
|
||||
await invoke('cancel_enqueue_generation', { id, generation: generation.toString() });
|
||||
} catch (error) {
|
||||
@@ -230,8 +242,11 @@ const invalidateDispatch = async (id: string): Promise<DispatchInvalidation> =>
|
||||
return { generation: nextGeneration, pendingDispatch: backendDispatchPromises.get(id) };
|
||||
};
|
||||
|
||||
const invalidateAndWaitForDispatch = async (id: string): Promise<boolean> => {
|
||||
const { pendingDispatch } = await invalidateDispatch(id);
|
||||
const invalidateAndWaitForDispatch = async (
|
||||
id: string,
|
||||
resetRetainedProgress = false,
|
||||
): Promise<boolean> => {
|
||||
const { pendingDispatch } = await invalidateDispatch(id, resetRetainedProgress);
|
||||
if (!pendingDispatch) return false;
|
||||
await pendingDispatch;
|
||||
return true;
|
||||
@@ -434,18 +449,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const showsAllocationPhase = isAllocationPhaseEligible(admittedItem);
|
||||
if (showsAllocationPhase) {
|
||||
useDownloadStore.getState().setAllocationPending(id, true);
|
||||
}
|
||||
let accepted;
|
||||
try {
|
||||
accepted = await invoke('enqueue_download', { item: enqueueItem });
|
||||
} finally {
|
||||
if (showsAllocationPhase) {
|
||||
useDownloadStore.getState().setAllocationPending(id, false);
|
||||
}
|
||||
}
|
||||
const accepted = await invoke('enqueue_download', { item: enqueueItem });
|
||||
backendAccepted = true;
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||
await removeStaleBackendDispatch(id);
|
||||
@@ -485,6 +489,12 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
const proxyBlocked = isSystemProxyConfigurationError(e);
|
||||
const destinationAccessBlocked = isRetryableDestinationAccessError(e);
|
||||
const message = errorMessage(e);
|
||||
if (destinationAccessBlocked) {
|
||||
useDownloadStore.getState().clearAllocationPending(id);
|
||||
useDownloadStore.setState(state => ({
|
||||
pendingOrder: state.pendingOrder.filter(value => value !== id)
|
||||
}));
|
||||
}
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
status: proxyBlocked ? 'queued' : destinationAccessBlocked ? 'ready' : 'failed',
|
||||
hasBeenDispatched: false,
|
||||
@@ -1045,7 +1055,8 @@ interface DownloadState {
|
||||
allocationPendingIds: Set<string>;
|
||||
registerBackendIds: (ids: string[]) => void;
|
||||
unregisterBackendIds: (ids: string[]) => void;
|
||||
setAllocationPending: (id: string, pending: boolean) => void;
|
||||
setAllocationPending: (id: string, pending: boolean, lifecycleGeneration: string) => void;
|
||||
clearAllocationPending: (id: string) => void;
|
||||
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
||||
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
|
||||
moveManyInQueueToPosition: (
|
||||
@@ -1326,7 +1337,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
// Fence any older enqueue before replacing a paused backend lifecycle.
|
||||
// Otherwise a late addUri result can win the race and make this
|
||||
// selection start outside the requested order.
|
||||
const { pendingDispatch } = await invalidateDispatch(id);
|
||||
const { pendingDispatch } = await invalidateDispatch(id, true);
|
||||
if (pendingDispatch) await pendingDispatch;
|
||||
targetItem = get().downloads.find(download => download.id === id);
|
||||
if (!targetItem || !canStartDownload(targetItem.status)) {
|
||||
@@ -1425,7 +1436,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
// A terminal aria2 gid is intentionally re-enqueued as a new
|
||||
// lifecycle. Advance and cancel the old generation before dispatching
|
||||
// so QueueManager does not reject the legitimate user retry as stale.
|
||||
await invalidateAndWaitForDispatch(id);
|
||||
await invalidateAndWaitForDispatch(id, true);
|
||||
dispatchSucceeded = await dispatchItemInternal(id);
|
||||
}
|
||||
|
||||
@@ -1662,12 +1673,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
for (const id of ids) nextSet.delete(id);
|
||||
return { backendRegisteredIds: nextSet };
|
||||
}),
|
||||
setAllocationPending: (id, pending) => set((state) => {
|
||||
setAllocationPending: (id, pending, lifecycleGeneration) => set((state) => {
|
||||
// Native allocation events can arrive while persisted rows are still
|
||||
// hydrating. Validate against the frontend lifecycle counter even when no
|
||||
// row exists yet, then retain the marker until that row is projected.
|
||||
if (lifecycleGeneration !== currentDownloadLifecycleGeneration(id)) return state;
|
||||
const nextSet = new Set(state.allocationPendingIds);
|
||||
if (pending) nextSet.add(id);
|
||||
else nextSet.delete(id);
|
||||
return { allocationPendingIds: nextSet };
|
||||
}),
|
||||
clearAllocationPending: (id) => set((state) => {
|
||||
if (!state.allocationPendingIds.has(id)) return state;
|
||||
const nextSet = new Set(state.allocationPendingIds);
|
||||
nextSet.delete(id);
|
||||
return { allocationPendingIds: nextSet };
|
||||
}),
|
||||
isAddModalOpen: false,
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
@@ -1852,6 +1873,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
hasBeenDispatched: false
|
||||
};
|
||||
advanceDownloadLifecycle(item.id);
|
||||
get().clearAllocationPending(item.id);
|
||||
useDownloadProgressStore.getState().resetDownloadProgress(item.id);
|
||||
set((state) => ({
|
||||
downloads: reorderQueueWithPausedAtEnd([...state.downloads, ownedItem], queueId)
|
||||
}));
|
||||
@@ -1997,7 +2020,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(id);
|
||||
useDownloadProgressStore.getState().resetDownloadProgress(id);
|
||||
info(`Download ${id} removed`);
|
||||
syncSystemIntegrations();
|
||||
},
|
||||
@@ -2066,6 +2089,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
hasBeenDispatched: false,
|
||||
dateAdded: new Date().toISOString()
|
||||
});
|
||||
useDownloadProgressStore.getState().resetDownloadProgress(id);
|
||||
|
||||
await commitDownloadState();
|
||||
|
||||
@@ -2765,25 +2789,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation;
|
||||
});
|
||||
if (dispatchableItems.length === 0) return;
|
||||
const allocationPendingIds = dispatchableItems
|
||||
.filter(item => {
|
||||
const current = latestItems.get(item.id);
|
||||
return current !== undefined && isAllocationPhaseEligible(current);
|
||||
})
|
||||
.map(item => item.id);
|
||||
allocationPendingIds.forEach(id => get().setAllocationPending(id, true));
|
||||
|
||||
let results;
|
||||
try {
|
||||
results = await invoke('enqueue_many', { items: dispatchableItems });
|
||||
} finally {
|
||||
allocationPendingIds.forEach(id => get().setAllocationPending(id, false));
|
||||
}
|
||||
const results = await invoke('enqueue_many', { items: dispatchableItems });
|
||||
const registeredIds = results.filter(result => result.success).map(result => result.id);
|
||||
const failedErrors = new Map(
|
||||
const failedResults = new Map(
|
||||
results
|
||||
.filter(result => !result.success)
|
||||
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
|
||||
.map(result => {
|
||||
const message = result.error || 'Backend rejected the queued download.';
|
||||
const destinationAccess = isRetryableDestinationAccessError(message);
|
||||
return [result.id, {
|
||||
message: destinationAccess
|
||||
? destinationAccessErrorMessage(message)
|
||||
: message,
|
||||
status: destinationAccess ? 'ready' as const : 'failed' as const,
|
||||
errorKind: destinationAccess
|
||||
? ('destinationAccess' as DownloadErrorKind)
|
||||
: undefined
|
||||
}];
|
||||
})
|
||||
);
|
||||
const acceptedFilenames = new Map(
|
||||
results
|
||||
@@ -2816,12 +2839,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
...state.backendRegisteredIds,
|
||||
...liveAcceptedIds
|
||||
]),
|
||||
pendingOrder: state.pendingOrder.filter(id => !failedResults.has(id)),
|
||||
downloads: state.downloads.map(download =>
|
||||
failedErrors.has(download.id)
|
||||
failedResults.has(download.id)
|
||||
? {
|
||||
...download,
|
||||
status: 'failed' as const,
|
||||
lastError: failedErrors.get(download.id)
|
||||
status: failedResults.get(download.id)!.status,
|
||||
hasBeenDispatched: false,
|
||||
lastError: failedResults.get(download.id)!.message,
|
||||
lastErrorKind: failedResults.get(download.id)!.errorKind
|
||||
}
|
||||
: liveAcceptedIds.has(download.id)
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user