Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 74 additions & 4 deletions client/src/components/ChannelPage/ChannelVideos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ import ChannelVideosHeader from './ChannelVideosHeader';
import ChannelVideosDialogs from './ChannelVideosDialogs';
import { useChannelVideos } from './hooks/useChannelVideos';
import { useRefreshChannelVideos } from './hooks/useRefreshChannelVideos';
import { useChannelFetchStatus } from './hooks/useChannelFetchStatus';
import { useChannelVideoFilters } from './hooks/useChannelVideoFilters';
import ChannelVideosFilters from './components/ChannelVideosFilters';
import { useConfig } from '../../hooks/useConfig';
import { useTriggerDownloads } from '../../hooks/useTriggerDownloads';

Expand All @@ -67,6 +70,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI
const [sortBy, setSortBy] = useState<SortBy>('date');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [filtersExpanded, setFiltersExpanded] = useState(false);

// Tab states
const [selectedTab, setSelectedTab] = useState<string | null>(null);
Expand All @@ -91,6 +95,20 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI
// Local state to track ignore status changes without refetching
const [localIgnoreStatus, setLocalIgnoreStatus] = useState<Record<string, boolean>>({});

// Filter state
const {
filters,
inputMinDuration,
inputMaxDuration,
setMinDuration,
setMaxDuration,
setDateFrom,
setDateTo,
clearAllFilters,
hasActiveFilters,
activeFilterCount,
} = useChannelVideoFilters();

const { deleteVideosByYoutubeIds, loading: deleteLoading } = useVideoDeletion();

const { channel_id: routeChannelId } = useParams();
Expand Down Expand Up @@ -232,6 +250,10 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI
sortOrder,
tabType: selectedTab,
token,
minDuration: filters.minDuration,
maxDuration: filters.maxDuration,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});

// Update available tabs from video fetch response if available
Expand All @@ -244,7 +266,12 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI
// Clear local ignore status overrides when videos are refetched (page change, tab change, etc)
useEffect(() => {
setLocalIgnoreStatus({});
}, [page, selectedTab, hideDownloaded, searchQuery, sortBy, sortOrder]);
}, [page, selectedTab, hideDownloaded, searchQuery, sortBy, sortOrder, filters]);

// Reset page to 1 when filters change
useEffect(() => {
setPage(1);
}, [filters.minDuration, filters.maxDuration, filters.dateFrom, filters.dateTo]);

const { config } = useConfig(token);
const hasChannelOverride = Boolean(channelVideoQuality);
Expand All @@ -255,10 +282,28 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI

const {
refreshVideos,
loading: fetchingAllVideos,
loading: localFetchingAllVideos,
error: fetchAllError,
clearError: clearFetchAllError,
} = useRefreshChannelVideos(channelId, page, pageSize, hideDownloaded, selectedTab, token);

// Poll for background fetch status (persists across navigation)
const {
isFetching: backgroundFetching,
onFetchComplete,
startPolling,
} = useChannelFetchStatus(channelId, selectedTab, token);

// Combine local and background fetch states
const fetchingAllVideos = localFetchingAllVideos || backgroundFetching;

// When a background fetch completes, refetch the videos
useEffect(() => {
onFetchComplete(() => {
refetchVideos();
});
}, [onFetchComplete, refetchVideos]);

const navigate = useNavigate();

// Apply local ignore status overrides to videos (for optimistic updates)
Expand Down Expand Up @@ -337,6 +382,8 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI

const handleRefreshConfirm = async () => {
setRefreshConfirmOpen(false);
// Start polling for fetch status since we're initiating a fetch
startPolling();
await refreshVideos();
// The hook handles loading and error states
// After refresh completes, refetch the videos to update the list
Expand Down Expand Up @@ -504,6 +551,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI
setPage(1); // Reset to first page when changing tabs
setCheckedBoxes([]); // Clear selections when changing tabs
setSelectedForDeletion([]); // Clear deletion selections when changing tabs
clearAllFilters(); // Clear filters when changing tabs
};

