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,
|
"idx": 7,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1774396066443,
|
"when": 1774742400000,
|
||||||
"tag": "0007_steep_the_watchers",
|
"tag": "0007_steep_the_watchers",
|
||||||
"breakpoints": true
|
"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). */
|
/** 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();
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue