chore(M007/S01): auto-commit after research-slice

This commit is contained in:
John Lightner 2026-03-24 23:04:09 -05:00
parent 436c19fca1
commit dbe163bdbb
5 changed files with 97 additions and 8 deletions

View file

@ -54,7 +54,7 @@
{ {
"idx": 7, "idx": 7,
"version": "6", "version": "6",
"when": 1774396066443, "when": 1774742400000,
"tag": "0007_steep_the_watchers", "tag": "0007_steep_the_watchers",
"breakpoints": true "breakpoints": true
}, },

View file

@ -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<ScanStatusResponse>(
`/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). */ /** Set the monitoring mode for a channel (cascades to content items). */
export function useSetMonitoringMode(channelId: number) { export function useSetMonitoringMode(channelId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View file

@ -18,8 +18,9 @@ import {
Search, Search,
Trash2, Trash2,
} from 'lucide-react'; } 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 { useChannelContent, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored } 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';
@ -100,6 +101,7 @@ export function ChannelDetail() {
// ── Local state ── // ── Local state ──
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null); const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null);
const [scanInProgress, setScanInProgress] = useState(false);
const [sortKey, setSortKey] = useState<string | null>(null); const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [expandedPlaylists, setExpandedPlaylists] = useState<Set<number | 'uncategorized'>>(new Set()); const [expandedPlaylists, setExpandedPlaylists] = useState<Set<number | 'uncategorized'>>(new Set());
@ -121,6 +123,26 @@ export function ChannelDetail() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [scanResult]); }, [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 ── // ── Handlers ──
const handleFormatProfileChange = useCallback( const handleFormatProfileChange = useCallback(
@ -155,12 +177,13 @@ export function ChannelDetail() {
scanChannel.mutate(undefined, { scanChannel.mutate(undefined, {
onSuccess: (result) => { onSuccess: (result) => {
if (result.status === 'already_running') { if (result.status === 'already_running') {
setScanResult({ message: 'Scan already in progress', isError: false }); setScanInProgress(true);
} else if (result.status === 'rate_limited') { } else if (result.status === 'rate_limited') {
setScanResult({ message: 'Rate limited — try again later', isError: false }); setScanResult({ message: 'Rate limited — try again later', isError: false });
} else if (result.status === 'error') { } else if (result.status === 'error') {
setScanResult({ message: 'Scan failed — check server logs', isError: true }); setScanResult({ message: 'Scan failed — check server logs', isError: true });
} else { } else {
// Scan completed synchronously (fast — small channel or cached)
const msg = result.newItems > 0 const msg = result.newItems > 0
? `Found ${result.newItems} new item${result.newItems === 1 ? '' : 's'}` ? `Found ${result.newItems} new item${result.newItems === 1 ? '' : 's'}`
: 'No new content'; : 'No new content';
@ -906,8 +929,8 @@ export function ChannelDetail() {
{/* Refresh & Scan button */} {/* Refresh & Scan button */}
<button <button
onClick={handleScan} onClick={handleScan}
disabled={scanChannel.isPending} disabled={scanChannel.isPending || scanInProgress}
title="Refresh & Scan" title={scanInProgress ? 'Scan in progress…' : 'Refresh & Scan'}
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
@ -920,15 +943,15 @@ export function ChannelDetail() {
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
transition: 'all var(--transition-fast)', transition: 'all var(--transition-fast)',
opacity: scanChannel.isPending ? 0.6 : 1, opacity: (scanChannel.isPending || scanInProgress) ? 0.6 : 1,
}} }}
> >
{scanChannel.isPending ? ( {(scanChannel.isPending || scanInProgress) ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : ( ) : (
<RefreshCw size={14} /> <RefreshCw size={14} />
)} )}
{scanChannel.isPending ? 'Scanning...' : 'Refresh & Scan'} {scanInProgress ? 'Scanning…' : scanChannel.isPending ? 'Scanning' : 'Refresh & Scan'}
</button> </button>
{/* Collect Monitored button */} {/* Collect Monitored button */}

View file

@ -105,4 +105,23 @@ export async function scanRoutes(fastify: FastifyInstance): Promise<void> {
return result; 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 };
}
);
} }

View file

@ -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. * Get the current state of the scheduler for diagnostic inspection.
*/ */