mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(downloads): preserve live progress across lifecycle edges
This commit is contained in:
+34
-8
@@ -887,9 +887,9 @@ fn parse_media_progress_line(line: &str) -> Option<MediaProgress> {
|
||||
let progress: serde_json::Value =
|
||||
serde_json::from_str(line[prefix_index + MEDIA_PROGRESS_PREFIX.len()..].trim()).ok()?;
|
||||
let downloaded = progress_json_number(&progress, "downloaded_bytes").unwrap_or(0.0);
|
||||
let total = progress_json_number(&progress, "total_bytes")
|
||||
.or_else(|| progress_json_number(&progress, "total_bytes_estimate"))
|
||||
.unwrap_or(0.0);
|
||||
let exact_total = progress_json_number(&progress, "total_bytes");
|
||||
let estimated_total = progress_json_number(&progress, "total_bytes_estimate");
|
||||
let total = exact_total.or(estimated_total).unwrap_or(0.0);
|
||||
let fragment_index = progress_json_number(&progress, "fragment_index");
|
||||
let fragment_count = progress_json_number(&progress, "fragment_count");
|
||||
let fraction = if let (Some(fragment_index), Some(fragment_count)) =
|
||||
@@ -930,8 +930,7 @@ fn parse_media_progress_line(line: &str) -> Option<MediaProgress> {
|
||||
let size = progress_json_string(&progress, "_total_bytes_str")
|
||||
.or_else(|| progress_json_string(&progress, "_total_bytes_estimate_str"))
|
||||
.or_else(|| (total > 0.0).then(|| crate::download::format_size(total)));
|
||||
let total_is_estimate = progress.get("total_bytes").is_none()
|
||||
&& progress.get("total_bytes_estimate").is_some();
|
||||
let total_is_estimate = exact_total.is_none() && estimated_total.is_some();
|
||||
|
||||
return Some(MediaProgress {
|
||||
fraction: fraction.clamp(0.0, 1.0),
|
||||
@@ -1168,12 +1167,16 @@ fn aggregate_media_byte_progress(
|
||||
) -> Option<(u64, u64, bool)> {
|
||||
if track_changed {
|
||||
if let Some(total_bytes) = state.current_track_total_bytes {
|
||||
let completed_downloaded = state
|
||||
.current_track_downloaded_bytes
|
||||
.unwrap_or(total_bytes)
|
||||
.clamp(0.0, total_bytes.max(0.0));
|
||||
*state
|
||||
.completed_tracks_total_bytes
|
||||
.get_or_insert(0.0) += total_bytes;
|
||||
*state
|
||||
.completed_tracks_downloaded_bytes
|
||||
.get_or_insert(0.0) += total_bytes;
|
||||
.get_or_insert(0.0) += completed_downloaded;
|
||||
state.completed_tracks_total_is_estimate |= state.current_track_total_is_estimate;
|
||||
} else {
|
||||
state.completed_tracks_total_bytes = None;
|
||||
@@ -1186,7 +1189,10 @@ fn aggregate_media_byte_progress(
|
||||
state.current_track_total_bytes = progress.total_bytes;
|
||||
state.current_track_downloaded_bytes = progress
|
||||
.downloaded_bytes
|
||||
.or_else(|| progress.total_bytes.map(|total| total * progress.fraction));
|
||||
.or_else(|| progress.total_bytes.map(|total| total * progress.fraction))
|
||||
.zip(progress.total_bytes)
|
||||
.map(|(downloaded, total)| downloaded.clamp(0.0, total.max(0.0)))
|
||||
.or(progress.downloaded_bytes);
|
||||
state.current_track_total_is_estimate = progress.total_is_estimate;
|
||||
|
||||
match (
|
||||
@@ -6422,6 +6428,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_structured_estimated_total_when_exact_total_is_null() {
|
||||
let line = format!(
|
||||
"{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,\"total_bytes\":null,\"total_bytes_estimate\":10485760,\"_total_bytes_estimate_str\":\"~10.00MiB\"}}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_media_progress_line(&line),
|
||||
Some(MediaProgress {
|
||||
fraction: 0.5,
|
||||
speed: "-".to_string(),
|
||||
eta: "-".to_string(),
|
||||
size: Some("~10.00MiB".to_string()),
|
||||
downloaded_bytes: Some(5242880.0),
|
||||
total_bytes: Some(10485760.0),
|
||||
total_is_estimate: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chunked_structured_ytdlp_progress() {
|
||||
let mut buffer = String::new();
|
||||
@@ -6544,7 +6570,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
aggregate_media_byte_progress(&second, true, &mut state),
|
||||
Some((102, 300, true))
|
||||
Some((101, 300, true))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -762,12 +762,14 @@ function App() {
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleForegroundChange);
|
||||
window.addEventListener('blur', handleForegroundChange);
|
||||
document.addEventListener('visibilitychange', handleForegroundChange);
|
||||
handleForegroundChange();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener('focus', handleForegroundChange);
|
||||
window.removeEventListener('blur', handleForegroundChange);
|
||||
document.removeEventListener('visibilitychange', handleForegroundChange);
|
||||
};
|
||||
}, [autoAddClipboardLinks, coreReady]);
|
||||
|
||||
@@ -801,7 +801,7 @@ export const AddDownloadsModal = () => {
|
||||
category,
|
||||
dateAdded: new Date().toISOString(),
|
||||
connections: Number(connections),
|
||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : '0',
|
||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
||||
username: useAuth ? username.trim() : undefined,
|
||||
password: useAuth ? password.trim() : undefined,
|
||||
headers: headersForRow(item.sourceUrl) || undefined,
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('../ipc', () => ({
|
||||
describe('useDownloadProgressStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
||||
useDownloadProgressStore.setState({ progressMap: {} });
|
||||
});
|
||||
|
||||
@@ -156,4 +157,48 @@ describe('useDownloadProgressStore', () => {
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
release();
|
||||
});
|
||||
|
||||
it('snapshots live progress before clearing it on a terminal transition', 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: 'snapshot',
|
||||
url: 'https://example.com/file',
|
||||
fileName: 'file.bin',
|
||||
status: 'downloading',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}]
|
||||
});
|
||||
|
||||
const release = await initDownloadListener();
|
||||
handlers['download-progress']({ payload: {
|
||||
id: 'snapshot',
|
||||
fraction: 0.8,
|
||||
speed: '1 MB/s',
|
||||
eta: '2s',
|
||||
size: '8 MB',
|
||||
size_is_final: false,
|
||||
downloaded_bytes: 8192,
|
||||
total_bytes: 10240,
|
||||
total_is_estimate: true
|
||||
} });
|
||||
handlers['download-state']({ payload: {
|
||||
id: 'snapshot',
|
||||
status: 'paused'
|
||||
} });
|
||||
|
||||
const row = useDownloadStore.getState().downloads[0];
|
||||
expect(row.status).toBe('paused');
|
||||
expect(row.fraction).toBe(0.8);
|
||||
expect(row.downloadedBytes).toBe(8192);
|
||||
expect(row.totalBytes).toBe(10240);
|
||||
expect(row.totalIsEstimate).toBe(true);
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
release();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ const startDownloadListeners = async () => {
|
||||
if (payload.total_bytes !== null && payload.total_bytes !== undefined) {
|
||||
updates.totalBytes = payload.total_bytes;
|
||||
}
|
||||
if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
|
||||
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
|
||||
updates.totalIsEstimate = payload.total_is_estimate;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
@@ -84,13 +84,24 @@ const startDownloadListeners = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
...(progress ? { fraction: progress.fraction } : {}),
|
||||
...(progress ? {
|
||||
fraction: progress.fraction,
|
||||
...(progress.downloaded_bytes != null
|
||||
? { downloadedBytes: progress.downloaded_bytes }
|
||||
: {}),
|
||||
...(progress.total_bytes != null
|
||||
? { totalBytes: progress.total_bytes }
|
||||
: {}),
|
||||
...(progress.total_is_estimate != null
|
||||
? { totalIsEstimate: progress.total_is_estimate }
|
||||
: {})
|
||||
} : {}),
|
||||
...(payload.error ? { lastError: payload.error } : {}),
|
||||
...((status === 'downloading' || status === 'retrying')
|
||||
? { lastTry: new Date().toISOString() }
|
||||
@@ -122,9 +133,6 @@ const startDownloadListeners = async () => {
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
}
|
||||
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
}),
|
||||
listen('tray-action', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
|
||||
@@ -571,6 +571,38 @@ describe('useDownloadStore', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the legacy media zero sentinel as inheriting the global limit', async () => {
|
||||
const defaultSettings = useSettingsStore.getState();
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...defaultSettings,
|
||||
globalSpeedLimit: '2M'
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_pending_order') return ['legacy-media-limit'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().addDownload({
|
||||
id: 'legacy-media-limit',
|
||||
url: 'https://www.youtube.com/watch?v=legacy',
|
||||
fileName: 'media.mp4',
|
||||
category: 'Movies',
|
||||
dateAdded: '',
|
||||
isMedia: true,
|
||||
speedLimit: '0'
|
||||
}, { type: 'start-now' });
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
id: 'legacy-media-limit',
|
||||
speed_limit: '2M'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a rejected immediate start instead of claiming success', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'enqueue_download') {
|
||||
|
||||
@@ -147,6 +147,12 @@ const speedLimitForDispatch = (
|
||||
globalSpeedLimit: string,
|
||||
isMedia: boolean | undefined
|
||||
): string | null => {
|
||||
// Older Add-window rows used "0" as the no-override sentinel. Media
|
||||
// downloads do not have aria2's daemon-wide cap, so preserve the intended
|
||||
// inherit-global behavior when dispatching those persisted rows.
|
||||
if (isMedia && itemSpeedLimit?.trim() === '0') {
|
||||
return normalizeSpeedLimitForBackend(globalSpeedLimit);
|
||||
}
|
||||
const explicitLimit = explicitSpeedLimitForDispatch(itemSpeedLimit);
|
||||
if (explicitLimit !== null || !isMedia) return explicitLimit;
|
||||
return normalizeSpeedLimitForBackend(globalSpeedLimit);
|
||||
|
||||
@@ -30,4 +30,15 @@ describe('download persistence progress snapshots', () => {
|
||||
expect(persisted.totalBytes).toBe(4096);
|
||||
expect(persisted.totalIsEstimate).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
|
||||
'keeps byte counters for %s snapshots',
|
||||
(status) => {
|
||||
const persisted = redactDownloadForPersistence(item(status));
|
||||
|
||||
expect(persisted.downloadedBytes).toBe(1024);
|
||||
expect(persisted.totalBytes).toBe(4096);
|
||||
expect(persisted.totalIsEstimate).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -120,12 +120,7 @@ export const isMediaUrl = (rawUrl: string): boolean => {
|
||||
*/
|
||||
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
|
||||
const VOLATILE_PROGRESS_STATUSES = new Set([
|
||||
'ready',
|
||||
'staged',
|
||||
'queued',
|
||||
'downloading',
|
||||
'processing',
|
||||
'retrying'
|
||||
'downloading'
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -133,8 +128,10 @@ const VOLATILE_PROGRESS_STATUSES = new Set([
|
||||
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
|
||||
* existing persistence path. Numeric byte totals remain for paused, failed,
|
||||
* and completed rows so those snapshots keep their accurate Size-column
|
||||
* display after restart; active-transfer counters stay in memory to avoid a
|
||||
* database write for every progress tick.
|
||||
* display after restart; counters for the actively ticking `downloading`
|
||||
* state stay in memory to avoid a database write for every progress tick.
|
||||
* Non-ticking states retain counters so paused, queued, staged, retrying, and
|
||||
* processing snapshots remain useful across restart and reconfiguration.
|
||||
*
|
||||
* Note: standard persistence intentionally retains `url` because it is the
|
||||
* download source. The backend applies a stricter portable-mode policy: URL
|
||||
|
||||
Reference in New Issue
Block a user