From 4546ddb4ea14a4468c983c40cb5f3fd74b0b98b2 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 21:43:23 +0000 Subject: [PATCH] feat: real-time scan streaming with fire-and-forget API and cancel support - Scan endpoint returns 202 immediately, runs in background - Items appear in real-time via WebSocket scan:item-discovered events - Phase 1 (fast flat-playlist) runs first with discoveryOnly flag - Phase 2 (slow enrichment) runs as background post-scan pass - Added POST /api/v1/channel/:id/scan-cancel endpoint - AbortController support in scheduler for scan cancellation - Frontend: Scan button toggles to Stop button during scan - Frontend: Live item count shown during scanning - Frontend: useCancelScan hook for cancel functionality - Moved tubearr config to local Docker volume (SQLite on CIFS fix) --- src/frontend/src/api/hooks/useChannels.ts | 26 ++-- src/frontend/src/pages/ChannelDetail.tsx | 65 ++++++---- src/server/routes/scan.ts | 57 ++++++++- src/services/scheduler.ts | 137 +++++++++++++++++++++- src/sources/platform-source.ts | 4 + src/sources/youtube.ts | 13 ++ 6 files changed, 259 insertions(+), 43 deletions(-) diff --git a/src/frontend/src/api/hooks/useChannels.ts b/src/frontend/src/api/hooks/useChannels.ts index 4d8582c..a3560dc 100644 --- a/src/frontend/src/api/hooks/useChannels.ts +++ b/src/frontend/src/api/hooks/useChannels.ts @@ -86,9 +86,9 @@ export function useDeleteChannel() { export interface ScanChannelResult { channelId: number; channelName: string; - newItems: number; - totalFetched: number; - status: 'success' | 'error' | 'rate_limited' | 'already_running'; + newItems?: number; + totalFetched?: number; + status: 'started' | 'success' | 'error' | 'rate_limited' | 'already_running'; } export interface ScanAllResult { @@ -98,18 +98,22 @@ export interface ScanAllResult { // ── Scan Mutations ── -/** Trigger a manual scan for a single channel. */ +/** + * Trigger a manual scan for a single channel. + * Returns immediately with status 'started' — progress is streamed via WebSocket. + */ export function useScanChannel(id: number) { - const queryClient = useQueryClient(); - return useMutation({ mutationFn: () => apiClient.post(`/api/v1/channel/${id}/scan`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: channelKeys.all }); - queryClient.invalidateQueries({ queryKey: channelKeys.detail(id) }); - queryClient.invalidateQueries({ queryKey: contentKeys.byChannel(id) }); - }, + }); +} + +/** Cancel an in-progress scan for a channel. */ +export function useCancelScan(id: number) { + return useMutation({ + mutationFn: () => + apiClient.post<{ channelId: number; cancelled: boolean }>(`/api/v1/channel/${id}/scan-cancel`), }); } diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 3a47737..3e33ee1 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -19,10 +19,11 @@ import { Music, RefreshCw, Save, + Square, Trash2, Users, } from 'lucide-react'; -import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode } from '../api/hooks/useChannels'; +import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useCancelScan, useSetMonitoringMode } from '../api/hooks/useChannels'; import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent'; import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists'; import { useFormatProfiles } from '../api/hooks/useFormatProfiles'; @@ -166,6 +167,7 @@ export function ChannelDetail() { const deleteChannel = useDeleteChannel(); const downloadContent = useDownloadContent(); const scanChannel = useScanChannel(channelId); + const cancelScan = useCancelScan(channelId); const collectMonitored = useCollectMonitored(channelId); const setMonitoringMode = useSetMonitoringMode(channelId); const toggleMonitored = useToggleMonitored(channelId); @@ -173,7 +175,7 @@ export function ChannelDetail() { const bulkMonitored = useBulkMonitored(channelId); // ── Scan state (WebSocket-driven) ── - const { scanning: scanInProgress, newItemCount: _scanNewItemCount } = useScanProgress(channelId); + const { scanning: scanInProgress, newItemCount: scanNewItemCount } = useScanProgress(channelId); // ── Local state ── const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -256,16 +258,8 @@ export function ChannelDetail() { onSuccess: (result) => { if (result.status === 'already_running') { toast('Scan already in progress', 'info'); - } else if (result.status === 'rate_limited') { - toast('Rate limited — try again later', 'info'); - } else if (result.status === 'error') { - toast('Scan failed — check server logs', 'error'); - } else { - // Scan completed synchronously (fast — small channel or cached) - const msg = result.newItems > 0 - ? `Found ${result.newItems} new item${result.newItems === 1 ? '' : 's'}` - : 'No new content'; - toast(msg, 'success'); + } else if (result.status === 'started') { + toast('Scan started — items will appear as they\'re found', 'success'); } }, onError: (err) => { @@ -274,6 +268,21 @@ export function ChannelDetail() { }); }, [scanChannel, toast]); + const handleCancelScan = useCallback(() => { + cancelScan.mutate(undefined, { + onSuccess: (result) => { + if (result.cancelled) { + toast('Scan cancelled', 'info'); + } else { + toast('No scan in progress', 'info'); + } + }, + onError: (err) => { + toast(err instanceof Error ? err.message : 'Failed to cancel scan', 'error'); + }, + }); + }, [cancelScan, toast]); + const handleCollect = useCallback(() => { collectMonitored.mutate(undefined, { onSuccess: (result) => { @@ -973,18 +982,20 @@ export function ChannelDetail() {