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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '../client';
|
import { apiClient } from '../client';
|
||||||
|
import { queueKeys } from './useQueue';
|
||||||
import type { ContentItem } from '@shared/types/index';
|
import type { ContentItem } from '@shared/types/index';
|
||||||
import type { ApiResponse } from '@shared/types/api';
|
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 ──
|
// ── Query Keys ──
|
||||||
|
|
||||||
export const contentKeys = {
|
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,
|
Music,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
|
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 } 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 { 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';
|
||||||
|
|
@ -90,6 +91,7 @@ export function ChannelDetail() {
|
||||||
const deleteChannel = useDeleteChannel();
|
const deleteChannel = useDeleteChannel();
|
||||||
const downloadContent = useDownloadContent();
|
const downloadContent = useDownloadContent();
|
||||||
const scanChannel = useScanChannel(channelId);
|
const scanChannel = useScanChannel(channelId);
|
||||||
|
const collectMonitored = useCollectMonitored(channelId);
|
||||||
const setMonitoringMode = useSetMonitoringMode(channelId);
|
const setMonitoringMode = useSetMonitoringMode(channelId);
|
||||||
const toggleMonitored = useToggleMonitored(channelId);
|
const toggleMonitored = useToggleMonitored(channelId);
|
||||||
const refreshPlaylists = useRefreshPlaylists(channelId);
|
const refreshPlaylists = useRefreshPlaylists(channelId);
|
||||||
|
|
@ -174,6 +176,25 @@ export function ChannelDetail() {
|
||||||
});
|
});
|
||||||
}, [scanChannel]);
|
}, [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(() => {
|
const handleRefreshPlaylists = useCallback(() => {
|
||||||
refreshPlaylists.mutate(undefined, {
|
refreshPlaylists.mutate(undefined, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -882,11 +903,11 @@ export function ChannelDetail() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scan Now button */}
|
{/* Refresh & Scan button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleScan}
|
onClick={handleScan}
|
||||||
disabled={scanChannel.isPending}
|
disabled={scanChannel.isPending}
|
||||||
title="Scan Now"
|
title="Refresh & Scan"
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -907,7 +928,35 @@ export function ChannelDetail() {
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw size={14} />
|
<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>
|
</button>
|
||||||
|
|
||||||
{/* Refresh Playlists button (YouTube only) */}
|
{/* Refresh Playlists button (YouTube only) */}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
|
||||||
|
import { useCollectAllMonitored } from '../api/hooks/useContent';
|
||||||
import { Table, type Column } from '../components/Table';
|
import { Table, type Column } from '../components/Table';
|
||||||
import { PlatformBadge } from '../components/PlatformBadge';
|
import { PlatformBadge } from '../components/PlatformBadge';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
|
@ -33,6 +34,7 @@ export function Channels() {
|
||||||
|
|
||||||
const { data: channels, isLoading, error, refetch } = useChannels();
|
const { data: channels, isLoading, error, refetch } = useChannels();
|
||||||
const scanAll = useScanAllChannels();
|
const scanAll = useScanAllChannels();
|
||||||
|
const collectAll = useCollectAllMonitored();
|
||||||
|
|
||||||
// Auto-dismiss scan result toast after 5 seconds
|
// Auto-dismiss scan result toast after 5 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -59,6 +61,25 @@ export function Channels() {
|
||||||
});
|
});
|
||||||
}, [scanAll]);
|
}, [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(
|
const handleRowClick = useCallback(
|
||||||
(channel: ChannelWithCounts) => {
|
(channel: ChannelWithCounts) => {
|
||||||
navigate(`/channel/${channel.id}`);
|
navigate(`/channel/${channel.id}`);
|
||||||
|
|
@ -234,11 +255,11 @@ export function Channels() {
|
||||||
Channels
|
Channels
|
||||||
</h1>
|
</h1>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
{/* Scan All button */}
|
{/* Refresh All button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleScanAll}
|
onClick={handleScanAll}
|
||||||
disabled={scanAll.isPending}
|
disabled={scanAll.isPending}
|
||||||
title="Scan All"
|
title="Refresh All"
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -259,7 +280,34 @@ export function Channels() {
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw size={16} />
|
<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>
|
</button>
|
||||||
{/* Add Channel button */}
|
{/* Add Channel button */}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue