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:
parent
e6faa05d1c
commit
bd06792645
3 changed files with 145 additions and 8 deletions
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue