mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 09:53:17 +00:00
fix(downloads): harden queue admission and live speed controls
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Queue = { id: string, name: string, isMain: boolean, };
|
||||
export type Queue = { id: string, name: string, isMain: boolean, maxConcurrent?: number, };
|
||||
|
||||
@@ -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 QueueConcurrencyConfig = { id: string, maxConcurrent: number | null, };
|
||||
@@ -72,6 +72,8 @@ export const PropertiesModal = () => {
|
||||
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
|
||||
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -143,6 +145,11 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeLimit = item?.speedLimit?.trim();
|
||||
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
|
||||
}, [item?.speedLimit, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||
@@ -245,8 +252,27 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLiveSpeedLimit = async (limit: string | null) => {
|
||||
if (isLiveSpeedLimitPending || item.isMedia || !['downloading', 'retrying'].includes(item.status)) return;
|
||||
|
||||
setErrorMessage('');
|
||||
setIsLiveSpeedLimitPending(true);
|
||||
try {
|
||||
await useDownloadStore.getState().setDownloadSpeedLimit(item.id, limit);
|
||||
if (limit === null) setLiveSpeedLimitValue('');
|
||||
} catch (error) {
|
||||
setErrorMessage(t($ => $.properties.liveSpeedLimitFailed, {
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
} finally {
|
||||
setIsLiveSpeedLimitPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const identityLocked = getIdentityLocked(item.status);
|
||||
const transferLocked = getTransferLocked(item.status);
|
||||
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
|
||||
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status);
|
||||
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
||||
const observedConnectionTotal = Math.max(
|
||||
1,
|
||||
@@ -458,6 +484,53 @@ export const PropertiesModal = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
|
||||
<div className="col-start-2 rounded-md border border-border-modal/60 bg-border-color/20 p-3 space-y-2">
|
||||
{liveSpeedLimitAvailable ? (
|
||||
<>
|
||||
<label htmlFor="live-speed-limit" className="block text-xs font-medium text-text-primary">
|
||||
{t($ => $.properties.liveSpeedLimit)}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="live-speed-limit"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={liveSpeedLimitValue}
|
||||
onChange={event => setLiveSpeedLimitValue(event.currentTarget.value)}
|
||||
placeholder={t($ => $.properties.liveSpeedLimitPlaceholder)}
|
||||
disabled={isLiveSpeedLimitPending}
|
||||
aria-describedby="live-speed-limit-hint"
|
||||
className="w-28 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLiveSpeedLimit(liveSpeedLimitValue)}
|
||||
disabled={isLiveSpeedLimitPending}
|
||||
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.properties.liveSpeedLimitApply)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLiveSpeedLimit(null)}
|
||||
disabled={isLiveSpeedLimitPending || !liveSpeedLimitValue}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.properties.liveSpeedLimitClear)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="live-speed-limit-hint" className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.liveSpeedLimitHint)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.liveSpeedLimitUnavailable)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ interface SidebarProps {
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const { selectedFilter, onSelectFilter } = props;
|
||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
|
||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
|
||||
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
||||
const { addToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
@@ -475,6 +475,45 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
<Pause size={14} className="me-2 text-text-secondary" />
|
||||
{t($ => $.actions.pauseQueue)}
|
||||
</button>
|
||||
{(() => {
|
||||
const queue = queues.find(candidate => candidate.id === contextMenu.id);
|
||||
if (!queue) return null;
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="queue-concurrency-label"
|
||||
className="px-3 py-2 text-[12px] text-text-secondary"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<label id="queue-concurrency-label" htmlFor="queue-concurrency-select" className="block mb-1">
|
||||
{t($ => $.sidebar.queueConcurrency)}
|
||||
</label>
|
||||
<select
|
||||
id="queue-concurrency-select"
|
||||
aria-label={t($ => $.sidebar.queueConcurrency)}
|
||||
value={queue.maxConcurrent?.toString() ?? ''}
|
||||
className="w-full rounded border border-border-color bg-bg-context-menu px-1.5 py-1 text-[12px] text-text-primary outline-none focus:border-accent"
|
||||
onChange={event => {
|
||||
const value = event.currentTarget.value === ''
|
||||
? null
|
||||
: Number(event.currentTarget.value);
|
||||
void setQueueConcurrency(queue.id, value).catch(error => {
|
||||
addToast({
|
||||
message: t($ => $.sidebar.queueConcurrencyFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{t($ => $.sidebar.queueConcurrencyGlobal)}</option>
|
||||
{Array.from({ length: 12 }, (_, index) => index + 1).map(value => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="h-px bg-border-color my-1 mx-2" />
|
||||
<button
|
||||
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
|
||||
@@ -216,6 +216,13 @@ const common = {
|
||||
connectionCountUnknown: '—/{{total}}',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'Speed cap',
|
||||
liveSpeedLimit: 'Live speed cap',
|
||||
liveSpeedLimitHint: 'Applies to active normal downloads only. Media downloads cannot be changed while running.',
|
||||
liveSpeedLimitPlaceholder: 'e.g. 1024K',
|
||||
liveSpeedLimitApply: 'Apply',
|
||||
liveSpeedLimitClear: 'Clear',
|
||||
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
||||
category: 'Category',
|
||||
lastTry: 'Last try',
|
||||
dateAdded: 'Date added',
|
||||
@@ -326,6 +333,9 @@ const common = {
|
||||
queueNameExists: 'A queue with this name already exists',
|
||||
startQueueFailed: 'Could not start queue: {{detail}}',
|
||||
pauseQueueFailed: 'Could not pause queue: {{detail}}',
|
||||
queueConcurrency: 'Concurrent downloads',
|
||||
queueConcurrencyGlobal: 'Use global limit',
|
||||
queueConcurrencyFailed: 'Could not update queue concurrency: {{detail}}',
|
||||
},
|
||||
downloadTable: {
|
||||
unknownQueue: 'Unknown Queue',
|
||||
|
||||
@@ -216,6 +216,13 @@ const fa = {
|
||||
connectionCountUnknown: '—/{{total}} فعال',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'سقف سرعت',
|
||||
liveSpeedLimit: 'سقف سرعت زنده',
|
||||
liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال میشود. سرعت دانلودهای رسانهای هنگام اجرا قابل تغییر نیست.',
|
||||
liveSpeedLimitPlaceholder: 'مثلاً 1024K',
|
||||
liveSpeedLimitApply: 'اعمال',
|
||||
liveSpeedLimitClear: 'پاک کردن',
|
||||
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||
category: 'دسته',
|
||||
lastTry: 'آخرین تلاش',
|
||||
dateAdded: 'تاریخ افزودن',
|
||||
@@ -326,6 +333,9 @@ const fa = {
|
||||
queueNameExists: 'یک صف با این نام از قبل وجود دارد',
|
||||
startQueueFailed: 'شروع صف ناموفق بود: {{detail}}',
|
||||
pauseQueueFailed: 'توقف صف ناموفق بود: {{detail}}',
|
||||
queueConcurrency: 'دانلودهای همزمان',
|
||||
queueConcurrencyGlobal: 'استفاده از محدودیت کلی',
|
||||
queueConcurrencyFailed: 'بهروزرسانی همزمانی صف ناموفق بود: {{detail}}',
|
||||
},
|
||||
downloadTable: {
|
||||
unknownQueue: 'صف نامشخص',
|
||||
|
||||
@@ -216,6 +216,13 @@ const he = {
|
||||
connectionCountUnknown: '—/{{total}} פעילות',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'הגבלת מהירות',
|
||||
liveSpeedLimit: 'הגבלת מהירות בזמן אמת',
|
||||
liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.',
|
||||
liveSpeedLimitPlaceholder: 'לדוגמה 1024K',
|
||||
liveSpeedLimitApply: 'החל',
|
||||
liveSpeedLimitClear: 'נקה',
|
||||
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||
category: 'קטגוריה',
|
||||
lastTry: 'ניסיון אחרון',
|
||||
dateAdded: 'תאריך הוספה',
|
||||
@@ -326,6 +333,9 @@ const he = {
|
||||
queueNameExists: 'כבר קיים תור בשם זה',
|
||||
startQueueFailed: 'לא ניתן להפעיל את התור: {{detail}}',
|
||||
pauseQueueFailed: 'לא ניתן להשהות את התור: {{detail}}',
|
||||
queueConcurrency: 'הורדות בו-זמניות',
|
||||
queueConcurrencyGlobal: 'שימוש במגבלה הכללית',
|
||||
queueConcurrencyFailed: 'לא ניתן לעדכן את מקביליות התור: {{detail}}',
|
||||
},
|
||||
downloadTable: {
|
||||
unknownQueue: 'תור לא ידוע',
|
||||
|
||||
@@ -216,6 +216,13 @@ const ru = {
|
||||
connectionCountUnknown: '—/{{total}} активных',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'Ограничение скорости',
|
||||
liveSpeedLimit: 'Текущее ограничение скорости',
|
||||
liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.',
|
||||
liveSpeedLimitPlaceholder: 'например, 1024K',
|
||||
liveSpeedLimitApply: 'Применить',
|
||||
liveSpeedLimitClear: 'Очистить',
|
||||
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||
category: 'Категория',
|
||||
lastTry: 'Последняя попытка',
|
||||
dateAdded: 'Дата добавления',
|
||||
@@ -326,6 +333,9 @@ const ru = {
|
||||
queueNameExists: 'Очередь с таким названием уже существует',
|
||||
startQueueFailed: 'Не удалось запустить очередь: {{detail}}',
|
||||
pauseQueueFailed: 'Не удалось приостановить очередь: {{detail}}',
|
||||
queueConcurrency: 'Одновременные загрузки',
|
||||
queueConcurrencyGlobal: 'Использовать общий лимит',
|
||||
queueConcurrencyFailed: 'Не удалось обновить параллельность очереди: {{detail}}',
|
||||
},
|
||||
downloadTable: {
|
||||
unknownQueue: 'Неизвестная очередь',
|
||||
|
||||
@@ -216,6 +216,13 @@ const uk = {
|
||||
connectionCountUnknown: '—/{{total}} активних',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'Обмеження швидкості',
|
||||
liveSpeedLimit: 'Поточне обмеження швидкості',
|
||||
liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.',
|
||||
liveSpeedLimitPlaceholder: 'наприклад, 1024K',
|
||||
liveSpeedLimitApply: 'Застосувати',
|
||||
liveSpeedLimitClear: 'Очистити',
|
||||
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||
category: 'Категорія',
|
||||
lastTry: 'Остання спроба',
|
||||
dateAdded: 'Дата додавання',
|
||||
@@ -326,6 +333,9 @@ const uk = {
|
||||
queueNameExists: 'Черга з такою назвою вже існує',
|
||||
startQueueFailed: 'Не вдалося запустити чергу: {{detail}}',
|
||||
pauseQueueFailed: 'Не вдалося призупинити чергу: {{detail}}',
|
||||
queueConcurrency: 'Одночасні завантаження',
|
||||
queueConcurrencyGlobal: 'Використовувати загальний ліміт',
|
||||
queueConcurrencyFailed: 'Не вдалося оновити паралельність черги: {{detail}}',
|
||||
},
|
||||
downloadTable: {
|
||||
unknownQueue: 'Невідома черга',
|
||||
|
||||
@@ -216,6 +216,13 @@ const zhCN = {
|
||||
connectionCountUnknown: '—/{{total}} 个连接',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: '速度上限',
|
||||
liveSpeedLimit: '实时速度上限',
|
||||
liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。',
|
||||
liveSpeedLimitPlaceholder: '例如 1024K',
|
||||
liveSpeedLimitApply: '应用',
|
||||
liveSpeedLimitClear: '清除',
|
||||
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||
category: '类别',
|
||||
lastTry: '上次尝试',
|
||||
dateAdded: '添加日期',
|
||||
@@ -326,6 +333,9 @@ const zhCN = {
|
||||
queueNameExists: '同名队列已存在',
|
||||
startQueueFailed: '无法启动队列:{{detail}}',
|
||||
pauseQueueFailed: '无法暂停队列:{{detail}}',
|
||||
queueConcurrency: '并行下载数',
|
||||
queueConcurrencyGlobal: '使用全局限制',
|
||||
queueConcurrencyFailed: '无法更新队列并发数:{{detail}}',
|
||||
},
|
||||
downloadTable: {
|
||||
unknownQueue: '未知队列',
|
||||
|
||||
+3
-1
@@ -16,6 +16,7 @@ import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
||||
import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -37,7 +38,7 @@ type CommandMap = {
|
||||
reveal_in_file_manager: { args: { path: string }; result: void };
|
||||
open_downloaded_file: { args: { path: string }; result: void };
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
resume_download: { args: { id: string }; result: boolean };
|
||||
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||
begin_dock_badge_session: { args: undefined; result: number };
|
||||
@@ -48,6 +49,7 @@ type CommandMap = {
|
||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
|
||||
set_concurrent_limit: { args: { limit: number }; result: void };
|
||||
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
|
||||
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
|
||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||
request_automation_permission: { args: undefined; result: void };
|
||||
|
||||
@@ -267,6 +267,166 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('persists a queue concurrency override only after backend synchronization', async () => {
|
||||
useDownloadStore.setState({
|
||||
queues: [
|
||||
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||
{ id: 'queue-a', name: 'Downloads', isMain: false }
|
||||
]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
await useDownloadStore.getState().setQueueConcurrency('queue-a', 2);
|
||||
|
||||
expect(useDownloadStore.getState().queues).toEqual([
|
||||
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
|
||||
]);
|
||||
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
||||
'set_queue_concurrency_limits',
|
||||
{
|
||||
limits: [
|
||||
{ id: 'main', maxConcurrent: null },
|
||||
{ id: 'queue-a', maxConcurrent: 2 }
|
||||
]
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('retains the previous queue concurrency after backend synchronization fails', async () => {
|
||||
useDownloadStore.setState({
|
||||
queues: [
|
||||
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
|
||||
]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
|
||||
|
||||
await expect(useDownloadStore.getState().setQueueConcurrency('queue-a', 3))
|
||||
.rejects.toThrow('backend unavailable');
|
||||
expect(useDownloadStore.getState().queues[1].maxConcurrent).toBe(2);
|
||||
});
|
||||
|
||||
it('rebases queue concurrency updates when queue state changes during IPC', async () => {
|
||||
useDownloadStore.setState({
|
||||
queues: [
|
||||
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||
{ id: 'queue-a', name: 'Downloads', isMain: false }
|
||||
]
|
||||
});
|
||||
let releaseFirstSync!: () => void;
|
||||
const firstSyncReleased = new Promise<void>(resolve => { releaseFirstSync = resolve; });
|
||||
let syncCalls = 0;
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||
if (command === 'set_queue_concurrency_limits') {
|
||||
syncCalls += 1;
|
||||
if (syncCalls === 1) await firstSyncReleased;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const update = useDownloadStore.getState().setQueueConcurrency('queue-a', 3);
|
||||
await vi.waitFor(() => expect(syncCalls).toBe(1));
|
||||
useDownloadStore.setState(state => ({
|
||||
queues: state.queues.filter(queue => queue.id !== 'queue-a')
|
||||
}));
|
||||
releaseFirstSync();
|
||||
|
||||
await expect(update).rejects.toThrow('Queue no longer exists.');
|
||||
expect(useDownloadStore.getState().queues).toEqual([
|
||||
{ id: 'main', name: 'Main Queue', isMain: true }
|
||||
]);
|
||||
expect(syncCalls).toBe(2);
|
||||
const configCalls = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'set_queue_concurrency_limits');
|
||||
expect(configCalls[1]).toEqual([
|
||||
'set_queue_concurrency_limits',
|
||||
{ limits: [{ id: 'main', maxConcurrent: null }] }
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates an active normal download after applying a live speed limit', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'live-speed',
|
||||
status: 'downloading',
|
||||
isMedia: false,
|
||||
speedLimit: '512K'
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', '2M');
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_download_speed_limit', {
|
||||
id: 'live-speed',
|
||||
limit: '2M'
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
|
||||
|
||||
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', null);
|
||||
const speedLimitCalls = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'set_download_speed_limit');
|
||||
expect(speedLimitCalls[speedLimitCalls.length - 1]).toEqual(['set_download_speed_limit', {
|
||||
id: 'live-speed',
|
||||
limit: null
|
||||
}]);
|
||||
expect(useDownloadStore.getState().downloads[0].speedLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects live speed changes for media and inactive downloads', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'media-speed', status: 'downloading', isMedia: true, speedLimit: '1M' },
|
||||
{ id: 'paused-speed', status: 'paused', isMedia: false, speedLimit: '1M' }
|
||||
] as any[]
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().setDownloadSpeedLimit('media-speed', '2M'))
|
||||
.rejects.toThrow('media downloads');
|
||||
await expect(useDownloadStore.getState().setDownloadSpeedLimit('paused-speed', '2M'))
|
||||
.rejects.toThrow('active download');
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_download_speed_limit', expect.anything());
|
||||
});
|
||||
|
||||
it('keeps the prior live speed limit when the backend rejects the update', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'live-speed-failure',
|
||||
status: 'downloading',
|
||||
isMedia: false,
|
||||
speedLimit: '512K'
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||
if (command === 'set_download_speed_limit') throw new Error('aria2 unavailable');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().setDownloadSpeedLimit('live-speed-failure', '2M'))
|
||||
.rejects.toThrow('aria2 unavailable');
|
||||
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('512K');
|
||||
});
|
||||
|
||||
it('coalesces duplicate live speed updates for one download', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{ id: 'live-speed-duplicate', status: 'downloading', isMedia: false }] as any[]
|
||||
});
|
||||
let releaseBackend!: () => void;
|
||||
const backendFinished = new Promise<void>(resolve => { releaseBackend = resolve; });
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async () => backendFinished);
|
||||
|
||||
const first = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '2M');
|
||||
const second = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '3M');
|
||||
expect(second).toBe(first);
|
||||
releaseBackend();
|
||||
await first;
|
||||
|
||||
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'set_download_speed_limit'))
|
||||
.toHaveLength(1);
|
||||
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
|
||||
});
|
||||
|
||||
it('normalizes malformed persisted queues around one canonical main queue', () => {
|
||||
expect(normalizePersistedQueues([
|
||||
{ id: 'custom-a', name: ' Downloads ', isMain: false },
|
||||
@@ -286,6 +446,47 @@ describe('useDownloadStore', () => {
|
||||
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
|
||||
});
|
||||
|
||||
it('keeps only valid persisted queue concurrency overrides', () => {
|
||||
expect(normalizePersistedQueues([
|
||||
{ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 },
|
||||
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
|
||||
{ id: 'zero', name: 'Zero', isMain: false, maxConcurrent: 0 },
|
||||
{ id: 'large', name: 'Large', isMain: false, maxConcurrent: 13 },
|
||||
{ id: 'null', name: 'Null', isMain: false, maxConcurrent: null }
|
||||
])).toEqual([
|
||||
{ id: '00000000-0000-0000-0000-000000000001', name: 'Main', isMain: true, maxConcurrent: 4 },
|
||||
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
|
||||
{ id: 'zero', name: 'Zero', isMain: false },
|
||||
{ id: 'large', name: 'Large', isMain: false },
|
||||
{ id: 'null', name: 'Null', isMain: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('synchronizes normalized queue limits before startup resume can run', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') {
|
||||
return [
|
||||
JSON.stringify({ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 }),
|
||||
JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false, maxConcurrent: 0 })
|
||||
];
|
||||
}
|
||||
if (cmd === 'db_get_all_downloads') return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
|
||||
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
||||
'set_queue_concurrency_limits',
|
||||
{
|
||||
limits: [
|
||||
{ id: '00000000-0000-0000-0000-000000000001', maxConcurrent: 4 },
|
||||
{ id: 'queue-a', maxConcurrent: null }
|
||||
]
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('remaps persisted downloads when queue records are malformed or missing', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') {
|
||||
@@ -731,6 +932,10 @@ describe('useDownloadStore', () => {
|
||||
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
||||
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
|
||||
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
|
||||
expect(calls.find(c => c[0] === 'resume_download')?.[1]).toEqual({
|
||||
id: 'resume-generation',
|
||||
queueId: 'MAIN'
|
||||
});
|
||||
expect(enqueueGeneration).toBe('1');
|
||||
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
|
||||
|
||||
@@ -28,6 +28,7 @@ const downloadLifecycleGenerations = new Map<string, bigint>();
|
||||
const queueReorderPromises = new Map<string, Promise<void>>();
|
||||
const queueStartPromises = new Map<string, Promise<string[]>>();
|
||||
const queueControlGenerations = new Map<string, number>();
|
||||
let queueConfigurationQueue: Promise<void> = Promise.resolve();
|
||||
type DownloadLifecycleOperation = {
|
||||
kind: string;
|
||||
promise: Promise<unknown>;
|
||||
@@ -585,18 +586,39 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
export type { DownloadStatus };
|
||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
|
||||
const MAX_QUEUE_CONCURRENT = 12;
|
||||
|
||||
const queueNameKey = (name: string): string => name.trim().toLowerCase();
|
||||
|
||||
export const normalizePersistedQueueState = (queues: Queue[]) => {
|
||||
const normalizeQueueConcurrency = (value: unknown): number | undefined => {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
|
||||
return value >= 1 && value <= MAX_QUEUE_CONCURRENT ? value : undefined;
|
||||
};
|
||||
|
||||
type PersistedQueue = Omit<Queue, 'maxConcurrent'> & {
|
||||
maxConcurrent?: number | null;
|
||||
};
|
||||
|
||||
const queueWithNormalizedConcurrency = (queue: PersistedQueue): Queue => {
|
||||
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
|
||||
return maxConcurrent === undefined
|
||||
? { id: queue.id, name: queue.name, isMain: queue.isMain }
|
||||
: { id: queue.id, name: queue.name, isMain: queue.isMain, maxConcurrent };
|
||||
};
|
||||
|
||||
export const normalizePersistedQueueState = (queues: PersistedQueue[]) => {
|
||||
const validQueues = queues.filter(queue =>
|
||||
queue && typeof queue.id === 'string' && typeof queue.name === 'string'
|
||||
);
|
||||
).map(queueWithNormalizedConcurrency);
|
||||
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|
||||
|| validQueues.find(queue => queue.isMain);
|
||||
const persistedMainId = persistedMain?.id.trim();
|
||||
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
|
||||
const normalized: Queue[] = [{ id: MAIN_QUEUE_ID, name: mainName, isMain: true }];
|
||||
const normalizedMain: Queue = { id: MAIN_QUEUE_ID, name: mainName, isMain: true };
|
||||
if (persistedMain?.maxConcurrent !== undefined) {
|
||||
normalizedMain.maxConcurrent = persistedMain.maxConcurrent;
|
||||
}
|
||||
const normalized: Queue[] = [normalizedMain];
|
||||
const seenIds = new Set([MAIN_QUEUE_ID]);
|
||||
const seenNames = new Set([queueNameKey(mainName)]);
|
||||
const queueIdRemap = new Map<string, string>();
|
||||
@@ -620,15 +642,37 @@ export const normalizePersistedQueueState = (queues: Queue[]) => {
|
||||
}
|
||||
seenIds.add(id);
|
||||
seenNames.add(queueNameKey(name));
|
||||
normalized.push({ id, name, isMain: false });
|
||||
const normalizedQueue = { id, name, isMain: false } as Queue;
|
||||
if (queue.maxConcurrent !== undefined) {
|
||||
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
|
||||
if (maxConcurrent !== undefined) normalizedQueue.maxConcurrent = maxConcurrent;
|
||||
}
|
||||
normalized.push(normalizedQueue);
|
||||
}
|
||||
|
||||
return { queues: normalized, queueIdRemap };
|
||||
};
|
||||
|
||||
export const normalizePersistedQueues = (queues: Queue[]): Queue[] =>
|
||||
export const normalizePersistedQueues = (queues: PersistedQueue[]): Queue[] =>
|
||||
normalizePersistedQueueState(queues).queues;
|
||||
|
||||
const synchronizeQueueConcurrencyLimits = async (queues: Queue[]): Promise<void> => {
|
||||
await invoke('set_queue_concurrency_limits', {
|
||||
limits: queues.map(queue => ({
|
||||
id: queue.id,
|
||||
maxConcurrent: queue.maxConcurrent ?? null
|
||||
}))
|
||||
});
|
||||
};
|
||||
|
||||
const sameQueueConcurrencyConfig = (left: Queue[], right: Queue[]): boolean =>
|
||||
left.length === right.length && left.every((queue, index) => {
|
||||
const other = right[index];
|
||||
return other !== undefined
|
||||
&& queue.id === other.id
|
||||
&& (queue.maxConcurrent ?? null) === (other.maxConcurrent ?? null);
|
||||
});
|
||||
|
||||
export type { DownloadItem, Queue };
|
||||
export type ExtensionDownloadRequest = ExtensionDownload;
|
||||
export type AddDownloadAction =
|
||||
@@ -706,6 +750,8 @@ interface DownloadState {
|
||||
startAll: () => Promise<number>;
|
||||
pauseAll: () => Promise<number>;
|
||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
|
||||
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
|
||||
addQueue: (name: string) => boolean;
|
||||
renameQueue: (id: string, name: string) => boolean;
|
||||
removeQueue: (id: string) => Promise<void>;
|
||||
@@ -794,7 +840,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
lastTry: new Date().toISOString()
|
||||
});
|
||||
|
||||
const resumedExisting = await invoke('resume_download', { id });
|
||||
const resumedExisting = await invoke('resume_download', {
|
||||
id,
|
||||
queueId: targetItem.queueId || MAIN_QUEUE_ID
|
||||
});
|
||||
|
||||
let dispatchSucceeded = resumedExisting;
|
||||
if (!dispatchSucceeded) {
|
||||
@@ -1551,6 +1600,79 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
}));
|
||||
});
|
||||
},
|
||||
setDownloadSpeedLimit: (id, limit) => runDownloadLifecycleOperation(
|
||||
id,
|
||||
'speed-limit',
|
||||
async () => {
|
||||
await waitForPendingStartupResume();
|
||||
const item = get().downloads.find(download => download.id === id);
|
||||
if (!item) throw new Error('Download no longer exists.');
|
||||
if (item.isMedia) {
|
||||
throw new Error('Live speed control is unavailable for media downloads.');
|
||||
}
|
||||
if (!['downloading', 'retrying'].includes(item.status)) {
|
||||
throw new Error('Live speed control requires an active download.');
|
||||
}
|
||||
|
||||
const trimmed = limit?.trim() || '';
|
||||
const normalizedLimit = trimmed
|
||||
? normalizeSpeedLimitForBackend(trimmed)
|
||||
: null;
|
||||
if (trimmed && normalizedLimit === null) {
|
||||
throw new Error('Enter a valid speed limit.');
|
||||
}
|
||||
|
||||
await invoke('set_download_speed_limit', {
|
||||
id,
|
||||
limit: normalizedLimit
|
||||
});
|
||||
if (get().downloads.some(download => download.id === id)) {
|
||||
get().updateDownload(id, { speedLimit: normalizedLimit ?? undefined });
|
||||
}
|
||||
},
|
||||
true,
|
||||
preemptDispatch
|
||||
),
|
||||
setQueueConcurrency: (id, maxConcurrent) => {
|
||||
const operation = queueConfigurationQueue.then(async () => {
|
||||
if (
|
||||
maxConcurrent !== null
|
||||
&& (!Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > MAX_QUEUE_CONCURRENT)
|
||||
) {
|
||||
throw new Error('Queue concurrency must be between 1 and 12.');
|
||||
}
|
||||
const currentQueues = get().queues;
|
||||
if (!currentQueues.some(queue => queue.id === id)) {
|
||||
throw new Error('Queue no longer exists.');
|
||||
}
|
||||
const nextQueues = currentQueues.map(queue =>
|
||||
queue.id === id
|
||||
? maxConcurrent === null
|
||||
? { id: queue.id, name: queue.name, isMain: queue.isMain }
|
||||
: { ...queue, maxConcurrent }
|
||||
: queue
|
||||
);
|
||||
await synchronizeQueueConcurrencyLimits(nextQueues);
|
||||
const latestQueues = get().queues;
|
||||
if (!latestQueues.some(queue => queue.id === id)) {
|
||||
await synchronizeQueueConcurrencyLimits(latestQueues);
|
||||
throw new Error('Queue no longer exists.');
|
||||
}
|
||||
const rebasedQueues = latestQueues.map(queue =>
|
||||
queue.id === id
|
||||
? maxConcurrent === null
|
||||
? { id: queue.id, name: queue.name, isMain: queue.isMain }
|
||||
: { ...queue, maxConcurrent }
|
||||
: queue
|
||||
);
|
||||
if (!sameQueueConcurrencyConfig(nextQueues, rebasedQueues)) {
|
||||
await synchronizeQueueConcurrencyLimits(rebasedQueues);
|
||||
}
|
||||
set({ queues: rebasedQueues });
|
||||
});
|
||||
queueConfigurationQueue = operation.then(() => undefined, () => undefined);
|
||||
return operation;
|
||||
},
|
||||
addQueue: (name) => {
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) return false;
|
||||
@@ -1772,7 +1894,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
try {
|
||||
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
|
||||
try {
|
||||
return [JSON.parse(value) as Queue];
|
||||
return [JSON.parse(value) as PersistedQueue];
|
||||
} catch {
|
||||
console.warn('Skipping malformed persisted queue record during startup');
|
||||
return [];
|
||||
@@ -1796,6 +1918,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
? normalizeQueuePositions(downloads)
|
||||
: state.downloads
|
||||
}));
|
||||
|
||||
// The backend dispatcher is live before the frontend finishes startup.
|
||||
// Synchronize the normalized queue policy before any saved download is
|
||||
// allowed to claim a permit.
|
||||
await synchronizeQueueConcurrencyLimits(queues);
|
||||
|
||||
// Reset interrupted active downloads to queued.
|
||||
set((state) => ({
|
||||
|
||||
Reference in New Issue
Block a user