diff --git a/src/__tests__/queue-service.test.ts b/src/__tests__/queue-service.test.ts index f3f61e7..1d3fd58 100644 --- a/src/__tests__/queue-service.test.ts +++ b/src/__tests__/queue-service.test.ts @@ -518,6 +518,129 @@ describe('QueueService', () => { }); }); + // ── Pause ── + + describe('pauseItem', () => { + it('pauses a pending item', async () => { + const qs = new QueueService(db, mockDownloadService as any, 0); + qs.stop(); + + await qs.enqueue(contentItems[0].id); + + const paused = await qs.pauseItem(1); + expect(paused.status).toBe('paused'); + }); + + it('pauses a downloading item by aborting the active download', async () => { + // Use a deferred so we can control when the download completes + let rejectFn: (err: Error) => void; + mockDownloadService.downloadItem.mockImplementationOnce(() => { + return new Promise((_resolve, reject) => { + rejectFn = reject; + }); + }); + + const qs = new QueueService(db, mockDownloadService as any, 1); + + await qs.enqueue(contentItems[0].id); + await tick(50); // Let it transition to downloading + + // Item should be downloading + let item = await getQueueItemById(db, 1); + expect(item!.status).toBe('downloading'); + + // Pause it — this should abort the download + const paused = await qs.pauseItem(1); + expect(paused.status).toBe('paused'); + + // Simulate the abort rejection (in real code, the AbortController signal kills yt-dlp) + rejectFn!(new Error('aborted')); + await tick(50); + + // Item should remain paused (not retried as failed) + item = await getQueueItemById(db, 1); + expect(item!.status).toBe('paused'); + }); + + it('throws for completed item', async () => { + const qs = new QueueService(db, mockDownloadService as any, 0); + qs.stop(); + + await qs.enqueue(contentItems[0].id); + await updateQueueItemStatus(db, 1, 'completed'); + + await expect(qs.pauseItem(1)).rejects.toThrow(/Cannot pause/); + }); + + it('throws for cancelled item', async () => { + const qs = new QueueService(db, mockDownloadService as any, 0); + qs.stop(); + + await qs.enqueue(contentItems[0].id); + await updateQueueItemStatus(db, 1, 'cancelled'); + + await expect(qs.pauseItem(1)).rejects.toThrow(/Cannot pause/); + }); + + it('throws for non-existent item', async () => { + const qs = new QueueService(db, mockDownloadService as any, 0); + await expect(qs.pauseItem(99999)).rejects.toThrow(/not found/); + }); + }); + + // ── Resume ── + + describe('resumeItem', () => { + it('resumes a paused item back to pending', async () => { + const qs = new QueueService(db, mockDownloadService as any, 0); + qs.stop(); + + await qs.enqueue(contentItems[0].id); + await qs.pauseItem(1); + + const resumed = await qs.resumeItem(1); + expect(resumed.status).toBe('pending'); + expect(resumed.error).toBeNull(); + + // Content status should be reset to queued + const contentItem = await getContentItemById(db, contentItems[0].id); + expect(contentItem!.status).toBe('queued'); + }); + + it('throws for non-paused item', async () => { + const qs = new QueueService(db, mockDownloadService as any, 0); + qs.stop(); + + await qs.enqueue(contentItems[0].id); + + await expect(qs.resumeItem(1)).rejects.toThrow(/expected 'paused'/); + }); + + it('throws for non-existent item', async () => { + const qs = new QueueService(db, mockDownloadService as any, 0); + await expect(qs.resumeItem(99999)).rejects.toThrow(/not found/); + }); + + it('triggers processNext after resume', async () => { + // After resuming, the item should get picked up and processed + const qs = new QueueService(db, mockDownloadService as any, 1); + + // Enqueue and pause + qs.stop(); + await qs.enqueue(contentItems[0].id); + await qs.pauseItem(1); + + // Resume — processNext should fire and download + qs.start(); + await qs.resumeItem(1); + await tick(100); + + const item = await getQueueItemById(db, 1); + expect(item!.status).toBe('completed'); + expect(mockDownloadService.downloadItem).toHaveBeenCalledOnce(); + }); + }); + // ── getState ── describe('getState', () => { @@ -539,6 +662,7 @@ describe('QueueService', () => { expect(state.failed).toBe(1); expect(state.downloading).toBe(0); expect(state.cancelled).toBe(0); + expect(state.paused).toBe(0); }); it('returns all zeros when queue is empty', async () => { @@ -551,6 +675,7 @@ describe('QueueService', () => { completed: 0, failed: 0, cancelled: 0, + paused: 0, }); }); }); diff --git a/src/db/repositories/queue-repository.ts b/src/db/repositories/queue-repository.ts index 0eab072..f347f27 100644 --- a/src/db/repositories/queue-repository.ts +++ b/src/db/repositories/queue-repository.ts @@ -192,6 +192,7 @@ export async function countQueueItemsByStatus( completed: 0, failed: 0, cancelled: 0, + paused: 0, }; for (const row of rows) { diff --git a/src/frontend/src/api/hooks/useQueue.ts b/src/frontend/src/api/hooks/useQueue.ts index 95c7dbe..92136bf 100644 --- a/src/frontend/src/api/hooks/useQueue.ts +++ b/src/frontend/src/api/hooks/useQueue.ts @@ -57,3 +57,33 @@ export function useCancelQueueItem() { }, }); } + +/** Pause a pending or downloading queue item. */ +export function usePauseQueueItem() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => + apiClient.put<{ success: boolean; data: QueueItem }>( + `/api/v1/queue/${id}/pause`, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queueKeys.all }); + }, + }); +} + +/** Resume a paused queue item. */ +export function useResumeQueueItem() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => + apiClient.put<{ success: boolean; data: QueueItem }>( + `/api/v1/queue/${id}/resume`, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queueKeys.all }); + }, + }); +} diff --git a/src/frontend/src/components/StatusBadge.tsx b/src/frontend/src/components/StatusBadge.tsx index 64b3c7e..b71556a 100644 --- a/src/frontend/src/components/StatusBadge.tsx +++ b/src/frontend/src/components/StatusBadge.tsx @@ -21,6 +21,7 @@ const STATUS_STYLES: Record = { // Queue statuses pending: { color: 'var(--warning)', backgroundColor: 'var(--warning-bg)' }, completed: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' }, + paused: { color: 'var(--info)', backgroundColor: 'var(--info-bg)' }, cancelled: { color: 'var(--text-muted)', backgroundColor: 'var(--bg-hover)' }, // Check statuses success: { color: 'var(--success)', backgroundColor: 'var(--success-bg)' }, diff --git a/src/frontend/src/pages/Queue.tsx b/src/frontend/src/pages/Queue.tsx index 241b284..2689032 100644 --- a/src/frontend/src/pages/Queue.tsx +++ b/src/frontend/src/pages/Queue.tsx @@ -28,6 +28,7 @@ const STATUS_TABS: { value: QueueStatus | ''; label: string }[] = [ { value: '', label: 'All' }, { value: 'pending', label: 'Pending' }, { value: 'downloading', label: 'Downloading' }, + { value: 'paused', label: 'Paused' }, { value: 'completed', label: 'Completed' }, { value: 'failed', label: 'Failed' }, ]; @@ -161,9 +162,39 @@ export function Queue() { { key: 'actions', label: 'Actions', - width: '100px', + width: '120px', render: (item) => (
+ {(item.status === 'pending' || item.status === 'downloading') && ( + + )} + {item.status === 'paused' && ( + + )} {item.status === 'failed' && ( )} - {item.status === 'pending' && ( + {(item.status === 'pending' || item.status === 'paused') && (