perf: Expanded DownloadProgressContext with ScanStore to handle scan We…

- "src/frontend/src/contexts/DownloadProgressContext.tsx"
- "src/frontend/src/pages/ChannelDetail.tsx"

GSD-Task: S05/T02
This commit is contained in:
jlightner 2026-04-03 07:08:21 +00:00
parent cdd1128632
commit cc50ed25e9
2 changed files with 183 additions and 30 deletions

View file

@ -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<number, ProgressInfo>();
@ -63,6 +98,58 @@ class ProgressStore {
}
}
// ── Scan Progress Store ──
export interface ScanProgress {
scanning: boolean;
newItemCount: number;
}
class ScanStore {
private _map = new Map<number, ScanProgress>();
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<number, ScanProgress>;
}
const DownloadProgressContext = createContext<DownloadProgressContextValue | null>(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 (
<DownloadProgressContext.Provider value={{ getProgress, isConnected }}>
<DownloadProgressContext.Provider
value={{
getProgress,
isConnected,
scanStoreSubscribe: scanStore.subscribe,
scanStoreGetSnapshot: scanStore.getSnapshot,
}}
>
{children}
</DownloadProgressContext.Provider>
);
@ -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<PaginatedResponse<ContentItem>>(
{ 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,
),
},
};
},
);
}

View file

@ -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<Set<string | number>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
@ -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') {