tubearr/src/frontend/src/contexts/DownloadProgressContext.tsx
jlightner cc50ed25e9 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
2026-04-03 07:23:39 +00:00

327 lines
9.1 KiB
TypeScript

import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react';
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 ──
export interface ProgressInfo {
percent: number;
speed: string;
eta: string;
}
interface DownloadProgressEvent {
type: 'download:progress';
contentItemId: number;
percent: number;
speed: string;
eta: string;
}
interface DownloadCompleteEvent {
type: 'download:complete';
contentItemId: number;
}
interface DownloadFailedEvent {
type: 'download:failed';
contentItemId: number;
error: string;
}
type DownloadEvent = DownloadProgressEvent | DownloadCompleteEvent | DownloadFailedEvent;
// ── 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>();
private _listeners = new Set<() => void>();
subscribe = (listener: () => void) => {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
};
getSnapshot = () => this._map;
set(id: number, info: ProgressInfo) {
this._map = new Map(this._map);
this._map.set(id, info);
this._notify();
}
delete(id: number) {
if (!this._map.has(id)) return;
this._map = new Map(this._map);
this._map.delete(id);
this._notify();
}
private _notify() {
for (const listener of this._listeners) listener();
}
}
// ── 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 {
/** Get progress for a specific content item. Returns undefined if not downloading. */
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);
// ── Provider ──
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 | ScanEvent;
if (!event?.type) return;
switch (event.type) {
case 'download:progress':
store.set(event.contentItemId, {
percent: event.percent,
speed: event.speed,
eta: event.eta,
});
break;
case 'download:complete':
store.delete(event.contentItemId);
// Invalidate content queries so the UI refreshes with updated status
queryClient.invalidateQueries({ queryKey: ['content'] });
queryClient.invalidateQueries({ queryKey: ['queue'] });
break;
case 'download:failed':
store.delete(event.contentItemId);
// Invalidate to show updated status (failed)
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, scanStore, queryClient],
);
const { isConnected } = useWebSocket({ onMessage: handleMessage });
const getProgress = useCallback(
(contentItemId: number): ProgressInfo | undefined => {
return progressMap.get(contentItemId);
},
[progressMap],
);
return (
<DownloadProgressContext.Provider
value={{
getProgress,
isConnected,
scanStoreSubscribe: scanStore.subscribe,
scanStoreGetSnapshot: scanStore.getSnapshot,
}}
>
{children}
</DownloadProgressContext.Provider>
);
}
// ── Hook ──
/**
* Get download progress for a specific content item.
* Returns undefined when the item is not actively downloading via WebSocket.
*/
export function useDownloadProgress(contentItemId: number): ProgressInfo | undefined {
const context = useContext(DownloadProgressContext);
if (!context) {
throw new Error('useDownloadProgress must be used within a DownloadProgressProvider');
}
return context.getProgress(contentItemId);
}
/**
* Get the WebSocket connection status.
*/
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,
),
},
};
},
);
}