feat(S03/T02): Add useCollectMonitored/useCollectAllMonitored hooks and…

- src/frontend/src/api/hooks/useContent.ts
- src/frontend/src/pages/ChannelDetail.tsx
- src/frontend/src/pages/Channels.tsx
This commit is contained in:
John Lightner 2026-03-24 20:52:17 -05:00
parent e6faa05d1c
commit bd06792645
3 changed files with 145 additions and 8 deletions

View file

@ -1,8 +1,18 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../client';
import { queueKeys } from './useQueue';
import type { ContentItem } from '@shared/types/index';
import type { ApiResponse } from '@shared/types/api';
// ── Collect Types ──
export interface CollectResult {
enqueued: number;
skipped: number;
errors: number;
items: Array<{ contentItemId: number; status: string }>;
}
// ── Query Keys ──
export const contentKeys = {
@ -74,3 +84,33 @@ export function useBulkMonitored(channelId: number) {
},
});
}
// ── Collect Mutations ──
/** Enqueue all monitored+undownloaded items for a single channel. */
export function useCollectMonitored(channelId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () =>
apiClient.post<CollectResult>(`/api/v1/channel/${channelId}/collect`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: contentKeys.byChannel(channelId) });
queryClient.invalidateQueries({ queryKey: queueKeys.all });
},
});
}
/** Enqueue all monitored+undownloaded items across all channels. */
export function useCollectAllMonitored() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () =>
apiClient.post<CollectResult>('/api/v1/channel/collect-all'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['content'] });
queryClient.invalidateQueries({ queryKey: queueKeys.all });
},
});
}

View file

@ -15,10 +15,11 @@ import {
Music,
RefreshCw,
Save,
Search,
Trash2,
} from 'lucide-react';
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode } from '../api/hooks/useChannels';
import { useChannelContent, useDownloadContent, useToggleMonitored, useBulkMonitored } from '../api/hooks/useContent';
import { useChannelContent, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored } from '../api/hooks/useContent';
import { useChannelPlaylists, useRefreshPlaylists } from '../api/hooks/usePlaylists';
import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
import { Table, type Column } from '../components/Table';
@ -90,6 +91,7 @@ export function ChannelDetail() {
const deleteChannel = useDeleteChannel();
const downloadContent = useDownloadContent();
const scanChannel = useScanChannel(channelId);
const collectMonitored = useCollectMonitored(channelId);
const setMonitoringMode = useSetMonitoringMode(channelId);
const toggleMonitored = useToggleMonitored(channelId);
const refreshPlaylists = useRefreshPlaylists(channelId);
@ -174,6 +176,25 @@ export function ChannelDetail() {
});
}, [scanChannel]);
const handleCollect = useCallback(() => {
collectMonitored.mutate(undefined, {
onSuccess: (result) => {
const parts: string[] = [];
if (result.enqueued > 0) parts.push(`${result.enqueued} enqueued`);
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`);
const msg = parts.length > 0 ? parts.join(', ') : 'No items to collect';
setScanResult({ message: msg, isError: result.errors > 0 });
},
onError: (err) => {
setScanResult({
message: err instanceof Error ? err.message : 'Collect failed',
isError: true,
});
},
});
}, [collectMonitored]);
const handleRefreshPlaylists = useCallback(() => {
refreshPlaylists.mutate(undefined, {
onSuccess: () => {
@ -882,11 +903,11 @@ export function ChannelDetail() {
</button>
</div>
{/* Scan Now button */}
{/* Refresh & Scan button */}
<button
onClick={handleScan}
disabled={scanChannel.isPending}
title="Scan Now"
title="Refresh & Scan"
style={{
display: 'inline-flex',
alignItems: 'center',
@ -907,7 +928,35 @@ export function ChannelDetail() {
) : (
<RefreshCw size={14} />
)}
{scanChannel.isPending ? 'Scanning...' : 'Scan Now'}
{scanChannel.isPending ? 'Scanning...' : 'Refresh & Scan'}
</button>
{/* Collect Monitored button */}
<button
onClick={handleCollect}
disabled={collectMonitored.isPending}
title="Collect Monitored"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
backgroundColor: 'var(--bg-hover)',
color: 'var(--text-secondary)',
border: '1px solid var(--border)',
transition: 'all var(--transition-fast)',
opacity: collectMonitored.isPending ? 0.6 : 1,
}}
>
{collectMonitored.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<Search size={14} />
)}
{collectMonitored.isPending ? 'Collecting...' : 'Collect Monitored'}
</button>
{/* Refresh Playlists button (YouTube only) */}

View file

@ -1,7 +1,8 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Loader, RefreshCw } from 'lucide-react';
import { Plus, Loader, RefreshCw, Search } from 'lucide-react';
import { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
import { useCollectAllMonitored } from '../api/hooks/useContent';
import { Table, type Column } from '../components/Table';
import { PlatformBadge } from '../components/PlatformBadge';
import { StatusBadge } from '../components/StatusBadge';
@ -33,6 +34,7 @@ export function Channels() {
const { data: channels, isLoading, error, refetch } = useChannels();
const scanAll = useScanAllChannels();
const collectAll = useCollectAllMonitored();
// Auto-dismiss scan result toast after 5 seconds
useEffect(() => {
@ -59,6 +61,25 @@ export function Channels() {
});
}, [scanAll]);
const handleCollectAll = useCallback(() => {
collectAll.mutate(undefined, {
onSuccess: (result) => {
const parts: string[] = [];
if (result.enqueued > 0) parts.push(`${result.enqueued} enqueued`);
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`);
const msg = parts.length > 0 ? parts.join(', ') : 'No items to collect';
setScanResult({ message: msg, isError: result.errors > 0 });
},
onError: (err) => {
setScanResult({
message: err instanceof Error ? err.message : 'Collect failed',
isError: true,
});
},
});
}, [collectAll]);
const handleRowClick = useCallback(
(channel: ChannelWithCounts) => {
navigate(`/channel/${channel.id}`);
@ -234,11 +255,11 @@ export function Channels() {
Channels
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
{/* Scan All button */}
{/* Refresh All button */}
<button
onClick={handleScanAll}
disabled={scanAll.isPending}
title="Scan All"
title="Refresh All"
style={{
display: 'inline-flex',
alignItems: 'center',
@ -259,7 +280,34 @@ export function Channels() {
) : (
<RefreshCw size={16} />
)}
{scanAll.isPending ? 'Scanning...' : 'Scan All'}
{scanAll.isPending ? 'Scanning...' : 'Refresh All'}
</button>
{/* Collect All Monitored button */}
<button
onClick={handleCollectAll}
disabled={collectAll.isPending}
title="Collect All Monitored"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-hover)',
color: 'var(--text-secondary)',
border: '1px solid var(--border)',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
transition: 'all var(--transition-fast)',
opacity: collectAll.isPending ? 0.6 : 1,
}}
>
{collectAll.isPending ? (
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<Search size={16} />
)}
{collectAll.isPending ? 'Collecting...' : 'Collect All Monitored'}
</button>
{/* Add Channel button */}
<button