fix(downloads): harden add modal and retry state

Route explicit no-limit speed overrides without inheriting the global cap.

Preserve dotted media titles when switching formats and drain retry gid completions through remember_gid.
This commit is contained in:
NimBold
2026-07-08 21:57:12 +03:30
parent f84726a403
commit 161b0028f9
9 changed files with 205 additions and 71 deletions
+19 -31
View File
@@ -22,6 +22,7 @@ import { isTransferLocked } from '../utils/downloadActions';
import { useToast } from '../contexts/ToastContext';
import {
canSubmitMetadataRows,
mediaFileNameForSelectedFormat,
mediaFormatSelectorForRow,
metadataSummaryMessage,
reconcileDownloadRows,
@@ -387,7 +388,7 @@ export const AddDownloadsModal = () => {
}
})();
}
}, [parsedItems, pendingAddFilename, password, useAuth, username, headers, cookies]);
}, [parsedItems, pendingAddFilename, pendingAddMediaUrls]);
useEffect(() => {
if (parsedItems.length === 0) {
@@ -481,12 +482,9 @@ export const AddDownloadsModal = () => {
for (let i = 0; i < parsedItems.length; i++) {
const item = parsedItems[i];
let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
const itemLocation = useSharedDestination
? finalLocation
: destinationOverrides[i] || await categoryLocationForFile(finalFile);
@@ -577,12 +575,9 @@ export const AddDownloadsModal = () => {
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') {
let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
const itemLocation = useSharedDestination
? finalLocation
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
@@ -633,12 +628,9 @@ export const AddDownloadsModal = () => {
itemsToAdd[idx] = null;
continue;
}
let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
const itemLocation = useSharedDestination
? finalLocation
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
@@ -682,17 +674,11 @@ export const AddDownloadsModal = () => {
if (!item) continue;
try {
const id = crypto.randomUUID();
let finalFile = canonicalizeDownloadFileName(item.file);
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
let formatSelector = mediaFormatSelectorForRow(item);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
if (!finalFile.endsWith(`.${selectedFormat.ext}`)) {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
}
const category = categoryForFileName(finalFile);
const added = await addDownload({
id,
@@ -701,7 +687,7 @@ export const AddDownloadsModal = () => {
category,
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
speedLimit: speedLimitEnabled ? `${speedLimit}K` : '0',
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headers.trim() || undefined,
@@ -763,7 +749,6 @@ export const AddDownloadsModal = () => {
const format = selectedItem?.formats?.[index];
if (!selectedItem || !format) return;
const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file;
setParsedItems(items => items.map((item, itemIndex) =>
itemIndex === selectedItemIndex
? {
@@ -771,7 +756,10 @@ export const AddDownloadsModal = () => {
selectedFormat: index,
size: format.bytes ? format.detail : undefined,
sizeBytes: format.bytes || undefined,
file: canonicalizeDownloadFileName(`${baseName}.${format.ext}`)
file: mediaFileNameForSelectedFormat(item.file, {
formats: item.formats,
selectedFormat: index
})
}
: item
));
+1
View File
@@ -1801,6 +1801,7 @@ body.is-resizing .column-resize-handle:hover::after {
border-radius: 6px;
color: hsl(var(--text-primary));
font-size: var(--download-row-font-size);
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
+84
View File
@@ -54,6 +54,29 @@ vi.mock('./useSettingsStore', () => ({
describe('useDownloadStore', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSettingsStore.getState).mockReturnValue({
proxyMode: 'none',
siteLogins: [],
globalSpeedLimit: '',
speedLimitPresetValues: [1, 5, 10],
logsEnabled: false,
perServerConnections: 16,
customUserAgent: '',
maxAutomaticRetries: 3,
mediaCookieSource: 'none',
baseDownloadFolder: '~/Downloads',
categorySubfoldersEnabled: true,
categorySubfolders: {
Musics: 'Musics',
Movies: 'Movies',
Compressed: 'Compressed',
Documents: 'Documents',
Pictures: 'Pictures',
Applications: 'Applications',
Other: 'Other',
},
categoryDirectoryOverrides: {},
} as unknown as ReturnType<typeof useSettingsStore.getState>);
useDownloadStore.setState({
downloads: [],
backendRegisteredIds: new Set(),
@@ -253,6 +276,67 @@ describe('useDownloadStore', () => {
);
});
it('does not replace an explicit no-limit item speed with the global speed 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 ['uncapped'];
return undefined;
});
await useDownloadStore.getState().addDownload({
id: 'uncapped',
url: 'https://example.com/uncapped.bin',
fileName: 'uncapped.bin',
category: 'Other',
dateAdded: '',
speedLimit: '0'
}, { type: 'start-now' });
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
id: 'uncapped',
speed_limit: '0'
})
})
);
});
it('uses the global speed limit only when an item has no explicit speed override', 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 ['inherits-global'];
return undefined;
});
await useDownloadStore.getState().addDownload({
id: 'inherits-global',
url: 'https://example.com/inherits-global.bin',
fileName: 'inherits-global.bin',
category: 'Other',
dateAdded: ''
}, { type: 'start-now' });
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
id: 'inherits-global',
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') {
+10 -2
View File
@@ -20,6 +20,14 @@ const backendDispatchPromises = new Map<string, Promise<boolean>>();
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLimit: string): string | null => {
const explicitLimit = itemSpeedLimit?.trim();
if (explicitLimit) {
return normalizeSpeedLimitForBackend(explicitLimit) || (explicitLimit === '0' ? '0' : null);
}
return normalizeSpeedLimitForBackend(globalSpeedLimit);
};
export async function dispatchItem(id: string): Promise<boolean> {
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
@@ -50,7 +58,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
destination,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
@@ -860,7 +868,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
+31
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
canSubmitMetadataRows,
mediaFormatSelectorForRow,
mediaFileNameForSelectedFormat,
metadataSummaryMessage,
reconcileDownloadRows,
refreshFailedMetadataRows,
@@ -154,6 +155,36 @@ describe('add download metadata workflow', () => {
expect(mediaFormatSelectorForRow(failedMedia)).toBeUndefined();
});
it('replaces only known media container suffixes when selecting formats', () => {
const mediaRow = row({
isMedia: true,
formats: [
{
name: '1080p MP4',
selector: 'mp4',
ext: 'mp4',
formatLabel: '1080p',
detail: '10 MB',
type: 'Video',
bytes: 10
},
{
name: '1080p MKV',
selector: 'mkv',
ext: 'mkv',
formatLabel: '1080p',
detail: '11 MB',
type: 'Video',
bytes: 11
}
],
selectedFormat: 1
});
expect(mediaFileNameForSelectedFormat('Version 1.5', mediaRow)).toBe('Version 1.5.mkv');
expect(mediaFileNameForSelectedFormat('Version 1.5.mp4', mediaRow)).toBe('Version 1.5.mkv');
});
it('reports fallback and invalid states accurately', () => {
expect(metadataSummaryMessage([
row(),
+32
View File
@@ -148,6 +148,38 @@ export const mediaFormatSelectorForRow = (
return row.formats?.[row.selectedFormat]?.selector;
};
export const mediaFileNameForSelectedFormat = (
fileName: string,
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
): string => {
const selectedFormat = row.selectedFormat === undefined
? undefined
: row.formats?.[row.selectedFormat];
if (!selectedFormat) return canonicalizeDownloadFileName(fileName);
const cleanFileName = canonicalizeDownloadFileName(fileName);
const selectedExt = selectedFormat.ext.replace(/^\.+/, '');
if (!selectedExt) return cleanFileName;
const lowerFileName = cleanFileName.toLowerCase();
if (lowerFileName.endsWith(`.${selectedExt.toLowerCase()}`)) {
return cleanFileName;
}
const lastDot = cleanFileName.lastIndexOf('.');
const currentExt = lastDot > 0 ? cleanFileName.slice(lastDot + 1).toLowerCase() : '';
const knownFormatExts = new Set(
(row.formats || [])
.map(format => format.ext.replace(/^\.+/, '').toLowerCase())
.filter(Boolean)
);
const baseName = currentExt && knownFormatExts.has(currentExt)
? cleanFileName.slice(0, lastDot)
: cleanFileName;
return canonicalizeDownloadFileName(`${baseName}.${selectedExt}`);
};
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
if (rows.length === 0) return 'Paste one or more links.';