From bd067926454b95171987ce53e0f5a1ce47473f36 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 24 Mar 2026 20:52:17 -0500 Subject: [PATCH] =?UTF-8?q?feat(S03/T02):=20Add=20useCollectMonitored/useC?= =?UTF-8?q?ollectAllMonitored=20hooks=20and=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/frontend/src/api/hooks/useContent.ts - src/frontend/src/pages/ChannelDetail.tsx - src/frontend/src/pages/Channels.tsx --- src/frontend/src/api/hooks/useContent.ts | 40 +++++++++++++++++ src/frontend/src/pages/ChannelDetail.tsx | 57 ++++++++++++++++++++++-- src/frontend/src/pages/Channels.tsx | 56 +++++++++++++++++++++-- 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/api/hooks/useContent.ts b/src/frontend/src/api/hooks/useContent.ts index e94cca8..d9f0a4e 100644 --- a/src/frontend/src/api/hooks/useContent.ts +++ b/src/frontend/src/api/hooks/useContent.ts @@ -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(`/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('/api/v1/channel/collect-all'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['content'] }); + queryClient.invalidateQueries({ queryKey: queueKeys.all }); + }, + }); +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 2f43476..e9e29fd 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -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() { - {/* Scan Now button */} + {/* Refresh & Scan button */} + + {/* Collect Monitored button */} + {/* Refresh Playlists button (YouTube only) */} diff --git a/src/frontend/src/pages/Channels.tsx b/src/frontend/src/pages/Channels.tsx index b9e0473..585f91c 100644 --- a/src/frontend/src/pages/Channels.tsx +++ b/src/frontend/src/pages/Channels.tsx @@ -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
- {/* Scan All button */} + {/* Refresh All button */} + {/* Collect All Monitored button */} + {/* Add Channel button */}