diff --git a/src/frontend/src/contexts/DownloadProgressContext.tsx b/src/frontend/src/contexts/DownloadProgressContext.tsx index 5683b66..23aac8d 100644 --- a/src/frontend/src/contexts/DownloadProgressContext.tsx +++ b/src/frontend/src/contexts/DownloadProgressContext.tsx @@ -1,7 +1,10 @@ import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQueryClient, type QueryClient } from '@tanstack/react-query'; import { useSyncExternalStore } from 'react'; import { useWebSocket } from '../hooks/useWebSocket'; +import { contentKeys } from '../api/hooks/useContent'; +import type { ContentItem } from '@shared/types/index'; +import type { PaginatedResponse } from '@shared/types/api'; // ── Types ── @@ -32,7 +35,39 @@ interface DownloadFailedEvent { type DownloadEvent = DownloadProgressEvent | DownloadCompleteEvent | DownloadFailedEvent; -// ── Store (external to React for zero unnecessary re-renders) ── +// ── Scan Event Types ── + +interface ScanStartedEvent { + type: 'scan:started'; + channelId: number; + channelName: string; +} + +interface ScanItemDiscoveredEvent { + type: 'scan:item-discovered'; + channelId: number; + channelName: string; + item: ContentItem; +} + +interface ScanCompleteEvent { + type: 'scan:complete'; + channelId: number; + channelName: string; + newItems: number; + totalFetched: number; +} + +interface ScanErrorEvent { + type: 'scan:error'; + channelId: number; + channelName: string; + error: string; +} + +type ScanEvent = ScanStartedEvent | ScanItemDiscoveredEvent | ScanCompleteEvent | ScanErrorEvent; + +// ── Download Progress Store (external to React for zero unnecessary re-renders) ── class ProgressStore { private _map = new Map(); @@ -63,6 +98,58 @@ class ProgressStore { } } +// ── Scan Progress Store ── + +export interface ScanProgress { + scanning: boolean; + newItemCount: number; +} + +class ScanStore { + private _map = new Map(); + private _listeners = new Set<() => void>(); + + subscribe = (listener: () => void) => { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + }; + + getSnapshot = () => this._map; + + startScan(channelId: number) { + this._map = new Map(this._map); + this._map.set(channelId, { scanning: true, newItemCount: 0 }); + this._notify(); + } + + incrementItems(channelId: number) { + this._map = new Map(this._map); + const current = this._map.get(channelId) ?? { scanning: true, newItemCount: 0 }; + this._map.set(channelId, { ...current, newItemCount: current.newItemCount + 1 }); + this._notify(); + } + + completeScan(channelId: number) { + this._map = new Map(this._map); + const current = this._map.get(channelId); + if (current) { + this._map.set(channelId, { scanning: false, newItemCount: current.newItemCount }); + } + this._notify(); + } + + clearScan(channelId: number) { + if (!this._map.has(channelId)) return; + this._map = new Map(this._map); + this._map.delete(channelId); + this._notify(); + } + + private _notify() { + for (const listener of this._listeners) listener(); + } +} + // ── Context ── interface DownloadProgressContextValue { @@ -70,6 +157,10 @@ interface DownloadProgressContextValue { getProgress: (contentItemId: number) => ProgressInfo | undefined; /** Whether the WebSocket is connected */ isConnected: boolean; + /** Subscribe to scan store changes */ + scanStoreSubscribe: (listener: () => void) => () => void; + /** Get scan store snapshot */ + scanStoreGetSnapshot: () => Map; } const DownloadProgressContext = createContext(null); @@ -80,13 +171,15 @@ export function DownloadProgressProvider({ children }: { children: ReactNode }) const queryClient = useQueryClient(); const storeRef = useRef(new ProgressStore()); const store = storeRef.current; + const scanStoreRef = useRef(new ScanStore()); + const scanStore = scanStoreRef.current; // Subscribe to the store with useSyncExternalStore for optimal re-renders const progressMap = useSyncExternalStore(store.subscribe, store.getSnapshot); const handleMessage = useCallback( (data: unknown) => { - const event = data as DownloadEvent; + const event = data as DownloadEvent | ScanEvent; if (!event?.type) return; switch (event.type) { @@ -111,9 +204,30 @@ export function DownloadProgressProvider({ children }: { children: ReactNode }) queryClient.invalidateQueries({ queryKey: ['content'] }); queryClient.invalidateQueries({ queryKey: ['queue'] }); break; + + case 'scan:started': + scanStore.startScan(event.channelId); + break; + + case 'scan:item-discovered': + scanStore.incrementItems(event.channelId); + injectContentItemIntoCache(queryClient, event.channelId, event.item); + break; + + case 'scan:complete': + scanStore.completeScan(event.channelId); + // Safety net: reconcile any missed items + queryClient.invalidateQueries({ + queryKey: contentKeys.byChannel(event.channelId), + }); + break; + + case 'scan:error': + scanStore.completeScan(event.channelId); + break; } }, - [store, queryClient], + [store, scanStore, queryClient], ); const { isConnected } = useWebSocket({ onMessage: handleMessage }); @@ -126,7 +240,14 @@ export function DownloadProgressProvider({ children }: { children: ReactNode }) ); return ( - + {children} ); @@ -153,3 +274,54 @@ export function useDownloadProgressConnection(): boolean { const context = useContext(DownloadProgressContext); return context?.isConnected ?? false; } + +// ── Scan Progress Hook ── + +/** + * Get scan progress for a specific channel. + * Returns `{ scanning, newItemCount }` from the scan store via useSyncExternalStore. + * Only re-renders components that use this hook when the scan store changes. + */ +export function useScanProgress(channelId: number): ScanProgress { + const context = useContext(DownloadProgressContext); + if (!context) { + throw new Error('useScanProgress must be used within a DownloadProgressProvider'); + } + const scanMap = useSyncExternalStore( + context.scanStoreSubscribe, + context.scanStoreGetSnapshot, + ); + return scanMap.get(channelId) ?? { scanning: false, newItemCount: 0 }; +} + +// ── Cache Injection Helper ── + +/** + * Inject a newly discovered content item into all matching TanStack Query caches + * for the given channel. Prepends the item to page 1 queries and increments pagination counts. + */ +function injectContentItemIntoCache( + queryClient: QueryClient, + channelId: number, + item: ContentItem, +) { + queryClient.setQueriesData>( + { queryKey: contentKeys.byChannel(channelId) }, + (oldData) => { + if (!oldData?.data) return oldData; + // Avoid duplicates + if (oldData.data.some((existing) => existing.id === item.id)) return oldData; + return { + ...oldData, + data: [item, ...oldData.data], + pagination: { + ...oldData.pagination, + totalItems: oldData.pagination.totalItems + 1, + totalPages: Math.ceil( + (oldData.pagination.totalItems + 1) / oldData.pagination.pageSize, + ), + }, + }; + }, + ); +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 13af27f..3a47737 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -22,9 +22,8 @@ import { Trash2, Users, } from 'lucide-react'; -import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels'; +import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode } from '../api/hooks/useChannels'; import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent'; -import { apiClient } from '../api/client'; import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists'; import { useFormatProfiles } from '../api/hooks/useFormatProfiles'; import { Table, type Column } from '../components/Table'; @@ -39,7 +38,7 @@ import { Pagination } from '../components/Pagination'; import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar'; import { Modal } from '../components/Modal'; import { useToast } from '../components/Toast'; -import { useDownloadProgress } from '../contexts/DownloadProgressContext'; +import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext'; import type { ContentItem, MonitoringMode } from '@shared/types/index'; // ── Helpers ── @@ -173,10 +172,12 @@ export function ChannelDetail() { const refreshPlaylists = useRefreshPlaylists(channelId); const bulkMonitored = useBulkMonitored(channelId); + // ── Scan state (WebSocket-driven) ── + const { scanning: scanInProgress, newItemCount: _scanNewItemCount } = useScanProgress(channelId); + // ── Local state ── const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showFullDescription, setShowFullDescription] = useState(false); - const [scanInProgress, setScanInProgress] = useState(false); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>(new Set()); const [localCheckInterval, setLocalCheckInterval] = useState(''); @@ -208,26 +209,6 @@ export function ChannelDetail() { } }, [channel?.checkInterval]); - // Poll scan status when a scan is known to be in progress - useScanStatus(channelId, scanInProgress, () => { - setScanInProgress(false); - toast('Scan complete — content refreshed', 'success'); - }); - - // On mount, check if a scan is already running (e.g. auto-scan after channel creation) - useEffect(() => { - if (!channelId || channelId <= 0) return; - let cancelled = false; - apiClient.get<{ scanning: boolean }>(`/api/v1/channel/${channelId}/scan-status`) - .then((result) => { - if (!cancelled && result.scanning) { - setScanInProgress(true); - } - }) - .catch(() => { /* ignore — non-critical */ }); - return () => { cancelled = true; }; - }, [channelId]); - // Surface download errors via toast useEffect(() => { if (downloadContent.isError) { @@ -274,7 +255,7 @@ export function ChannelDetail() { scanChannel.mutate(undefined, { onSuccess: (result) => { if (result.status === 'already_running') { - setScanInProgress(true); + toast('Scan already in progress', 'info'); } else if (result.status === 'rate_limited') { toast('Rate limited — try again later', 'info'); } else if (result.status === 'error') {