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:
parent
cdd1128632
commit
cc50ed25e9
2 changed files with 183 additions and 30 deletions
|
|
@ -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,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue