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 { 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 { useSyncExternalStore } from 'react';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
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 ──
|
// ── Types ──
|
||||||
|
|
||||||
|
|
@ -32,7 +35,39 @@ interface DownloadFailedEvent {
|
||||||
|
|
||||||
type DownloadEvent = DownloadProgressEvent | DownloadCompleteEvent | 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 {
|
class ProgressStore {
|
||||||
private _map = new Map<number, ProgressInfo>();
|
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 ──
|
// ── Context ──
|
||||||
|
|
||||||
interface DownloadProgressContextValue {
|
interface DownloadProgressContextValue {
|
||||||
|
|
@ -70,6 +157,10 @@ interface DownloadProgressContextValue {
|
||||||
getProgress: (contentItemId: number) => ProgressInfo | undefined;
|
getProgress: (contentItemId: number) => ProgressInfo | undefined;
|
||||||
/** Whether the WebSocket is connected */
|
/** Whether the WebSocket is connected */
|
||||||
isConnected: boolean;
|
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);
|
const DownloadProgressContext = createContext<DownloadProgressContextValue | null>(null);
|
||||||
|
|
@ -80,13 +171,15 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const storeRef = useRef(new ProgressStore());
|
const storeRef = useRef(new ProgressStore());
|
||||||
const store = storeRef.current;
|
const store = storeRef.current;
|
||||||
|
const scanStoreRef = useRef(new ScanStore());
|
||||||
|
const scanStore = scanStoreRef.current;
|
||||||
|
|
||||||
// Subscribe to the store with useSyncExternalStore for optimal re-renders
|
// Subscribe to the store with useSyncExternalStore for optimal re-renders
|
||||||
const progressMap = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
const progressMap = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||||
|
|
||||||
const handleMessage = useCallback(
|
const handleMessage = useCallback(
|
||||||
(data: unknown) => {
|
(data: unknown) => {
|
||||||
const event = data as DownloadEvent;
|
const event = data as DownloadEvent | ScanEvent;
|
||||||
if (!event?.type) return;
|
if (!event?.type) return;
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
|
@ -111,9 +204,30 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
|
||||||
queryClient.invalidateQueries({ queryKey: ['content'] });
|
queryClient.invalidateQueries({ queryKey: ['content'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||||
break;
|
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 });
|
const { isConnected } = useWebSocket({ onMessage: handleMessage });
|
||||||
|
|
@ -126,7 +240,14 @@ export function DownloadProgressProvider({ children }: { children: ReactNode })
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DownloadProgressContext.Provider value={{ getProgress, isConnected }}>
|
<DownloadProgressContext.Provider
|
||||||
|
value={{
|
||||||
|
getProgress,
|
||||||
|
isConnected,
|
||||||
|
scanStoreSubscribe: scanStore.subscribe,
|
||||||
|
scanStoreGetSnapshot: scanStore.getSnapshot,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</DownloadProgressContext.Provider>
|
</DownloadProgressContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
@ -153,3 +274,54 @@ export function useDownloadProgressConnection(): boolean {
|
||||||
const context = useContext(DownloadProgressContext);
|
const context = useContext(DownloadProgressContext);
|
||||||
return context?.isConnected ?? false;
|
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,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} 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 { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent';
|
||||||
import { apiClient } from '../api/client';
|
|
||||||
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
|
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
|
||||||
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
|
|
@ -39,7 +38,7 @@ import { Pagination } from '../components/Pagination';
|
||||||
import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress, useScanProgress } from '../contexts/DownloadProgressContext';
|
||||||
import type { ContentItem, MonitoringMode } from '@shared/types/index';
|
import type { ContentItem, MonitoringMode } from '@shared/types/index';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
@ -173,10 +172,12 @@ export function ChannelDetail() {
|
||||||
const refreshPlaylists = useRefreshPlaylists(channelId);
|
const refreshPlaylists = useRefreshPlaylists(channelId);
|
||||||
const bulkMonitored = useBulkMonitored(channelId);
|
const bulkMonitored = useBulkMonitored(channelId);
|
||||||
|
|
||||||
|
// ── Scan state (WebSocket-driven) ──
|
||||||
|
const { scanning: scanInProgress, newItemCount: _scanNewItemCount } = useScanProgress(channelId);
|
||||||
|
|
||||||
// ── Local state ──
|
// ── Local state ──
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showFullDescription, setShowFullDescription] = useState(false);
|
const [showFullDescription, setShowFullDescription] = useState(false);
|
||||||
const [scanInProgress, setScanInProgress] = useState(false);
|
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string | number>>(new Set());
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
|
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
|
||||||
|
|
@ -208,26 +209,6 @@ export function ChannelDetail() {
|
||||||
}
|
}
|
||||||
}, [channel?.checkInterval]);
|
}, [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
|
// Surface download errors via toast
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (downloadContent.isError) {
|
if (downloadContent.isError) {
|
||||||
|
|
@ -274,7 +255,7 @@ export function ChannelDetail() {
|
||||||
scanChannel.mutate(undefined, {
|
scanChannel.mutate(undefined, {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
if (result.status === 'already_running') {
|
if (result.status === 'already_running') {
|
||||||
setScanInProgress(true);
|
toast('Scan already in progress', 'info');
|
||||||
} else if (result.status === 'rate_limited') {
|
} else if (result.status === 'rate_limited') {
|
||||||
toast('Rate limited — try again later', 'info');
|
toast('Rate limited — try again later', 'info');
|
||||||
} else if (result.status === 'error') {
|
} else if (result.status === 'error') {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue