chore(M007/S01): auto-commit after research-slice
This commit is contained in:
parent
436c19fca1
commit
dbe163bdbb
5 changed files with 97 additions and 8 deletions
|
|
@ -54,7 +54,7 @@
|
|||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1774396066443,
|
||||
"when": 1774742400000,
|
||||
"tag": "0007_steep_the_watchers",
|
||||
"breakpoints": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue