mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-06 01:17:48 +00:00
feat(queue): implement backend-driven download queue coordinator
This commit replaces the frontend-imperative download dispatcher with a centralized backend `QueueManager`. It acts as the sole concurrency gatekeeper using a single `tokio::sync::Semaphore` across all download paths (aria2 RPC, native HTTP, yt-dlp media). Backend Changes: - **queue**: Added `QueueManager` to manage an ordered `VecDeque` of tasks, semaphore permits, and retirement debt (CAS resize). - **commands**: Replaced direct start commands with `enqueue_download`, `enqueue_many`, `move_in_queue`, and `remove_from_queue`. - **ipc**: Exported `DownloadStateEvent` and `QueueDirection` to the frontend. - **tests**: Added 11 integration tests covering idle-parking, idempotent releases, CAS underflow prevention, and gid-completion races. Frontend Changes: - **store**: Made `useDownloadStore` reactive to the backend via the `download-state` event. - **store**: Removed `processQueue` and introduced `pendingOrder` to track the accurate sequence of queued items. - **ui**: Updated `DownloadItem` with queue visuals (clock icon, position badge). - **ui**: Added Move Up/Down controls to interact with the backend queue reordering API.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
import { Play, Pause, MoreVertical } from 'lucide-react';
|
||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
|
||||
interface DownloadItemProps {
|
||||
@@ -24,6 +24,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
getCategoryIcon,
|
||||
}) => {
|
||||
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
|
||||
const pendingOrder = useDownloadStore(state => state.pendingOrder);
|
||||
const moveInQueue = useDownloadStore(state => state.moveInQueue);
|
||||
const queueIndex = pendingOrder.indexOf(downloadId);
|
||||
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const statusTextRef = useRef<HTMLSpanElement>(null);
|
||||
@@ -31,7 +34,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
const etaTextRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// We only need transient updates while it's actively downloading
|
||||
if (!download || download.status !== 'downloading') return;
|
||||
|
||||
const unsubscribe = useDownloadProgressStore.subscribe((state) => {
|
||||
@@ -89,17 +91,32 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<div className="download-progress-track">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className={`download-progress-fill ${download.status === 'paused' ? 'paused' : ''}`}
|
||||
className={`download-progress-fill ${
|
||||
download.status === 'paused' ? 'paused' :
|
||||
download.status === 'queued' ? 'queued' : ''
|
||||
}`}
|
||||
style={{ width: `${(download.fraction || 0) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
ref={statusTextRef}
|
||||
className={`download-status ${download.status === 'paused' ? 'download-status-paused' : download.status === 'failed' ? 'download-status-failed' : download.status === 'downloading' ? 'download-status-downloading' : ''}`}
|
||||
className={`download-status flex items-center gap-1.5 ${
|
||||
download.status === 'paused' ? 'download-status-paused' :
|
||||
download.status === 'failed' ? 'download-status-failed' :
|
||||
download.status === 'downloading' ? 'download-status-downloading' :
|
||||
download.status === 'queued' ? 'download-status-queued' : ''
|
||||
}`}
|
||||
>
|
||||
{download.status === 'downloading'
|
||||
? `${((download.fraction || 0) * 100).toFixed(0)}%`
|
||||
: download.status.charAt(0).toUpperCase() + download.status.slice(1)}
|
||||
{download.status === 'queued' && queueIndex !== -1 ? (
|
||||
<>
|
||||
<Clock size={12} className="animate-pulse shrink-0" />
|
||||
<span className="truncate">Queued #{queueIndex + 1}</span>
|
||||
</>
|
||||
) : download.status === 'downloading' ? (
|
||||
`${((download.fraction || 0) * 100).toFixed(0)}%`
|
||||
) : (
|
||||
download.status.charAt(0).toUpperCase() + download.status.slice(1)
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -119,6 +136,26 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</span>
|
||||
|
||||
<div className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto">
|
||||
{download.status === 'queued' && queueIndex !== -1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => moveInQueue(download.id, 'Up')}
|
||||
disabled={queueIndex === 0}
|
||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||
title="Move Up"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveInQueue(download.id, 'Down')}
|
||||
disabled={queueIndex === pendingOrder.length - 1}
|
||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||
title="Move Down"
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{download.status === 'downloading' && (
|
||||
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title="Pause">
|
||||
<Pause size={14} fill="currentColor" />
|
||||
|
||||
@@ -12,7 +12,7 @@ interface DownloadTableProps {
|
||||
}
|
||||
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const { downloads, toggleAddModal, updateDownload, openDeleteModal, redownload } = useDownloadStore();
|
||||
const { downloads, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
|
||||
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
@@ -93,15 +93,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const handlePause = async (id: string) => {
|
||||
try {
|
||||
await invoke('pause_download', { id });
|
||||
updateDownload(id, { status: 'paused', speed: '-', eta: '-' });
|
||||
} catch (e) {
|
||||
console.error("Failed to pause:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = (item: DownloadItem) => {
|
||||
updateDownload(item.id, { status: 'queued', _dispatched: false, speed: '-', eta: '-' });
|
||||
useDownloadStore.getState().processQueue();
|
||||
useDownloadStore.getState().resumeDownload(item.id);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
|
||||
Reference in New Issue
Block a user