From dbe163bdbbbf75443e70edbd2a607a2049bb6c36 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 24 Mar 2026 23:04:09 -0500 Subject: [PATCH] chore(M007/S01): auto-commit after research-slice --- drizzle/meta/_journal.json | 2 +- src/frontend/src/api/hooks/useChannels.ts | 40 +++++++++++++++++++++++ src/frontend/src/pages/ChannelDetail.tsx | 37 +++++++++++++++++---- src/server/routes/scan.ts | 19 +++++++++++ src/services/scheduler.ts | 7 ++++ 5 files changed, 97 insertions(+), 8 deletions(-) diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5c68313..063a6a8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -54,7 +54,7 @@ { "idx": 7, "version": "6", - "when": 1774396066443, + "when": 1774742400000, "tag": "0007_steep_the_watchers", "breakpoints": true }, diff --git a/src/frontend/src/api/hooks/useChannels.ts b/src/frontend/src/api/hooks/useChannels.ts index 4bb250e..51de309 100644 --- a/src/frontend/src/api/hooks/useChannels.ts +++ b/src/frontend/src/api/hooks/useChannels.ts @@ -128,6 +128,46 @@ export function useScanAllChannels() { }); } +// ── Scan Status Polling ── + +interface ScanStatusResponse { + scanning: boolean; +} + +/** + * Poll the scan-status endpoint while `enabled` is true. + * When the scan completes (scanning flips false), calls `onComplete`. + * Polls every 2s. + */ +export function useScanStatus( + channelId: number, + enabled: boolean, + onComplete?: () => void, +) { + const queryClient = useQueryClient(); + const onCompleteRef = { current: onComplete }; + onCompleteRef.current = onComplete; + + return useQuery({ + queryKey: ['scan-status', channelId] as const, + queryFn: async () => { + const result = await apiClient.get( + `/api/v1/channel/${channelId}/scan-status`, + ); + // When scan just finished, refetch content and notify caller + if (!result.scanning) { + queryClient.invalidateQueries({ queryKey: channelKeys.all }); + queryClient.invalidateQueries({ queryKey: channelKeys.detail(channelId) }); + queryClient.invalidateQueries({ queryKey: contentKeys.byChannel(channelId) }); + onCompleteRef.current?.(); + } + return result; + }, + enabled: enabled && channelId > 0, + refetchInterval: enabled ? 2000 : false, + }); +} + /** Set the monitoring mode for a channel (cascades to content items). */ export function useSetMonitoringMode(channelId: number) { const queryClient = useQueryClient(); diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index e9e29fd..3255dc0 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -18,8 +18,9 @@ import { Search, Trash2, } from 'lucide-react'; -import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode } from '../api/hooks/useChannels'; +import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels'; import { useChannelContent, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored } 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'; @@ -100,6 +101,7 @@ export function ChannelDetail() { // ── Local state ── const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null); + const [scanInProgress, setScanInProgress] = useState(false); const [sortKey, setSortKey] = useState(null); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [expandedPlaylists, setExpandedPlaylists] = useState>(new Set()); @@ -121,6 +123,26 @@ export function ChannelDetail() { return () => clearTimeout(timer); }, [scanResult]); + // Poll scan status when a scan is known to be in progress + useScanStatus(channelId, scanInProgress, () => { + setScanInProgress(false); + setScanResult({ message: 'Scan complete — content refreshed', isError: false }); + }); + + // 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]); + // ── Handlers ── const handleFormatProfileChange = useCallback( @@ -155,12 +177,13 @@ export function ChannelDetail() { scanChannel.mutate(undefined, { onSuccess: (result) => { if (result.status === 'already_running') { - setScanResult({ message: 'Scan already in progress', isError: false }); + setScanInProgress(true); } else if (result.status === 'rate_limited') { setScanResult({ message: 'Rate limited — try again later', isError: false }); } else if (result.status === 'error') { setScanResult({ message: 'Scan failed — check server logs', isError: true }); } else { + // Scan completed synchronously (fast — small channel or cached) const msg = result.newItems > 0 ? `Found ${result.newItems} new item${result.newItems === 1 ? '' : 's'}` : 'No new content'; @@ -906,8 +929,8 @@ export function ChannelDetail() { {/* Refresh & Scan button */} {/* Collect Monitored button */} diff --git a/src/server/routes/scan.ts b/src/server/routes/scan.ts index af1ce3a..5c55257 100644 --- a/src/server/routes/scan.ts +++ b/src/server/routes/scan.ts @@ -105,4 +105,23 @@ export async function scanRoutes(fastify: FastifyInstance): Promise { return result; } ); + + // ── GET /api/v1/channel/:id/scan-status ── + + fastify.get<{ Params: { id: string } }>( + '/api/v1/channel/:id/scan-status', + async (request, reply) => { + const id = parseInt(request.params.id, 10); + if (isNaN(id)) { + return reply.status(400).send({ + statusCode: 400, + error: 'Bad Request', + message: 'Channel ID must be a number', + }); + } + + const scanning = fastify.scheduler?.isScanning(id) ?? false; + return { scanning }; + } + ); } diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts index d084ee4..5949ec7 100644 --- a/src/services/scheduler.ts +++ b/src/services/scheduler.ts @@ -289,6 +289,13 @@ export class SchedulerService { } } + /** + * Check whether a channel scan is currently in progress. + */ + isScanning(channelId: number): boolean { + return this.activeChecks.has(channelId); + } + /** * Get the current state of the scheduler for diagnostic inspection. */