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,
"version": "6",
"when": 1774396066443,
"when": 1774742400000,
"tag": "0007_steep_the_watchers",
"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). */
export function useSetMonitoringMode(channelId: number) {
const queryClient = useQueryClient();

View file

@ -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<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [expandedPlaylists, setExpandedPlaylists] = useState<Set<number | 'uncategorized'>>(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 */}
<button
onClick={handleScan}
disabled={scanChannel.isPending}
title="Refresh & Scan"
disabled={scanChannel.isPending || scanInProgress}
title={scanInProgress ? 'Scan in progress…' : 'Refresh & Scan'}
style={{
display: 'inline-flex',
alignItems: 'center',
@ -920,15 +943,15 @@ export function ChannelDetail() {
color: 'var(--text-secondary)',
border: '1px solid var(--border)',
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' }} />
) : (
<RefreshCw size={14} />
)}
{scanChannel.isPending ? 'Scanning...' : 'Refresh & Scan'}
{scanInProgress ? 'Scanning…' : scanChannel.isPending ? 'Scanning' : 'Refresh & Scan'}
</button>
{/* Collect Monitored button */}

View file

@ -105,4 +105,23 @@ export async function scanRoutes(fastify: FastifyInstance): Promise<void> {
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.
*/