const handleAutoDownloadChange = async (enabled: boolean) => {
Expand Down Expand Up @@ -760,6 +808,26 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI
onDeleteClick={handleDeleteClick}
onBulkIgnoreClick={handleBulkIgnore}
onInfoIconClick={(tooltip) => setMobileTooltip(tooltip)}
activeFilterCount={activeFilterCount}
filtersExpanded={filtersExpanded}
onFiltersExpandedChange={setFiltersExpanded}
/>

{/* Filters */}
<ChannelVideosFilters
isMobile={isMobile}
filters={filters}
inputMinDuration={inputMinDuration}
inputMaxDuration={inputMaxDuration}
onMinDurationChange={setMinDuration}
onMaxDurationChange={setMaxDuration}
onDateFromChange={setDateFrom}
onDateToChange={setDateTo}
onClearAll={clearAllFilters}
hasActiveFilters={hasActiveFilters}
activeFilterCount={activeFilterCount}
hideDateFilter={selectedTab === 'shorts'}
filtersExpanded={filtersExpanded}
/>

{/* Tabs */}
Expand Down Expand Up @@ -795,7 +863,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI

{/* Content area */}
<Box sx={{ p: 2 }} {...(isMobile ? handlers : {})}>
{videoFailed && videos.length === 0 ? (
{videoFailed && videos.length === 0 && !hasActiveFilters && !searchQuery ? (
<Alert severity="error">
Failed to fetch channel videos. Please try again later.
</Alert>
Expand All @@ -817,7 +885,9 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI
) : videos.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1" color="text.secondary">
No videos found
{hasActiveFilters || searchQuery
? 'No videos found matching your search and filter criteria'
: 'No videos found'}
</Typography>
</Box>
) : (
Expand Down
6 changes: 3 additions & 3 deletions client/src/components/ChannelPage/ChannelVideosDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,19 @@ function ChannelVideosDialogs({
token={token}
/>

{/* Refresh Confirmation Dialog */}
{/* Load More Confirmation Dialog */}
<Dialog
open={refreshConfirmOpen}
onClose={onRefreshCancel}
aria-labelledby="refresh-dialog-title"
aria-describedby="refresh-dialog-description"
>
<DialogTitle id="refresh-dialog-title">
Refresh All {tabLabel} Videos
Load More {tabLabel}
</DialogTitle>
<DialogContent>
<DialogContentText id="refresh-dialog-description">
This will refresh all &apos;{tabLabel}&apos; videos for this Channel. This may take some time to complete.
This will load up to 5000 additional videos from this channel&apos;s &apos;{tabLabel}&apos; tab on YouTube. <i>This can take quite some time to complete, depending on the size of the channel and your internet connection!</i>
</DialogContentText>
</DialogContent>
<DialogActions>
Expand Down
27 changes: 25 additions & 2 deletions client/src/components/ChannelPage/ChannelVideosHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Chip,
LinearProgress,
IconButton,
Badge,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
Expand All @@ -23,6 +24,7 @@ import RefreshIcon from '@mui/icons-material/Refresh';
import DeleteIcon from '@mui/icons-material/Delete';
import BlockIcon from '@mui/icons-material/Block';
import InfoIcon from '@mui/icons-material/Info';
import FilterListIcon from '@mui/icons-material/FilterList';
import { getVideoStatus } from '../../utils/videoStatus';
import { ChannelVideo } from '../../types/ChannelVideo';

Expand Down Expand Up @@ -53,6 +55,10 @@ interface ChannelVideosHeaderProps {
onDeleteClick: () => void;
onBulkIgnoreClick: () => void;
onInfoIconClick: (tooltip: string) => void;
// Filter-related props (desktop only)
activeFilterCount?: number;
filtersExpanded?: boolean;
onFiltersExpandedChange?: (expanded: boolean) => void;
}

function ChannelVideosHeader({
Expand Down Expand Up @@ -80,6 +86,9 @@ function ChannelVideosHeader({
onDeleteClick,
onBulkIgnoreClick,
onInfoIconClick,
activeFilterCount = 0,
filtersExpanded = false,
onFiltersExpandedChange,
}: ChannelVideosHeaderProps) {
const renderInfoIcon = (message: string) => {
const handleClick = (e: React.MouseEvent) => {
Expand Down Expand Up @@ -150,7 +159,7 @@ function ChannelVideosHeader({
disabled={fetchingAllVideos}
startIcon={<RefreshIcon />}
>
{fetchingAllVideos ? 'Refreshing...' : 'Refresh All'}
{fetchingAllVideos ? 'Loading...' : 'Load More'}
</Button>
</Box>

Expand Down Expand Up @@ -236,7 +245,21 @@ function ChannelVideosHeader({

{/* Action buttons for desktop */}
{!isMobile && (
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<Box sx={{ display: 'flex', gap: 1, mt: 2, flexWrap: 'wrap' }}>
{onFiltersExpandedChange && (
<Button
variant={filtersExpanded ? 'contained' : 'outlined'}
size="small"
startIcon={
<Badge badgeContent={activeFilterCount} color="primary" invisible={activeFilterCount === 0}>
<FilterListIcon />
</Badge>
}
onClick={() => onFiltersExpandedChange(!filtersExpanded)}
>
Filters
</Button>
)}
<Button
variant="contained"
size="small"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,10 @@ describe('ChannelVideos Component', () => {
sortOrder: 'desc',
tabType: null,
token: mockToken,
minDuration: null,
maxDuration: null,
dateFrom: null,
dateTo: null,
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('ChannelVideosDialogs Component', () => {
expect(screen.getByTestId('delete-videos-dialog')).toBeInTheDocument();

// Should render refresh confirmation dialog (even if closed)
expect(screen.queryByText('Refresh All Videos Videos')).not.toBeInTheDocument();
expect(screen.queryByText('Load More Videos')).not.toBeInTheDocument();
});
});

Expand Down Expand Up @@ -163,7 +163,7 @@ describe('ChannelVideosDialogs Component', () => {
test('does not show refresh dialog when closed', () => {
renderWithProviders(<ChannelVideosDialogs {...defaultProps} />);

expect(screen.queryByText(/Refresh All/)).not.toBeInTheDocument();
expect(screen.queryByText(/Load More/)).not.toBeInTheDocument();
});

test('shows refresh dialog when open', () => {
Expand All @@ -174,7 +174,7 @@ describe('ChannelVideosDialogs Component', () => {
/>
);

expect(screen.getByText('Refresh All Videos Videos')).toBeInTheDocument();
expect(screen.getByText('Load More Videos')).toBeInTheDocument();
});

test('displays correct tab label in refresh dialog', () => {
Expand All @@ -186,8 +186,8 @@ describe('ChannelVideosDialogs Component', () => {
/>
);

expect(screen.getByText('Refresh All Shorts Videos')).toBeInTheDocument();
expect(screen.getByText(/This will refresh all 'Shorts' videos for this Channel/)).toBeInTheDocument();
expect(screen.getByText('Load More Shorts')).toBeInTheDocument();
expect(screen.getByText(/This will load up to 5000 additional videos from this channel's 'Shorts' tab on YouTube/)).toBeInTheDocument();
});

test('calls onRefreshCancel when Cancel is clicked', async () => {
Expand Down Expand Up @@ -481,7 +481,7 @@ describe('ChannelVideosDialogs Component', () => {
/>
);

expect(screen.getByText('Refresh All Videos Videos')).toBeInTheDocument();
expect(screen.getByText('Load More Videos')).toBeInTheDocument();
expect(screen.getByText('Operation completed')).toBeInTheDocument();
});
});
Expand Down Expand Up @@ -558,7 +558,7 @@ describe('ChannelVideosDialogs Component', () => {
/>
);

expect(screen.getByText('Refresh All Live & Upcoming Videos')).toBeInTheDocument();
expect(screen.getByText('Load More Live & Upcoming')).toBeInTheDocument();
});

test('handles zero video count in download dialog', () => {
Expand Down Expand Up @@ -608,7 +608,7 @@ describe('ChannelVideosDialogs Component', () => {
// All components should render
expect(screen.getByTestId('download-settings-dialog')).toBeInTheDocument();
expect(screen.getByTestId('delete-videos-dialog')).toBeInTheDocument();
expect(screen.getByText('Refresh All Videos Videos')).toBeInTheDocument();
expect(screen.getByText('Load More Videos')).toBeInTheDocument();

// Multiple snackbars should show
expect(screen.getByText('Error 1')).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,47 +126,47 @@ describe('ChannelVideosHeader Component', () => {
});
});

describe('Refresh All Button', () => {
test('renders refresh all button', () => {
describe('Load More Button', () => {
test('renders load more button', () => {
renderWithProviders(<ChannelVideosHeader {...defaultProps} />);
expect(screen.getByRole('button', { name: /Refresh All/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Load More/i })).toBeInTheDocument();
});

test('calls onRefreshClick when refresh all button is clicked', async () => {
test('calls onRefreshClick when load more button is clicked', async () => {
const user = userEvent.setup();
const onRefreshClick = jest.fn();

renderWithProviders(
<ChannelVideosHeader {...defaultProps} onRefreshClick={onRefreshClick} />
);

await user.click(screen.getByRole('button', { name: /Refresh All/i }));
await user.click(screen.getByRole('button', { name: /Load More/i }));
expect(onRefreshClick).toHaveBeenCalledTimes(1);
});

test('disables refresh all button when fetching', () => {
test('disables load more button when fetching', () => {
renderWithProviders(
<ChannelVideosHeader {...defaultProps} fetchingAllVideos={true} />
);

const button = screen.getByRole('button', { name: /Refreshing.../i });
const button = screen.getByRole('button', { name: /Loading.../i });
expect(button).toBeDisabled();
});

test('shows "Refreshing..." text when fetching videos', () => {
test('shows "Loading..." text when fetching videos', () => {
renderWithProviders(
<ChannelVideosHeader {...defaultProps} fetchingAllVideos={true} />
);

expect(screen.getByText('Refreshing...')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});

test('shows "Refresh All" text when not fetching', () => {
test('shows "Load More" text when not fetching', () => {
renderWithProviders(
<ChannelVideosHeader {...defaultProps} fetchingAllVideos={false} />
);

expect(screen.getByText('Refresh All')).toBeInTheDocument();
expect(screen.getByText('Load More')).toBeInTheDocument();
});
});

Expand Down
Loading