fix(downloads): harden queue admission and live speed controls

This commit is contained in:
NimBold
2026-07-23 04:13:07 +03:30
parent b60818d3af
commit 3587fb0c0d
17 changed files with 1298 additions and 93 deletions
+73
View File
@@ -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>
+40 -1
View File
@@ -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"