diff --git a/client/src/components/ChannelPage/ChannelVideos.tsx b/client/src/components/ChannelPage/ChannelVideos.tsx index a11c1f76..f776ac2a 100644 --- a/client/src/components/ChannelPage/ChannelVideos.tsx +++ b/client/src/components/ChannelPage/ChannelVideos.tsx @@ -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'; @@ -67,6 +70,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI const [sortBy, setSortBy] = useState('date'); const [sortOrder, setSortOrder] = useState('desc'); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); + const [filtersExpanded, setFiltersExpanded] = useState(false); // Tab states const [selectedTab, setSelectedTab] = useState(null); @@ -91,6 +95,20 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI // Local state to track ignore status changes without refetching const [localIgnoreStatus, setLocalIgnoreStatus] = useState>({}); + // Filter state + const { + filters, + inputMinDuration, + inputMaxDuration, + setMinDuration, + setMaxDuration, + setDateFrom, + setDateTo, + clearAllFilters, + hasActiveFilters, + activeFilterCount, + } = useChannelVideoFilters(); + const { deleteVideosByYoutubeIds, loading: deleteLoading } = useVideoDeletion(); const { channel_id: routeChannelId } = useParams(); @@ -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 @@ -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); @@ -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) @@ -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 @@ -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) => { @@ -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 */} + {/* Tabs */} @@ -795,7 +863,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI {/* Content area */} - {videoFailed && videos.length === 0 ? ( + {videoFailed && videos.length === 0 && !hasActiveFilters && !searchQuery ? ( Failed to fetch channel videos. Please try again later. @@ -817,7 +885,9 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI ) : videos.length === 0 ? ( - No videos found + {hasActiveFilters || searchQuery + ? 'No videos found matching your search and filter criteria' + : 'No videos found'} ) : ( diff --git a/client/src/components/ChannelPage/ChannelVideosDialogs.tsx b/client/src/components/ChannelPage/ChannelVideosDialogs.tsx index 06d23d37..367b605d 100644 --- a/client/src/components/ChannelPage/ChannelVideosDialogs.tsx +++ b/client/src/components/ChannelPage/ChannelVideosDialogs.tsx @@ -83,7 +83,7 @@ function ChannelVideosDialogs({ token={token} /> - {/* Refresh Confirmation Dialog */} + {/* Load More Confirmation Dialog */} - Refresh All {tabLabel} Videos + Load More {tabLabel} - This will refresh all '{tabLabel}' videos for this Channel. This may take some time to complete. + This will load up to 5000 additional videos from this channel's '{tabLabel}' tab on YouTube. This can take quite some time to complete, depending on the size of the channel and your internet connection! diff --git a/client/src/components/ChannelPage/ChannelVideosHeader.tsx b/client/src/components/ChannelPage/ChannelVideosHeader.tsx index 65d820b3..bef279dc 100644 --- a/client/src/components/ChannelPage/ChannelVideosHeader.tsx +++ b/client/src/components/ChannelPage/ChannelVideosHeader.tsx @@ -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'; @@ -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'; @@ -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({ @@ -80,6 +86,9 @@ function ChannelVideosHeader({ onDeleteClick, onBulkIgnoreClick, onInfoIconClick, + activeFilterCount = 0, + filtersExpanded = false, + onFiltersExpandedChange, }: ChannelVideosHeaderProps) { const renderInfoIcon = (message: string) => { const handleClick = (e: React.MouseEvent) => { @@ -150,7 +159,7 @@ function ChannelVideosHeader({ disabled={fetchingAllVideos} startIcon={} > - {fetchingAllVideos ? 'Refreshing...' : 'Refresh All'} + {fetchingAllVideos ? 'Loading...' : 'Load More'} @@ -236,7 +245,21 @@ function ChannelVideosHeader({ {/* Action buttons for desktop */} {!isMobile && ( - + + {onFiltersExpandedChange && ( + + )} + + {/* Show active filter chips on mobile too */} + {hasActiveFilters && ( + + )} + + + setDrawerOpen(false)} + filters={filters} + inputMinDuration={inputMinDuration} + inputMaxDuration={inputMaxDuration} + onMinDurationChange={onMinDurationChange} + onMaxDurationChange={onMaxDurationChange} + onDateFromChange={onDateFromChange} + onDateToChange={onDateToChange} + onClearAll={onClearAll} + hasActiveFilters={hasActiveFilters} + hideDateFilter={hideDateFilter} + /> + + ); + } + + // Desktop: Collapsible filter panel (button is in header, controlled by parent) + return ( + + + + + {!hideDateFilter ? ( + + ) : ( + + Shorts do not have date information + + )} + {hasActiveFilters && ( + + )} + + + + ); +} + +export default ChannelVideosFilters; diff --git a/client/src/components/ChannelPage/components/DateRangeFilterInput.tsx b/client/src/components/ChannelPage/components/DateRangeFilterInput.tsx new file mode 100644 index 00000000..2aaa8ac0 --- /dev/null +++ b/client/src/components/ChannelPage/components/DateRangeFilterInput.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Box, TextField, Typography } from '@mui/material'; +import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; + +interface DateRangeFilterInputProps { + dateFrom: Date | null; + dateTo: Date | null; + onFromChange: (value: Date | null) => void; + onToChange: (value: Date | null) => void; + compact?: boolean; +} + +function DateRangeFilterInput({ + dateFrom, + dateTo, + onFromChange, + onToChange, + compact = false, +}: DateRangeFilterInputProps) { + return ( + + + {!compact && ( + + Date: + + )} + ( + + )} + /> + + to + + ( + + )} + /> + + + ); +} + +export default DateRangeFilterInput; diff --git a/client/src/components/ChannelPage/components/DurationFilterInput.tsx b/client/src/components/ChannelPage/components/DurationFilterInput.tsx new file mode 100644 index 00000000..c54109ed --- /dev/null +++ b/client/src/components/ChannelPage/components/DurationFilterInput.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Box, TextField, Typography } from '@mui/material'; + +interface DurationFilterInputProps { + minDuration: number | null; + maxDuration: number | null; + onMinChange: (value: number | null) => void; + onMaxChange: (value: number | null) => void; + compact?: boolean; +} + +function DurationFilterInput({ + minDuration, + maxDuration, + onMinChange, + onMaxChange, + compact = false, +}: DurationFilterInputProps) { + const handleMinChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '') { + onMinChange(null); + } else { + const numValue = parseInt(value, 10); + if (!isNaN(numValue) && numValue >= 0) { + onMinChange(numValue); + } + } + }; + + const handleMaxChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '') { + onMaxChange(null); + } else { + const numValue = parseInt(value, 10); + if (!isNaN(numValue) && numValue >= 0) { + onMaxChange(numValue); + } + } + }; + + return ( + + {!compact && ( + + Duration: + + )} + + + to + + + + min + + + ); +} + +export default DurationFilterInput; diff --git a/client/src/components/ChannelPage/components/FilterChips.tsx b/client/src/components/ChannelPage/components/FilterChips.tsx new file mode 100644 index 00000000..97dbf46b --- /dev/null +++ b/client/src/components/ChannelPage/components/FilterChips.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Box, Chip } from '@mui/material'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; +import { VideoFilters } from '../hooks/useChannelVideoFilters'; + +interface FilterChipsProps { + filters: VideoFilters; + onClearDuration: () => void; + onClearDateRange: () => void; +} + +function formatDurationLabel(minDuration: number | null, maxDuration: number | null): string { + if (minDuration !== null && maxDuration !== null) { + return `${minDuration}-${maxDuration} min`; + } else if (minDuration !== null) { + return `${minDuration}+ min`; + } else if (maxDuration !== null) { + return `0-${maxDuration} min`; + } + return ''; +} + +function formatDateLabel(dateFrom: Date | null, dateTo: Date | null): string { + const formatDate = (date: Date): string => { + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + }; + + if (dateFrom && dateTo) { + return `${formatDate(dateFrom)} - ${formatDate(dateTo)}`; + } else if (dateFrom) { + return `From ${formatDate(dateFrom)}`; + } else if (dateTo) { + return `Until ${formatDate(dateTo)}`; + } + return ''; +} + +function FilterChips({ + filters, + onClearDuration, + onClearDateRange, +}: FilterChipsProps) { + const hasDurationFilter = filters.minDuration !== null || filters.maxDuration !== null; + const hasDateFilter = filters.dateFrom !== null || filters.dateTo !== null; + + if (!hasDurationFilter && !hasDateFilter) { + return null; + } + + return ( + + {hasDurationFilter && ( + } + label={formatDurationLabel(filters.minDuration, filters.maxDuration)} + size="small" + onDelete={onClearDuration} + color="primary" + variant="outlined" + /> + )} + {hasDateFilter && ( + } + label={formatDateLabel(filters.dateFrom, filters.dateTo)} + size="small" + onDelete={onClearDateRange} + color="primary" + variant="outlined" + /> + )} + + ); +} + +export default FilterChips; diff --git a/client/src/components/ChannelPage/components/MobileFilterDrawer.tsx b/client/src/components/ChannelPage/components/MobileFilterDrawer.tsx new file mode 100644 index 00000000..5ed097bc --- /dev/null +++ b/client/src/components/ChannelPage/components/MobileFilterDrawer.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { + Drawer, + Box, + Typography, + IconButton, + Button, + Divider, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import DurationFilterInput from './DurationFilterInput'; +import DateRangeFilterInput from './DateRangeFilterInput'; +import { VideoFilters } from '../hooks/useChannelVideoFilters'; + +interface MobileFilterDrawerProps { + open: boolean; + onClose: () => void; + filters: VideoFilters; + inputMinDuration: number | null; + inputMaxDuration: number | null; + onMinDurationChange: (value: number | null) => void; + onMaxDurationChange: (value: number | null) => void; + onDateFromChange: (value: Date | null) => void; + onDateToChange: (value: Date | null) => void; + onClearAll: () => void; + hasActiveFilters: boolean; + hideDateFilter?: boolean; +} + +function MobileFilterDrawer({ + open, + onClose, + filters, + inputMinDuration, + inputMaxDuration, + onMinDurationChange, + onMaxDurationChange, + onDateFromChange, + onDateToChange, + onClearAll, + hasActiveFilters, + hideDateFilter = false, +}: MobileFilterDrawerProps) { + return ( + + + {/* Header */} + + Filters + + + + + + + + {/* Duration Filter */} + + + Duration + + + + + {/* Date Range Filter */} + {!hideDateFilter ? ( + + + Date Range + + + + ) : ( + + + Shorts do not have date information + + + )} + + + + {/* Actions */} + + + + + + + ); +} + +export default MobileFilterDrawer; diff --git a/client/src/components/ChannelPage/components/__tests__/ChannelVideosFilters.test.tsx b/client/src/components/ChannelPage/components/__tests__/ChannelVideosFilters.test.tsx new file mode 100644 index 00000000..bd911ecc --- /dev/null +++ b/client/src/components/ChannelPage/components/__tests__/ChannelVideosFilters.test.tsx @@ -0,0 +1,246 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import ChannelVideosFilters from '../ChannelVideosFilters'; +import { VideoFilters } from '../../hooks/useChannelVideoFilters'; +import { renderWithProviders } from '../../../../test-utils'; + +// Mock child components to isolate ChannelVideosFilters behavior +jest.mock('../DurationFilterInput', () => ({ + __esModule: true, + default: function MockDurationFilterInput() { + const React = require('react'); + return React.createElement('div', { 'data-testid': 'duration-filter-input' }, 'DurationFilterInput'); + }, +})); + +jest.mock('../DateRangeFilterInput', () => ({ + __esModule: true, + default: function MockDateRangeFilterInput() { + const React = require('react'); + return React.createElement('div', { 'data-testid': 'date-range-filter-input' }, 'DateRangeFilterInput'); + }, +})); + +jest.mock('../FilterChips', () => ({ + __esModule: true, + default: function MockFilterChips(props: { onClearDuration: () => void; onClearDateRange: () => void }) { + const React = require('react'); + return React.createElement('div', { 'data-testid': 'filter-chips' }, + React.createElement('button', { 'data-testid': 'clear-duration-chip', onClick: props.onClearDuration }, 'Clear Duration'), + React.createElement('button', { 'data-testid': 'clear-date-chip', onClick: props.onClearDateRange }, 'Clear Date') + ); + }, +})); + +jest.mock('../MobileFilterDrawer', () => ({ + __esModule: true, + default: function MockMobileFilterDrawer(props: { open: boolean; onClose: () => void; onClearAll: () => void; hasActiveFilters: boolean }) { + const React = require('react'); + if (!props.open) return null; + return React.createElement('div', { 'data-testid': 'mobile-filter-drawer' }, + React.createElement('button', { 'data-testid': 'drawer-close', onClick: props.onClose }, 'Close'), + React.createElement('button', { + 'data-testid': 'drawer-clear-all', + onClick: props.onClearAll, + disabled: !props.hasActiveFilters + }, 'Clear All') + ); + }, +})); + +const defaultFilters: VideoFilters = { + minDuration: null, + maxDuration: null, + dateFrom: null, + dateTo: null, +}; + +describe('ChannelVideosFilters Component', () => { + const defaultProps = { + isMobile: false, + filters: defaultFilters, + inputMinDuration: null, + inputMaxDuration: null, + onMinDurationChange: jest.fn(), + onMaxDurationChange: jest.fn(), + onDateFromChange: jest.fn(), + onDateToChange: jest.fn(), + onClearAll: jest.fn(), + hasActiveFilters: false, + activeFilterCount: 0, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Desktop Mode', () => { + test('renders collapsed by default', () => { + renderWithProviders(); + + // Component renders but filter inputs should not be visible due to collapsed state + expect(screen.queryByTestId('duration-filter-input')).not.toBeVisible(); + }); + + test('renders filter inputs when expanded', () => { + renderWithProviders(); + + expect(screen.getByTestId('duration-filter-input')).toBeInTheDocument(); + expect(screen.getByTestId('date-range-filter-input')).toBeInTheDocument(); + }); + + test('hides date filter when hideDateFilter is true', () => { + renderWithProviders( + + ); + + expect(screen.getByTestId('duration-filter-input')).toBeInTheDocument(); + expect(screen.queryByTestId('date-range-filter-input')).not.toBeInTheDocument(); + expect(screen.getByText('Shorts do not have date information')).toBeInTheDocument(); + }); + + test('shows Clear All button when filters are active', () => { + renderWithProviders( + + ); + + expect(screen.getByRole('button', { name: /Clear All/i })).toBeInTheDocument(); + }); + + test('hides Clear All button when no filters active', () => { + renderWithProviders( + + ); + + expect(screen.queryByRole('button', { name: /Clear All/i })).not.toBeInTheDocument(); + }); + + test('calls onClearAll when Clear All button is clicked', async () => { + const user = userEvent.setup(); + const onClearAll = jest.fn(); + + renderWithProviders( + + ); + + await user.click(screen.getByRole('button', { name: /Clear All/i })); + expect(onClearAll).toHaveBeenCalledTimes(1); + }); + }); + + describe('Mobile Mode', () => { + test('renders filter button on mobile', () => { + renderWithProviders(); + + expect(screen.getByRole('button', { name: /Filters/i })).toBeInTheDocument(); + }); + + test('opens drawer when filter button is clicked', async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + expect(screen.queryByTestId('mobile-filter-drawer')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Filters/i })); + + expect(screen.getByTestId('mobile-filter-drawer')).toBeInTheDocument(); + }); + + test('closes drawer when close button is clicked', async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + await user.click(screen.getByRole('button', { name: /Filters/i })); + expect(screen.getByTestId('mobile-filter-drawer')).toBeInTheDocument(); + + await user.click(screen.getByTestId('drawer-close')); + expect(screen.queryByTestId('mobile-filter-drawer')).not.toBeInTheDocument(); + }); + + test('shows filter chips on mobile when filters are active', () => { + renderWithProviders( + + ); + + expect(screen.getByTestId('filter-chips')).toBeInTheDocument(); + }); + + test('hides filter chips on mobile when no filters active', () => { + renderWithProviders( + + ); + + expect(screen.queryByTestId('filter-chips')).not.toBeInTheDocument(); + }); + + test('displays badge with active filter count', () => { + renderWithProviders( + + ); + + // The badge should show the count + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + test('hides badge when no active filters', () => { + renderWithProviders( + + ); + + // Badge should be invisible (not rendered in the DOM as visible text) + expect(screen.queryByText('0')).not.toBeInTheDocument(); + }); + }); + + describe('Filter Clearing via Chips', () => { + test('clears duration filter when chip is deleted', async () => { + const user = userEvent.setup(); + const onMinDurationChange = jest.fn(); + const onMaxDurationChange = jest.fn(); + + renderWithProviders( + + ); + + await user.click(screen.getByTestId('clear-duration-chip')); + + expect(onMinDurationChange).toHaveBeenCalledWith(null); + expect(onMaxDurationChange).toHaveBeenCalledWith(null); + }); + + test('clears date range filter when chip is deleted', async () => { + const user = userEvent.setup(); + const onDateFromChange = jest.fn(); + const onDateToChange = jest.fn(); + + renderWithProviders( + + ); + + await user.click(screen.getByTestId('clear-date-chip')); + + expect(onDateFromChange).toHaveBeenCalledWith(null); + expect(onDateToChange).toHaveBeenCalledWith(null); + }); + }); +}); diff --git a/client/src/components/ChannelPage/components/__tests__/DateRangeFilterInput.test.tsx b/client/src/components/ChannelPage/components/__tests__/DateRangeFilterInput.test.tsx new file mode 100644 index 00000000..59839740 --- /dev/null +++ b/client/src/components/ChannelPage/components/__tests__/DateRangeFilterInput.test.tsx @@ -0,0 +1,116 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import DateRangeFilterInput from '../DateRangeFilterInput'; +import { renderWithProviders } from '../../../../test-utils'; + +describe('DateRangeFilterInput Component', () => { + const defaultProps = { + dateFrom: null, + dateTo: null, + onFromChange: jest.fn(), + onToChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Non-Compact Mode (default)', () => { + test('renders Date label and both date inputs', () => { + renderWithProviders(); + + expect(screen.getByText('Date:')).toBeInTheDocument(); + expect(screen.getByText('to')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /filter from date/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /filter to date/i })).toBeInTheDocument(); + }); + + test('does not show From/To labels on date pickers', () => { + renderWithProviders(); + + // In non-compact mode, the DatePicker labels should not be present + expect(screen.queryByLabelText('From')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('To')).not.toBeInTheDocument(); + }); + }); + + describe('Compact Mode', () => { + test('hides Date label in compact mode', () => { + renderWithProviders(); + + expect(screen.queryByText('Date:')).not.toBeInTheDocument(); + expect(screen.getByText('to')).toBeInTheDocument(); + }); + + test('shows From/To labels on date pickers in compact mode', () => { + renderWithProviders(); + + expect(screen.getByLabelText('From')).toBeInTheDocument(); + expect(screen.getByLabelText('To')).toBeInTheDocument(); + }); + }); + + describe('Value Display', () => { + test('displays provided date values', () => { + const fromDate = new Date(2024, 0, 15); // Jan 15, 2024 + const toDate = new Date(2024, 5, 20); // Jun 20, 2024 + + renderWithProviders( + + ); + + const fromInput = screen.getByRole('textbox', { name: /filter from date/i }); + const toInput = screen.getByRole('textbox', { name: /filter to date/i }); + + expect(fromInput).toHaveValue('01/15/2024'); + expect(toInput).toHaveValue('06/20/2024'); + }); + + test('displays empty inputs when dates are null', () => { + renderWithProviders(); + + const fromInput = screen.getByRole('textbox', { name: /filter from date/i }); + const toInput = screen.getByRole('textbox', { name: /filter to date/i }); + + expect(fromInput).toHaveValue(''); + expect(toInput).toHaveValue(''); + }); + }); + + describe('User Interactions', () => { + test('calls onFromChange when from date is typed', async () => { + const user = userEvent.setup(); + const onFromChange = jest.fn(); + + renderWithProviders( + + ); + + const fromInput = screen.getByRole('textbox', { name: /filter from date/i }); + await user.clear(fromInput); + await user.type(fromInput, '01/15/2024'); + + expect(onFromChange).toHaveBeenCalled(); + }); + + test('calls onToChange when to date is typed', async () => { + const user = userEvent.setup(); + const onToChange = jest.fn(); + + renderWithProviders( + + ); + + const toInput = screen.getByRole('textbox', { name: /filter to date/i }); + await user.clear(toInput); + await user.type(toInput, '06/20/2024'); + + expect(onToChange).toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/components/ChannelPage/components/__tests__/DurationFilterInput.test.tsx b/client/src/components/ChannelPage/components/__tests__/DurationFilterInput.test.tsx new file mode 100644 index 00000000..b0eae267 --- /dev/null +++ b/client/src/components/ChannelPage/components/__tests__/DurationFilterInput.test.tsx @@ -0,0 +1,136 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import DurationFilterInput from '../DurationFilterInput'; +import { renderWithProviders } from '../../../../test-utils'; + +describe('DurationFilterInput Component', () => { + const defaultProps = { + minDuration: null, + maxDuration: null, + onMinChange: jest.fn(), + onMaxChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Non-Compact Mode (default)', () => { + test('renders Duration label and both inputs', () => { + renderWithProviders(); + + expect(screen.getByText('Duration:')).toBeInTheDocument(); + expect(screen.getByText('to')).toBeInTheDocument(); + expect(screen.getByText('min')).toBeInTheDocument(); + expect(screen.getByRole('spinbutton', { name: /minimum duration/i })).toBeInTheDocument(); + expect(screen.getByRole('spinbutton', { name: /maximum duration/i })).toBeInTheDocument(); + }); + }); + + describe('Compact Mode', () => { + test('hides Duration label in compact mode', () => { + renderWithProviders(); + + expect(screen.queryByText('Duration:')).not.toBeInTheDocument(); + expect(screen.getByText('to')).toBeInTheDocument(); + expect(screen.getByText('min')).toBeInTheDocument(); + }); + }); + + describe('Value Display', () => { + test('displays provided duration values', () => { + renderWithProviders( + + ); + + const minInput = screen.getByRole('spinbutton', { name: /minimum duration/i }); + const maxInput = screen.getByRole('spinbutton', { name: /maximum duration/i }); + + expect(minInput).toHaveValue(5); + expect(maxInput).toHaveValue(30); + }); + + test('displays empty inputs when values are null', () => { + renderWithProviders(); + + const minInput = screen.getByRole('spinbutton', { name: /minimum duration/i }); + const maxInput = screen.getByRole('spinbutton', { name: /maximum duration/i }); + + expect(minInput).toHaveValue(null); + expect(maxInput).toHaveValue(null); + }); + }); + + describe('User Interactions', () => { + test('calls onMinChange with number when valid value is entered', async () => { + const user = userEvent.setup(); + const onMinChange = jest.fn(); + + renderWithProviders( + + ); + + const minInput = screen.getByRole('spinbutton', { name: /minimum duration/i }); + await user.type(minInput, '5'); + + expect(onMinChange).toHaveBeenCalledWith(5); + }); + + test('calls onMaxChange with number when valid value is entered', async () => { + const user = userEvent.setup(); + const onMaxChange = jest.fn(); + + renderWithProviders( + + ); + + const maxInput = screen.getByRole('spinbutton', { name: /maximum duration/i }); + await user.type(maxInput, '9'); + + expect(onMaxChange).toHaveBeenCalledWith(9); + }); + + test('calls onMinChange with null when input is cleared', async () => { + const user = userEvent.setup(); + const onMinChange = jest.fn(); + + renderWithProviders( + + ); + + const minInput = screen.getByRole('spinbutton', { name: /minimum duration/i }); + await user.clear(minInput); + + expect(onMinChange).toHaveBeenCalledWith(null); + }); + + test('calls onMaxChange with null when input is cleared', async () => { + const user = userEvent.setup(); + const onMaxChange = jest.fn(); + + renderWithProviders( + + ); + + const maxInput = screen.getByRole('spinbutton', { name: /maximum duration/i }); + await user.clear(maxInput); + + expect(onMaxChange).toHaveBeenCalledWith(null); + }); + + test('accepts zero as a valid value', async () => { + const user = userEvent.setup(); + const onMinChange = jest.fn(); + + renderWithProviders( + + ); + + const minInput = screen.getByRole('spinbutton', { name: /minimum duration/i }); + await user.type(minInput, '0'); + + expect(onMinChange).toHaveBeenCalledWith(0); + }); + }); +}); diff --git a/client/src/components/ChannelPage/components/__tests__/FilterChips.test.tsx b/client/src/components/ChannelPage/components/__tests__/FilterChips.test.tsx new file mode 100644 index 00000000..f1b71ead --- /dev/null +++ b/client/src/components/ChannelPage/components/__tests__/FilterChips.test.tsx @@ -0,0 +1,164 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import FilterChips from '../FilterChips'; +import { VideoFilters } from '../../hooks/useChannelVideoFilters'; +import { renderWithProviders } from '../../../../test-utils'; + +const defaultFilters: VideoFilters = { + minDuration: null, + maxDuration: null, + dateFrom: null, + dateTo: null, +}; + +// Create dates using explicit year/month/day to avoid timezone issues +function createDate(year: number, month: number, day: number): Date { + return new Date(year, month - 1, day); +} + +describe('FilterChips Component', () => { + const defaultProps = { + filters: defaultFilters, + onClearDuration: jest.fn(), + onClearDateRange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + test('returns null when no filters are active', () => { + renderWithProviders(); + + // With no active filters, the component should not render any chips + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('renders duration chip when minDuration is set', () => { + renderWithProviders( + + ); + + expect(screen.getByText('5+ min')).toBeInTheDocument(); + }); + + test('renders duration chip when maxDuration is set', () => { + renderWithProviders( + + ); + + expect(screen.getByText('0-10 min')).toBeInTheDocument(); + }); + + test('renders duration chip when both min and max duration are set', () => { + renderWithProviders( + + ); + + expect(screen.getByText('5-20 min')).toBeInTheDocument(); + }); + + test('renders date chip when dateFrom is set', () => { + const dateFrom = createDate(2024, 6, 15); + renderWithProviders( + + ); + + expect(screen.getByText(/From Jun 15/)).toBeInTheDocument(); + }); + + test('renders date chip when dateTo is set', () => { + const dateTo = createDate(2024, 6, 20); + renderWithProviders( + + ); + + expect(screen.getByText(/Until Jun 20/)).toBeInTheDocument(); + }); + + test('renders date chip with range when both dates are set', () => { + const dateFrom = createDate(2024, 6, 15); + const dateTo = createDate(2024, 6, 20); + renderWithProviders( + + ); + + expect(screen.getByText(/Jun 15 - Jun 20/)).toBeInTheDocument(); + }); + + test('renders both chips when duration and date filters are active', () => { + const dateFrom = createDate(2024, 6, 15); + renderWithProviders( + + ); + + expect(screen.getByText('5+ min')).toBeInTheDocument(); + expect(screen.getByText(/From Jun 15/)).toBeInTheDocument(); + }); + }); + + describe('Chip Deletion', () => { + test('calls onClearDuration when duration chip delete icon is clicked', async () => { + const user = userEvent.setup(); + const onClearDuration = jest.fn(); + + renderWithProviders( + + ); + + // Find the chip by its text, then find the delete icon within it + const chip = screen.getByRole('button', { name: /5\+ min/i }); + const deleteIcon = within(chip).getByTestId('CancelIcon'); + await user.click(deleteIcon); + + expect(onClearDuration).toHaveBeenCalledTimes(1); + }); + + test('calls onClearDateRange when date chip delete icon is clicked', async () => { + const user = userEvent.setup(); + const onClearDateRange = jest.fn(); + const dateFrom = createDate(2024, 6, 15); + + renderWithProviders( + + ); + + const chip = screen.getByRole('button', { name: /From Jun 15/i }); + const deleteIcon = within(chip).getByTestId('CancelIcon'); + await user.click(deleteIcon); + + expect(onClearDateRange).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/client/src/components/ChannelPage/components/__tests__/MobileFilterDrawer.test.tsx b/client/src/components/ChannelPage/components/__tests__/MobileFilterDrawer.test.tsx new file mode 100644 index 00000000..607d79e3 --- /dev/null +++ b/client/src/components/ChannelPage/components/__tests__/MobileFilterDrawer.test.tsx @@ -0,0 +1,186 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import MobileFilterDrawer from '../MobileFilterDrawer'; +import { renderWithProviders } from '../../../../test-utils'; +import { VideoFilters } from '../../hooks/useChannelVideoFilters'; + +// Mock the child filter components +jest.mock('../DurationFilterInput', () => ({ + __esModule: true, + default: function MockDurationFilterInput(props: { + minDuration: number | null; + maxDuration: number | null; + onMinChange: (v: number | null) => void; + onMaxChange: (v: number | null) => void; + }) { + const React = require('react'); + return React.createElement('div', { 'data-testid': 'duration-filter' }, [ + React.createElement('span', { key: 'min' }, `min:${props.minDuration}`), + React.createElement('span', { key: 'max' }, `max:${props.maxDuration}`), + ]); + }, +})); + +jest.mock('../DateRangeFilterInput', () => ({ + __esModule: true, + default: function MockDateRangeFilterInput(props: { + dateFrom: Date | null; + dateTo: Date | null; + }) { + const React = require('react'); + return React.createElement('div', { 'data-testid': 'date-filter' }, [ + React.createElement('span', { key: 'from' }, `from:${props.dateFrom?.toISOString() ?? 'null'}`), + React.createElement('span', { key: 'to' }, `to:${props.dateTo?.toISOString() ?? 'null'}`), + ]); + }, +})); + +describe('MobileFilterDrawer Component', () => { + const defaultFilters: VideoFilters = { + minDuration: null, + maxDuration: null, + dateFrom: null, + dateTo: null, + }; + + const defaultProps = { + open: true, + onClose: jest.fn(), + filters: defaultFilters, + inputMinDuration: null, + inputMaxDuration: null, + onMinDurationChange: jest.fn(), + onMaxDurationChange: jest.fn(), + onDateFromChange: jest.fn(), + onDateToChange: jest.fn(), + onClearAll: jest.fn(), + hasActiveFilters: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + test('renders header with Filters title when open', () => { + renderWithProviders(); + + expect(screen.getByText('Filters')).toBeInTheDocument(); + }); + + test('renders Duration section with filter input', () => { + renderWithProviders(); + + expect(screen.getByText('Duration')).toBeInTheDocument(); + expect(screen.getByTestId('duration-filter')).toBeInTheDocument(); + }); + + test('renders Date Range section with filter input when hideDateFilter is false', () => { + renderWithProviders(); + + expect(screen.getByText('Date Range')).toBeInTheDocument(); + expect(screen.getByTestId('date-filter')).toBeInTheDocument(); + }); + + test('renders Clear All and Close action buttons', () => { + renderWithProviders(); + + expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^close$/i })).toBeInTheDocument(); + }); + }); + + describe('hideDateFilter prop', () => { + test('shows shorts message instead of date filter when hideDateFilter is true', () => { + renderWithProviders(); + + expect(screen.queryByText('Date Range')).not.toBeInTheDocument(); + expect(screen.queryByTestId('date-filter')).not.toBeInTheDocument(); + expect(screen.getByText('Shorts do not have date information')).toBeInTheDocument(); + }); + }); + + describe('Clear All Button', () => { + test('Clear All button is disabled when hasActiveFilters is false', () => { + renderWithProviders(); + + expect(screen.getByRole('button', { name: /clear all/i })).toBeDisabled(); + }); + + test('Clear All button is enabled when hasActiveFilters is true', () => { + renderWithProviders(); + + expect(screen.getByRole('button', { name: /clear all/i })).toBeEnabled(); + }); + + test('calls onClearAll when Clear All button is clicked', async () => { + const user = userEvent.setup(); + const onClearAll = jest.fn(); + + renderWithProviders( + + ); + + await user.click(screen.getByRole('button', { name: /clear all/i })); + + expect(onClearAll).toHaveBeenCalledTimes(1); + }); + }); + + describe('Close Functionality', () => { + test('calls onClose when header close button is clicked', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + + renderWithProviders(); + + await user.click(screen.getByTestId('drawer-close-button')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('calls onClose when action Close button is clicked', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + + renderWithProviders(); + + await user.click(screen.getByRole('button', { name: /^close$/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe('Filter Values Passed to Children', () => { + test('passes duration values to DurationFilterInput', () => { + renderWithProviders( + + ); + + const durationFilter = screen.getByTestId('duration-filter'); + expect(durationFilter).toHaveTextContent('min:5'); + expect(durationFilter).toHaveTextContent('max:30'); + }); + + test('passes date values to DateRangeFilterInput', () => { + const dateFrom = new Date('2024-01-15T00:00:00.000Z'); + const dateTo = new Date('2024-06-20T00:00:00.000Z'); + + renderWithProviders( + + ); + + const dateFilter = screen.getByTestId('date-filter'); + expect(dateFilter).toHaveTextContent('from:2024-01-15'); + expect(dateFilter).toHaveTextContent('to:2024-06-20'); + }); + }); +}); diff --git a/client/src/components/ChannelPage/hooks/__tests__/useChannelFetchStatus.test.ts b/client/src/components/ChannelPage/hooks/__tests__/useChannelFetchStatus.test.ts new file mode 100644 index 00000000..3f79683a --- /dev/null +++ b/client/src/components/ChannelPage/hooks/__tests__/useChannelFetchStatus.test.ts @@ -0,0 +1,701 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useChannelFetchStatus } from '../useChannelFetchStatus'; + +// Mock fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch as any; + +describe('useChannelFetchStatus', () => { + const mockToken = 'test-token-123'; + const mockChannelId = 'UC123456789'; + const mockTabType = 'videos'; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockReset(); + // Suppress console.error for expected errors during test cleanup + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + describe('Initial State', () => { + test('returns default state values on initialization', () => { + // Don't make any fetch call complete to test initial state + mockFetch.mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + expect(result.current.isFetching).toBe(false); + expect(result.current.startTime).toBeNull(); + expect(typeof result.current.onFetchComplete).toBe('function'); + expect(typeof result.current.startPolling).toBe('function'); + }); + }); + + describe('Initial Fetch Status Check', () => { + test('checks fetch status on mount when all parameters provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + type: null, + tabType: 'videos', + }), + }); + + renderHook(() => useChannelFetchStatus(mockChannelId, mockTabType, mockToken)); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + expect(mockFetch).toHaveBeenCalledWith( + `/api/channels/${mockChannelId}/fetch-status?tabType=${mockTabType}`, + expect.objectContaining({ + headers: { + 'x-access-token': mockToken, + }, + }) + ); + }); + + test('does not check fetch status when channelId is undefined', () => { + renderHook(() => useChannelFetchStatus(undefined, mockTabType, mockToken)); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('does not check fetch status when token is null', () => { + renderHook(() => useChannelFetchStatus(mockChannelId, mockTabType, null)); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('does not check fetch status when tabType is null', () => { + renderHook(() => useChannelFetchStatus(mockChannelId, null, mockToken)); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('updates state when fetch is in progress', async () => { + const mockStartTime = '2023-01-01T00:00:00Z'; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: true, + startTime: mockStartTime, + type: 'full', + tabType: 'videos', + }), + }); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + await waitFor(() => { + expect(result.current.isFetching).toBe(true); + }); + + expect(result.current.startTime).toBe(mockStartTime); + }); + + test('handles missing startTime in response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + }), + }); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + expect(result.current.startTime).toBeNull(); + }); + }); + + describe('Error Handling', () => { + test('handles network errors gracefully', async () => { + const networkError = new Error('Network error'); + + mockFetch.mockRejectedValueOnce(networkError); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith('Error checking fetch status:', networkError); + }); + + // State should remain at defaults + expect(result.current.isFetching).toBe(false); + expect(result.current.startTime).toBeNull(); + }); + + test('handles non-ok response status', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // State should remain at defaults when response is not ok + expect(result.current.isFetching).toBe(false); + expect(result.current.startTime).toBeNull(); + }); + }); + + describe('Polling Behavior', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + test('starts polling when fetch is detected in progress', async () => { + // Initial check shows fetching in progress + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: true, + startTime: '2023-01-01T00:00:00Z', + }), + }); + + // Second poll + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: true, + startTime: '2023-01-01T00:00:00Z', + }), + }); + + renderHook(() => useChannelFetchStatus(mockChannelId, mockTabType, mockToken)); + + // Wait for initial fetch to complete + await act(async () => { + await Promise.resolve(); + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Advance timers by poll interval (3000ms) + await act(async () => { + jest.advanceTimersByTime(3000); + await Promise.resolve(); + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('startPolling enables polling manually', async () => { + // Initial check shows not fetching + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + + // After startPolling, poll should happen + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: true, + startTime: '2023-01-01T00:00:00Z', + }), + }); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + // Wait for initial fetch + await act(async () => { + await Promise.resolve(); + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Manually start polling + act(() => { + result.current.startPolling(); + }); + + // Advance timers by poll interval + await act(async () => { + jest.advanceTimersByTime(3000); + await Promise.resolve(); + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('stops polling when fetch completes', async () => { + // Initial check shows fetching in progress + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: true, + startTime: '2023-01-01T00:00:00Z', + }), + }); + + // Second poll shows fetch completed + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + // Wait for initial fetch + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isFetching).toBe(true); + + // Advance to trigger poll + await act(async () => { + jest.advanceTimersByTime(3000); + await Promise.resolve(); + }); + + expect(result.current.isFetching).toBe(false); + + // Advance more time - should not poll again since shouldPoll is false + await act(async () => { + jest.advanceTimersByTime(6000); + await Promise.resolve(); + }); + + // Should only have 2 calls (initial + one poll that detected completion) + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + test('does not poll when shouldPoll is false', async () => { + // Initial check shows not fetching + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + + renderHook(() => useChannelFetchStatus(mockChannelId, mockTabType, mockToken)); + + // Wait for initial fetch + await act(async () => { + await Promise.resolve(); + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Advance timers - should not trigger additional polls + await act(async () => { + jest.advanceTimersByTime(10000); + await Promise.resolve(); + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('Fetch Complete Callback', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + test('calls onFetchComplete callback when fetch transitions from true to false', async () => { + const mockCallback = jest.fn(); + + // Initial check shows fetching in progress + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: true, + startTime: '2023-01-01T00:00:00Z', + }), + }); + + // Second poll shows fetch completed + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + // Register the callback + act(() => { + result.current.onFetchComplete(mockCallback); + }); + + // Wait for initial fetch + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isFetching).toBe(true); + + // Advance to trigger poll + await act(async () => { + jest.advanceTimersByTime(3000); + await Promise.resolve(); + }); + + expect(result.current.isFetching).toBe(false); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + test('does not call callback when fetch was already false', async () => { + const mockCallback = jest.fn(); + + // Initial check shows not fetching + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + // Register the callback + act(() => { + result.current.onFetchComplete(mockCallback); + }); + + // Wait for initial fetch + await act(async () => { + await Promise.resolve(); + }); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + + test('callback can be updated', async () => { + const mockCallback1 = jest.fn(); + const mockCallback2 = jest.fn(); + + // Initial check shows fetching in progress + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: true, + startTime: '2023-01-01T00:00:00Z', + }), + }); + + // Second poll shows fetch completed + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + + const { result } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + // Register first callback + act(() => { + result.current.onFetchComplete(mockCallback1); + }); + + // Update to second callback + act(() => { + result.current.onFetchComplete(mockCallback2); + }); + + // Wait for initial fetch + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isFetching).toBe(true); + + // Advance to trigger poll + await act(async () => { + jest.advanceTimersByTime(3000); + await Promise.resolve(); + }); + + expect(result.current.isFetching).toBe(false); + + expect(mockCallback1).not.toHaveBeenCalled(); + expect(mockCallback2).toHaveBeenCalledTimes(1); + }); + }); + + describe('Cleanup', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + test('clears polling interval on unmount', async () => { + // Initial check shows fetching in progress + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: true, + startTime: '2023-01-01T00:00:00Z', + }), + }); + + const { unmount } = renderHook(() => + useChannelFetchStatus(mockChannelId, mockTabType, mockToken) + ); + + // Wait for initial fetch + await act(async () => { + await Promise.resolve(); + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + unmount(); + + // Advance timers after unmount + act(() => { + jest.advanceTimersByTime(10000); + }); + + // No additional polls should happen after unmount + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('Callback Stability', () => { + test('onFetchComplete function remains stable across rerenders', async () => { + // Mock for initial render + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + // Mock for rerender with new channelId + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + + const { result, rerender } = renderHook( + ({ channelId }: { channelId: string }) => + useChannelFetchStatus(channelId, mockTabType, mockToken), + { + initialProps: { channelId: mockChannelId }, + } + ); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + const firstOnFetchComplete = result.current.onFetchComplete; + + rerender({ channelId: 'UC987654321' }); + + // Wait for the second fetch to complete + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + expect(result.current.onFetchComplete).toBe(firstOnFetchComplete); + }); + + test('startPolling function remains stable across rerenders', async () => { + // Mock for initial render + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + // Mock for rerender with new channelId + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + isFetching: false, + startTime: null, + }), + }); + + const { result, rerender } = renderHook( + ({ channelId }: { channelId: string }) => + useChannelFetchStatus(channelId, mockTabType, mockToken), + { + initialProps: { channelId: mockChannelId }, + } + ); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + const firstStartPolling = result.current.startPolling; + + rerender({ channelId: 'UC987654321' }); + + // Wait for the second fetch to complete + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + expect(result.current.startPolling).toBe(firstStartPolling); + }); + }); + + describe('Parameter Changes', () => { + const createFetchMock = () => ({ + ok: true, + json: jest.fn().mockResolvedValue({ + isFetching: false, + startTime: null, + }), + }); + + test('rechecks fetch status when channelId changes', async () => { + // Mock for initial render + mockFetch.mockResolvedValueOnce(createFetchMock()); + // Mock for rerender with new channelId + mockFetch.mockResolvedValueOnce(createFetchMock()); + + const { rerender } = renderHook( + ({ channelId }: { channelId: string }) => + useChannelFetchStatus(channelId, mockTabType, mockToken), + { + initialProps: { channelId: mockChannelId }, + } + ); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + rerender({ channelId: 'UC987654321' }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + const secondCallUrl = mockFetch.mock.calls[1][0]; + expect(secondCallUrl).toContain('UC987654321'); + }); + + test('rechecks fetch status when tabType changes', async () => { + // Mock for initial render + mockFetch.mockResolvedValueOnce(createFetchMock()); + // Mock for rerender with new tabType + mockFetch.mockResolvedValueOnce(createFetchMock()); + + const { rerender } = renderHook( + ({ tabType }: { tabType: string }) => + useChannelFetchStatus(mockChannelId, tabType, mockToken), + { + initialProps: { tabType: mockTabType }, + } + ); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + rerender({ tabType: 'shorts' }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + const secondCallUrl = mockFetch.mock.calls[1][0]; + expect(secondCallUrl).toContain('tabType=shorts'); + }); + + test('rechecks fetch status when token changes', async () => { + // Mock for initial render + mockFetch.mockResolvedValueOnce(createFetchMock()); + // Mock for rerender with new token + mockFetch.mockResolvedValueOnce(createFetchMock()); + + const { rerender } = renderHook( + ({ token }: { token: string }) => + useChannelFetchStatus(mockChannelId, mockTabType, token), + { + initialProps: { token: mockToken }, + } + ); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + rerender({ token: 'new-token-456' }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + const secondCallHeaders = mockFetch.mock.calls[1][1].headers; + expect(secondCallHeaders['x-access-token']).toBe('new-token-456'); + }); + }); +}); diff --git a/client/src/components/ChannelPage/hooks/__tests__/useChannelVideoFilters.test.ts b/client/src/components/ChannelPage/hooks/__tests__/useChannelVideoFilters.test.ts new file mode 100644 index 00000000..1ca3e0f9 --- /dev/null +++ b/client/src/components/ChannelPage/hooks/__tests__/useChannelVideoFilters.test.ts @@ -0,0 +1,295 @@ +import { renderHook, act } from '@testing-library/react'; +import { useChannelVideoFilters } from '../useChannelVideoFilters'; + +describe('useChannelVideoFilters', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('Initial State', () => { + test('returns default filter values', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + expect(result.current.filters).toEqual({ + minDuration: null, + maxDuration: null, + dateFrom: null, + dateTo: null, + }); + expect(result.current.inputMinDuration).toBeNull(); + expect(result.current.inputMaxDuration).toBeNull(); + expect(result.current.hasActiveFilters).toBe(false); + expect(result.current.activeFilterCount).toBe(0); + }); + }); + + describe('Duration Filters with Debouncing', () => { + test('updates inputMinDuration immediately but debounces filter update', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setMinDuration(5); + }); + + // Input updates immediately + expect(result.current.inputMinDuration).toBe(5); + // Filter not updated yet (debounced) + expect(result.current.filters.minDuration).toBeNull(); + + // Advance past debounce delay + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(result.current.filters.minDuration).toBe(5); + }); + + test('updates inputMaxDuration immediately but debounces filter update', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setMaxDuration(60); + }); + + expect(result.current.inputMaxDuration).toBe(60); + expect(result.current.filters.maxDuration).toBeNull(); + + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(result.current.filters.maxDuration).toBe(60); + }); + + test('cancels previous debounce when setting new duration value', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setMinDuration(5); + }); + + // Advance partially + act(() => { + jest.advanceTimersByTime(200); + }); + + // Set new value before debounce completes + act(() => { + result.current.setMinDuration(10); + }); + + // Advance past original timeout + act(() => { + jest.advanceTimersByTime(200); + }); + + // Filter should not have old value + expect(result.current.filters.minDuration).toBeNull(); + expect(result.current.inputMinDuration).toBe(10); + + // Complete new debounce + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(result.current.filters.minDuration).toBe(10); + }); + }); + + describe('Date Filters', () => { + test('updates dateFrom immediately without debouncing', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + const testDate = new Date('2023-01-15'); + + act(() => { + result.current.setDateFrom(testDate); + }); + + expect(result.current.filters.dateFrom).toEqual(testDate); + }); + + test('updates dateTo immediately without debouncing', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + const testDate = new Date('2023-12-31'); + + act(() => { + result.current.setDateTo(testDate); + }); + + expect(result.current.filters.dateTo).toEqual(testDate); + }); + }); + + describe('clearAllFilters', () => { + test('clears all filter values and input states', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + // Set various filters + act(() => { + result.current.setMinDuration(5); + result.current.setMaxDuration(60); + result.current.setDateFrom(new Date('2023-01-01')); + result.current.setDateTo(new Date('2023-12-31')); + }); + + // Let debounces complete + act(() => { + jest.advanceTimersByTime(400); + }); + + // Verify filters are set + expect(result.current.hasActiveFilters).toBe(true); + + // Clear all + act(() => { + result.current.clearAllFilters(); + }); + + expect(result.current.filters).toEqual({ + minDuration: null, + maxDuration: null, + dateFrom: null, + dateTo: null, + }); + expect(result.current.inputMinDuration).toBeNull(); + expect(result.current.inputMaxDuration).toBeNull(); + expect(result.current.hasActiveFilters).toBe(false); + }); + + test('cancels pending debounce timers when clearing', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setMinDuration(5); + result.current.setMaxDuration(60); + }); + + // Clear before debounce completes + act(() => { + result.current.clearAllFilters(); + }); + + // Advance past debounce delay + act(() => { + jest.advanceTimersByTime(400); + }); + + // Filters should remain null + expect(result.current.filters.minDuration).toBeNull(); + expect(result.current.filters.maxDuration).toBeNull(); + }); + }); + + describe('hasActiveFilters', () => { + test('returns true when minDuration is set', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setMinDuration(5); + jest.advanceTimersByTime(400); + }); + + expect(result.current.hasActiveFilters).toBe(true); + }); + + test('returns true when maxDuration is set', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setMaxDuration(60); + jest.advanceTimersByTime(400); + }); + + expect(result.current.hasActiveFilters).toBe(true); + }); + + test('returns true when dateFrom is set', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setDateFrom(new Date('2023-01-01')); + }); + + expect(result.current.hasActiveFilters).toBe(true); + }); + + test('returns true when dateTo is set', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setDateTo(new Date('2023-12-31')); + }); + + expect(result.current.hasActiveFilters).toBe(true); + }); + }); + + describe('activeFilterCount', () => { + test('counts duration as one filter even with both min and max', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setMinDuration(5); + result.current.setMaxDuration(60); + jest.advanceTimersByTime(400); + }); + + expect(result.current.activeFilterCount).toBe(1); + }); + + test('counts date range as one filter even with both from and to', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setDateFrom(new Date('2023-01-01')); + result.current.setDateTo(new Date('2023-12-31')); + }); + + expect(result.current.activeFilterCount).toBe(1); + }); + + test('returns 2 when both duration and date filters are active', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setMinDuration(5); + result.current.setDateFrom(new Date('2023-01-01')); + jest.advanceTimersByTime(400); + }); + + expect(result.current.activeFilterCount).toBe(2); + }); + + test('returns 0 when no filters are active', () => { + const { result } = renderHook(() => useChannelVideoFilters()); + + expect(result.current.activeFilterCount).toBe(0); + }); + }); + + describe('Cleanup on Unmount', () => { + test('clears pending timers on unmount', () => { + const { result, unmount } = renderHook(() => useChannelVideoFilters()); + + act(() => { + result.current.setMinDuration(5); + result.current.setMaxDuration(60); + }); + + // Unmount before debounce completes + unmount(); + + // Advancing timers should not cause issues + act(() => { + jest.advanceTimersByTime(400); + }); + + // No assertions needed - test passes if no errors thrown + }); + }); +}); diff --git a/client/src/components/ChannelPage/hooks/useChannelFetchStatus.ts b/client/src/components/ChannelPage/hooks/useChannelFetchStatus.ts new file mode 100644 index 00000000..c056f6fc --- /dev/null +++ b/client/src/components/ChannelPage/hooks/useChannelFetchStatus.ts @@ -0,0 +1,98 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; + +interface FetchStatus { + isFetching: boolean; + startTime?: string; + type?: string; + tabType?: string; +} + +interface UseChannelFetchStatusResult { + isFetching: boolean; + startTime: string | null; + onFetchComplete: (callback: () => void) => void; + startPolling: () => void; +} + +const POLL_INTERVAL_MS = 3000; + +export function useChannelFetchStatus( + channelId: string | undefined, + tabType: string | null, + token: string | null +): UseChannelFetchStatusResult { + const [isFetching, setIsFetching] = useState(false); + const [startTime, setStartTime] = useState(null); + const [shouldPoll, setShouldPoll] = useState(false); + const onCompleteCallbackRef = useRef<(() => void) | null>(null); + const previousIsFetchingRef = useRef(false); + + const checkFetchStatus = useCallback(async () => { + if (!channelId || !token || !tabType) return; + + try { + const response = await fetch(`/api/channels/${channelId}/fetch-status?tabType=${tabType}`, { + headers: { + 'x-access-token': token, + }, + }); + + if (response.ok) { + const data: FetchStatus = await response.json(); + + // Detect transition from fetching to not fetching + if (previousIsFetchingRef.current && !data.isFetching) { + // Fetch just completed - stop polling and trigger callback + setShouldPoll(false); + if (onCompleteCallbackRef.current) { + onCompleteCallbackRef.current(); + } + } + + // If we detected an active fetch, enable polling + if (data.isFetching && !shouldPoll) { + setShouldPoll(true); + } + + previousIsFetchingRef.current = data.isFetching; + setIsFetching(data.isFetching); + setStartTime(data.startTime || null); + } + } catch (error) { + console.error('Error checking fetch status:', error); + } + }, [channelId, tabType, token, shouldPoll]); + + // Single check on mount to detect any background fetches + useEffect(() => { + if (!channelId || !token || !tabType) return; + checkFetchStatus(); + }, [channelId, tabType, token]); // eslint-disable-line react-hooks/exhaustive-deps + + // Polling interval - only active when shouldPoll is true + useEffect(() => { + if (!channelId || !token || !tabType || !shouldPoll) return; + + const intervalId = setInterval(checkFetchStatus, POLL_INTERVAL_MS); + + return () => { + clearInterval(intervalId); + }; + }, [channelId, tabType, token, shouldPoll, checkFetchStatus]); + + const onFetchComplete = useCallback((callback: () => void) => { + onCompleteCallbackRef.current = callback; + }, []); + + // Allow callers to start polling (e.g., when they initiate a fetch) + const startPolling = useCallback(() => { + setShouldPoll(true); + }, []); + + return { + isFetching, + startTime, + onFetchComplete, + startPolling, + }; +} diff --git a/client/src/components/ChannelPage/hooks/useChannelVideoFilters.ts b/client/src/components/ChannelPage/hooks/useChannelVideoFilters.ts new file mode 100644 index 00000000..d3d87db8 --- /dev/null +++ b/client/src/components/ChannelPage/hooks/useChannelVideoFilters.ts @@ -0,0 +1,135 @@ +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; + +export interface VideoFilters { + minDuration: number | null; // minutes + maxDuration: number | null; // minutes + dateFrom: Date | null; + dateTo: Date | null; +} + +export interface UseChannelVideoFiltersReturn { + filters: VideoFilters; + // Immediate values for input display (updates instantly) + inputMinDuration: number | null; + inputMaxDuration: number | null; + setMinDuration: (value: number | null) => void; + setMaxDuration: (value: number | null) => void; + setDateFrom: (value: Date | null) => void; + setDateTo: (value: Date | null) => void; + clearAllFilters: () => void; + hasActiveFilters: boolean; + activeFilterCount: number; +} + +const initialFilters: VideoFilters = { + minDuration: null, + maxDuration: null, + dateFrom: null, + dateTo: null, +}; + +const DEBOUNCE_DELAY = 400; // ms + +export function useChannelVideoFilters(): UseChannelVideoFiltersReturn { + const [filters, setFilters] = useState(initialFilters); + + // Separate state for immediate input values (for responsive UI) + const [inputMinDuration, setInputMinDuration] = useState(null); + const [inputMaxDuration, setInputMaxDuration] = useState(null); + + // Refs for debounce timers + const minDurationTimerRef = useRef | null>(null); + const maxDurationTimerRef = useRef | null>(null); + + // Cleanup timers on unmount + useEffect(() => { + return () => { + if (minDurationTimerRef.current) clearTimeout(minDurationTimerRef.current); + if (maxDurationTimerRef.current) clearTimeout(maxDurationTimerRef.current); + }; + }, []); + + const setMinDuration = useCallback((value: number | null) => { + // Update input immediately for responsive UI + setInputMinDuration(value); + + // Clear existing timer + if (minDurationTimerRef.current) { + clearTimeout(minDurationTimerRef.current); + } + + // Debounce the actual filter update + minDurationTimerRef.current = setTimeout(() => { + setFilters((prev) => ({ ...prev, minDuration: value })); + }, DEBOUNCE_DELAY); + }, []); + + const setMaxDuration = useCallback((value: number | null) => { + // Update input immediately for responsive UI + setInputMaxDuration(value); + + // Clear existing timer + if (maxDurationTimerRef.current) { + clearTimeout(maxDurationTimerRef.current); + } + + // Debounce the actual filter update + maxDurationTimerRef.current = setTimeout(() => { + setFilters((prev) => ({ ...prev, maxDuration: value })); + }, DEBOUNCE_DELAY); + }, []); + + const setDateFrom = useCallback((value: Date | null) => { + setFilters((prev) => ({ ...prev, dateFrom: value })); + }, []); + + const setDateTo = useCallback((value: Date | null) => { + setFilters((prev) => ({ ...prev, dateTo: value })); + }, []); + + const clearAllFilters = useCallback(() => { + // Clear any pending debounce timers + if (minDurationTimerRef.current) clearTimeout(minDurationTimerRef.current); + if (maxDurationTimerRef.current) clearTimeout(maxDurationTimerRef.current); + + // Reset both input and filter state + setInputMinDuration(null); + setInputMaxDuration(null); + setFilters(initialFilters); + }, []); + + const hasActiveFilters = useMemo(() => { + return ( + filters.minDuration !== null || + filters.maxDuration !== null || + filters.dateFrom !== null || + filters.dateTo !== null + ); + }, [filters]); + + const activeFilterCount = useMemo(() => { + let count = 0; + // Count duration as one filter (even if both min and max are set) + if (filters.minDuration !== null || filters.maxDuration !== null) { + count += 1; + } + // Count date range as one filter (even if both from and to are set) + if (filters.dateFrom !== null || filters.dateTo !== null) { + count += 1; + } + return count; + }, [filters]); + + return { + filters, + inputMinDuration, + inputMaxDuration, + setMinDuration, + setMaxDuration, + setDateFrom, + setDateTo, + clearAllFilters, + hasActiveFilters, + activeFilterCount, + }; +} diff --git a/client/src/components/ChannelPage/hooks/useChannelVideos.ts b/client/src/components/ChannelPage/hooks/useChannelVideos.ts index 6c7166e7..4aad3428 100644 --- a/client/src/components/ChannelPage/hooks/useChannelVideos.ts +++ b/client/src/components/ChannelPage/hooks/useChannelVideos.ts @@ -11,6 +11,10 @@ interface UseChannelVideosParams { sortOrder: string; tabType: string | null; token: string | null; + minDuration?: number | null; + maxDuration?: number | null; + dateFrom?: Date | null; + dateTo?: Date | null; } interface UseChannelVideosResult { @@ -35,6 +39,10 @@ export function useChannelVideos({ sortOrder, tabType, token, + minDuration, + maxDuration, + dateFrom, + dateTo, }: UseChannelVideosParams): UseChannelVideosResult { const [videos, setVideos] = useState([]); const [totalCount, setTotalCount] = useState(0); @@ -62,6 +70,20 @@ export function useChannelVideos({ tabType: tabType, }); + // Add optional filter params (convert duration from minutes to seconds) + if (minDuration != null) { + queryParams.append('minDuration', (minDuration * 60).toString()); + } + if (maxDuration != null) { + queryParams.append('maxDuration', (maxDuration * 60).toString()); + } + if (dateFrom) { + queryParams.append('dateFrom', dateFrom.toISOString().split('T')[0]); + } + if (dateTo) { + queryParams.append('dateTo', dateTo.toISOString().split('T')[0]); + } + const response = await fetch(`/getchannelvideos/${channelId}?${queryParams}`, { headers: { 'x-access-token': token, @@ -88,7 +110,7 @@ export function useChannelVideos({ } finally { setLoading(false); } - }, [channelId, page, pageSize, hideDownloaded, searchQuery, sortBy, sortOrder, tabType, token]); + }, [channelId, page, pageSize, hideDownloaded, searchQuery, sortBy, sortOrder, tabType, token, minDuration, maxDuration, dateFrom, dateTo]); useEffect(() => { fetchVideos(); diff --git a/client/src/components/ChannelPage/hooks/useRefreshChannelVideos.ts b/client/src/components/ChannelPage/hooks/useRefreshChannelVideos.ts index 7a02fadc..0922e4b5 100644 --- a/client/src/components/ChannelPage/hooks/useRefreshChannelVideos.ts +++ b/client/src/components/ChannelPage/hooks/useRefreshChannelVideos.ts @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { ChannelVideo } from '../../../types/ChannelVideo'; interface RefreshResult { @@ -25,6 +25,12 @@ export function useRefreshChannelVideos( const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Reset loading state when tab changes (the load is per-tab, not global) + useEffect(() => { + setLoading(false); + setError(null); + }, [tabType]); + const refreshVideos = useCallback(async (): Promise => { if (!channelId || !token || !tabType) return null; diff --git a/server/__tests__/server.routes.test.js b/server/__tests__/server.routes.test.js index cc6ec832..708063d4 100644 --- a/server/__tests__/server.routes.test.js +++ b/server/__tests__/server.routes.test.js @@ -813,7 +813,11 @@ describe('server routes - channels', () => { '', // default searchQuery 'date', // default sortBy 'desc', // default sortOrder - 'videos' // default tabType + 'videos', // default tabType + null, // default minDuration + null, // default maxDuration + null, // default dateFrom + null // default dateTo ); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ @@ -875,7 +879,11 @@ describe('server routes - channels', () => { 'test search', 'title', 'asc', - 'videos' // default tabType + 'videos', // default tabType + null, // default minDuration + null, // default maxDuration + null, // default dateFrom + null // default dateTo ); expect(res.statusCode).toBe(200); }); @@ -910,7 +918,11 @@ describe('server routes - channels', () => { '', 'date', 'desc', - 'shorts' + 'shorts', + null, // default minDuration + null, // default maxDuration + null, // default dateFrom + null // default dateTo ); expect(res.statusCode).toBe(200); }); diff --git a/server/modules/__tests__/channelModule.test.js b/server/modules/__tests__/channelModule.test.js index c6b3de7b..9c4eb8e6 100644 --- a/server/modules/__tests__/channelModule.test.js +++ b/server/modules/__tests__/channelModule.test.js @@ -2198,10 +2198,11 @@ describe('ChannelModule', () => { ChannelVideo.count.mockResolvedValue(0); Video.findAll = jest.fn().mockResolvedValue([]); - // Simulate an active fetch - ChannelModule.activeFetches.set('UC123', { + // Simulate an active fetch using composite key (channelId:tabType) + ChannelModule.activeFetches.set('UC123:videos', { startTime: new Date().toISOString(), - type: 'fetchAll' + type: 'fetchAll', + tabType: 'videos' }); const result = await ChannelModule.getChannelVideos('UC123'); @@ -2209,12 +2210,12 @@ describe('ChannelModule', () => { // Should not throw, should return cached data expect(result.videos).toBeDefined(); expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ channelId: 'UC123' }), - 'Skipping auto-refresh - fetch already in progress' + expect.objectContaining({ channelId: 'UC123', tabType: 'videos' }), + 'Skipping auto-refresh - fetch already in progress for this tab' ); // Clean up - ChannelModule.activeFetches.delete('UC123'); + ChannelModule.activeFetches.delete('UC123:videos'); }); test('should handle errors and return cached data', async () => { @@ -2279,15 +2280,17 @@ describe('ChannelModule', () => { }); test('should throw error when fetch already in progress', async () => { - ChannelModule.activeFetches.set('UC123', { + // Use composite key (channelId:tabType) since we now track per-tab + ChannelModule.activeFetches.set('UC123:videos', { startTime: new Date().toISOString(), - type: 'autoRefresh' + type: 'autoRefresh', + tabType: 'videos' }); await expect(ChannelModule.fetchAllChannelVideos('UC123')).rejects.toThrow('fetch operation is already in progress'); // Clean up - ChannelModule.activeFetches.delete('UC123'); + ChannelModule.activeFetches.delete('UC123:videos'); }); test('should throw error when channel not found', async () => { diff --git a/server/modules/__tests__/videosModule.test.js b/server/modules/__tests__/videosModule.test.js index da2f6209..e4f6ffcb 100644 --- a/server/modules/__tests__/videosModule.test.js +++ b/server/modules/__tests__/videosModule.test.js @@ -716,7 +716,6 @@ describe('VideosModule', () => { // Mock large number of videos mockVideo.count.mockResolvedValueOnce(2500); - // Mock findAll to return videos in chunks const chunk1 = Array(1000).fill({ id: 1, diff --git a/server/modules/channelModule.js b/server/modules/channelModule.js index d96c14bd..2b941700 100644 --- a/server/modules/channelModule.js +++ b/server/modules/channelModule.js @@ -30,6 +30,12 @@ const MEDIA_TAB_TYPE_MAP = { 'streams': 'livestream', }; +// Maximum number of videos to load when user clicks "Load More" +// Limit set here because some channels have tens or hundreds of thousands of videos... +// which effectively is not "loadable", so we had to set some reasonable limit. +// Unfortunately, yt-dlp ALWAYS starts a fetch with the newest video, so there is no way to "page" through +const MAX_LOAD_MORE_VIDEOS = 5000; + class ChannelModule { constructor() { this.channelAutoDownload = this.channelAutoDownload.bind(this); @@ -97,6 +103,41 @@ class ChannelModule { await channel.reload(); } + /** + * Check if a fetch operation is currently in progress for a channel/tab combination + * @param {string} channelId - Channel ID to check + * @param {string} tabType - Tab type to check (optional, defaults to checking any tab) + * @returns {Object} - Object with isFetching boolean and operation details if fetching + */ + isFetchInProgress(channelId, tabType = null) { + if (tabType) { + // Check for specific tab + const key = `${channelId}:${tabType}`; + if (this.activeFetches.has(key)) { + const activeOperation = this.activeFetches.get(key); + return { + isFetching: true, + startTime: activeOperation.startTime, + type: activeOperation.type, + tabType: tabType + }; + } + } else { + // Check for any tab on this channel (legacy behavior) + for (const [key, value] of this.activeFetches.entries()) { + if (key.startsWith(`${channelId}:`)) { + return { + isFetching: true, + startTime: value.startTime, + type: value.type, + tabType: key.split(':')[1] + }; + } + } + } + return { isFetching: false }; + } + /** * Execute yt-dlp command with promise-based handling @@ -1270,6 +1311,45 @@ class ChannelModule { }); } + /** + * Apply duration and date filters to a list of videos. + * @param {Array} videos - Array of video objects to filter + * @param {number|null} minDuration - Minimum duration in seconds + * @param {number|null} maxDuration - Maximum duration in seconds + * @param {string|null} dateFrom - Filter videos from this date (ISO string) + * @param {string|null} dateTo - Filter videos to this date (ISO string) + * @returns {Array} - Filtered array of videos + */ + _applyDurationAndDateFilters(videos, minDuration, maxDuration, dateFrom, dateTo) { + let filtered = videos; + + if (minDuration !== null) { + filtered = filtered.filter(video => + video.duration && video.duration >= minDuration + ); + } + if (maxDuration !== null) { + filtered = filtered.filter(video => + video.duration && video.duration <= maxDuration + ); + } + if (dateFrom) { + const fromDate = new Date(dateFrom); + filtered = filtered.filter(video => + video.publishedAt && new Date(video.publishedAt) >= fromDate + ); + } + if (dateTo) { + const toDate = new Date(dateTo); + toDate.setHours(23, 59, 59, 999); // Include entire day + filtered = filtered.filter(video => + video.publishedAt && new Date(video.publishedAt) <= toDate + ); + } + + return filtered; + } + /** * Fetch the newest videos for a channel from the database with search and sort. * Returns videos with download status. @@ -1282,9 +1362,13 @@ class ChannelModule { * @param {string} sortOrder - Sort order: 'asc' or 'desc' (default 'desc') * @param {boolean} checkFiles - Whether to check file existence for current page (default false) * @param {string} mediaType - Media type to filter by: 'video', 'short', 'livestream' (default 'video') + * @param {number|null} minDuration - Minimum duration in seconds (default null) + * @param {number|null} maxDuration - Maximum duration in seconds (default null) + * @param {string|null} dateFrom - Filter videos from this date (ISO string, default null) + * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise} - Array of video objects with download status */ - async fetchNewestVideosFromDb(channelId, limit = 50, offset = 0, excludeDownloaded = false, searchQuery = '', sortBy = 'date', sortOrder = 'desc', checkFiles = false, mediaType = 'video') { + async fetchNewestVideosFromDb(channelId, limit = 50, offset = 0, excludeDownloaded = false, searchQuery = '', sortBy = 'date', sortOrder = 'desc', checkFiles = false, mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null) { // First get all videos to enrich with download status const allChannelVideos = await ChannelVideo.findAll({ where: { @@ -1312,6 +1396,9 @@ class ChannelModule { ); } + // Apply duration and date filters + filteredVideos = this._applyDurationAndDateFilters(filteredVideos, minDuration, maxDuration, dateFrom, dateTo); + // Apply sorting filteredVideos.sort((a, b) => { let comparison = 0; @@ -1379,11 +1466,15 @@ class ChannelModule { * @param {boolean} excludeDownloaded - Whether to exclude downloaded videos (default false) * @param {string} searchQuery - Search query to filter videos by title (default '') * @param {string} mediaType - Media type to filter by: 'video', 'short', 'livestream' (default 'video') + * @param {number|null} minDuration - Minimum duration in seconds (default null) + * @param {number|null} maxDuration - Maximum duration in seconds (default null) + * @param {string|null} dateFrom - Filter videos from this date (ISO string, default null) + * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise} - Object with totalCount and oldestVideoDate */ - async getChannelVideoStats(channelId, excludeDownloaded = false, searchQuery = '', mediaType = 'video') { + async getChannelVideoStats(channelId, excludeDownloaded = false, searchQuery = '', mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null) { // If we have search or filter, we need to get all videos - if (excludeDownloaded || searchQuery) { + if (excludeDownloaded || searchQuery || minDuration !== null || maxDuration !== null || dateFrom || dateTo) { // Need to filter by download status and/or search const allChannelVideos = await ChannelVideo.findAll({ where: { @@ -1410,6 +1501,9 @@ class ChannelModule { ); } + // Apply duration and date filters + filteredVideos = this._applyDurationAndDateFilters(filteredVideos, minDuration, maxDuration, dateFrom, dateTo); + return { totalCount: filteredVideos.length, oldestVideoDate: filteredVideos.length > 0 ? @@ -1860,9 +1954,13 @@ class ChannelModule { * @param {string} sortBy - Field to sort by: 'date', 'title', 'duration', 'size' (default 'date') * @param {string} sortOrder - Sort order: 'asc' or 'desc' (default 'desc') * @param {string} tabType - Tab type to fetch: 'videos', 'shorts', or 'streams' (default 'videos') + * @param {number|null} minDuration - Minimum duration in seconds (default null) + * @param {number|null} maxDuration - Maximum duration in seconds (default null) + * @param {string|null} dateFrom - Filter videos from this date (ISO string, default null) + * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise} - Response object with videos and metadata */ - async getChannelVideos(channelId, page = 1, pageSize = 50, hideDownloaded = false, searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS) { + async getChannelVideos(channelId, page = 1, pageSize = 50, hideDownloaded = false, searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null) { const channel = await Channel.findOne({ where: { channel_id: channelId }, }); @@ -1896,14 +1994,18 @@ class ChannelModule { const mostRecentVideoDate = allVideos.length > 0 ? allVideos[0].publishedAt : null; if (shouldFetchFromYoutube && this.shouldRefreshChannelVideos(channel, allVideos.length, mediaType)) { - // Check if there's already an active fetch for this channel - if (this.activeFetches.has(channelId)) { - logger.info({ channelId }, 'Skipping auto-refresh - fetch already in progress'); + // Use composite key to allow concurrent fetches for different tabs + const fetchKey = `${channelId}:${tabType}`; + + // Check if there's already an active fetch for this channel/tab + if (this.activeFetches.has(fetchKey)) { + logger.info({ channelId, tabType }, 'Skipping auto-refresh - fetch already in progress for this tab'); } else { // Register this fetch operation - this.activeFetches.set(channelId, { + this.activeFetches.set(fetchKey, { startTime: new Date().toISOString(), - type: 'autoRefresh' + type: 'autoRefresh', + tabType: tabType }); try { @@ -1911,14 +2013,14 @@ class ChannelModule { await this.fetchAndSaveVideosViaYtDlp(channel, channelId, tabType, mostRecentVideoDate); } finally { // Clear the active fetch record - this.activeFetches.delete(channelId); + this.activeFetches.delete(fetchKey); } } } // Now fetch the requested page of videos with file checking enabled const offset = (page - 1) * pageSize; - const paginatedVideos = await this.fetchNewestVideosFromDb(channelId, pageSize, offset, hideDownloaded, searchQuery, sortBy, sortOrder, true, mediaType); + const paginatedVideos = await this.fetchNewestVideosFromDb(channelId, pageSize, offset, hideDownloaded, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo); // Check if videos still exist on YouTube and mark as removed if they don't const videoValidationModule = require('./videoValidationModule'); @@ -1988,15 +2090,15 @@ class ChannelModule { } // Get stats for the response - const stats = await this.getChannelVideoStats(channelId, hideDownloaded, searchQuery, mediaType); + const stats = await this.getChannelVideoStats(channelId, hideDownloaded, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo); return this.buildChannelVideosResponse(paginatedVideos, channel, 'cache', stats, autoDownloadsEnabled, mediaType); } catch (error) { logger.error({ err: error, channelId }, 'Error fetching channel videos'); const offset = (page - 1) * pageSize; - const cachedVideos = await this.fetchNewestVideosFromDb(channelId, pageSize, offset, hideDownloaded, searchQuery, sortBy, sortOrder, true, mediaType); - const stats = await this.getChannelVideoStats(channelId, hideDownloaded, searchQuery, mediaType); + const cachedVideos = await this.fetchNewestVideosFromDb(channelId, pageSize, offset, hideDownloaded, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo); + const stats = await this.getChannelVideoStats(channelId, hideDownloaded, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo); return this.buildChannelVideosResponse(cachedVideos, channel, 'cache', stats, autoDownloadsEnabled, mediaType); } } @@ -2055,16 +2157,20 @@ class ChannelModule { * @returns {Promise} - Response with success status and paginated data */ async fetchAllChannelVideos(channelId, requestedPage = 1, requestedPageSize = 50, hideDownloaded = false, tabType = TAB_TYPES.VIDEOS) { - // Check if there's already an active fetch for this channel - if (this.activeFetches.has(channelId)) { - const activeOperation = this.activeFetches.get(channelId); - throw new Error(`A fetch operation is already in progress for this channel (started ${activeOperation.startTime})`); + // Use composite key to allow concurrent fetches for different tabs + const fetchKey = `${channelId}:${tabType}`; + + // Check if there's already an active fetch for this channel/tab combination + if (this.activeFetches.has(fetchKey)) { + const activeOperation = this.activeFetches.get(fetchKey); + throw new Error(`A fetch operation is already in progress for this channel tab (started ${activeOperation.startTime})`); } // Register this fetch operation - this.activeFetches.set(channelId, { + this.activeFetches.set(fetchKey, { startTime: new Date().toISOString(), - type: 'fetchAll' + type: 'fetchAll', + tabType: tabType }); try { @@ -2080,14 +2186,15 @@ class ChannelModule { logger.info({ channelId, channelTitle: channel.title, tabType }, 'Starting full video fetch for channel'); const startTime = Date.now(); - // Fetch ALL videos from YouTube (no --playlist-end parameter) + // Fetch videos from YouTube (limited to MAX_LOAD_MORE_VIDEOS to prevent hanging on large channels) const canonicalUrl = `${this.resolveChannelUrlFromId(channelId)}/${tabType}`; const YtdlpCommandBuilder = require('./download/ytdlpCommandBuilder'); const result = await this.withTempFile('channel-all-videos', async (outputFilePath) => { const args = YtdlpCommandBuilder.buildMetadataFetchArgs(canonicalUrl, { flatPlaylist: true, - extractorArgs: 'youtubetab:approximate_date' + extractorArgs: 'youtubetab:approximate_date', + playlistEnd: MAX_LOAD_MORE_VIDEOS }); const content = await this.executeYtDlpCommand(args, outputFilePath); @@ -2148,7 +2255,7 @@ class ChannelModule { } } finally { // Always clear the active fetch record, whether successful or failed - this.activeFetches.delete(channelId); + this.activeFetches.delete(fetchKey); } } } diff --git a/server/modules/channelSettingsModule.js b/server/modules/channelSettingsModule.js index 19f93292..e3061c44 100644 --- a/server/modules/channelSettingsModule.js +++ b/server/modules/channelSettingsModule.js @@ -347,13 +347,13 @@ class ChannelSettingsModule { throw new Error(validation.error); } - // Get recent 20 videos for this channel from channelvideos table + // Get recent 50 videos for this channel from channelvideos table // This table is populated when browsing channel page, before any downloads const channelVideos = await ChannelVideo.findAll({ where: { channel_id: channelId }, attributes: ['youtube_id', 'title', 'publishedAt'], order: [['publishedAt', 'DESC']], - limit: 20, + limit: 50, }); // If no regex pattern provided or empty, all videos match diff --git a/server/routes/channels.js b/server/routes/channels.js index fbf9f0c4..b46f1189 100644 --- a/server/routes/channels.js +++ b/server/routes/channels.js @@ -631,7 +631,14 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch const sortBy = req.query.sortBy || 'date'; const sortOrder = req.query.sortOrder || 'desc'; const tabType = req.query.tabType || 'videos'; - const result = await channelModule.getChannelVideos(channelId, page, pageSize, hideDownloaded, searchQuery, sortBy, sortOrder, tabType); + // Parse optional filter parameters (validate to avoid NaN issues) + const parsedMinDuration = req.query.minDuration ? parseInt(req.query.minDuration, 10) : null; + const parsedMaxDuration = req.query.maxDuration ? parseInt(req.query.maxDuration, 10) : null; + const minDuration = (parsedMinDuration !== null && !isNaN(parsedMinDuration)) ? parsedMinDuration : null; + const maxDuration = (parsedMaxDuration !== null && !isNaN(parsedMaxDuration)) ? parsedMaxDuration : null; + const dateFrom = req.query.dateFrom || null; + const dateTo = req.query.dateTo || null; + const result = await channelModule.getChannelVideos(channelId, page, pageSize, hideDownloaded, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo); if (Array.isArray(result)) { res.status(200).json({ @@ -711,6 +718,60 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch } }); + /** + * @swagger + * /api/channels/{channelId}/fetch-status: + * get: + * summary: Check if a fetch operation is in progress for a channel + * description: Returns whether a fetch operation (like Load More) is currently running for this channel/tab. + * tags: [Channels] + * parameters: + * - in: path + * name: channelId + * required: true + * schema: + * type: string + * description: The channel ID + * - in: query + * name: tabType + * schema: + * type: string + * enum: [videos, shorts, streams] + * description: Tab type to check (if not provided, checks any tab) + * responses: + * 200: + * description: Fetch status retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * isFetching: + * type: boolean + * startTime: + * type: string + * format: date-time + * type: + * type: string + * tabType: + * type: string + */ + router.get('/api/channels/:channelId/fetch-status', verifyToken, async (req, res) => { + const { channelId } = req.params; + const tabType = req.query.tabType || null; + + try { + const status = channelModule.isFetchInProgress(channelId, tabType); + res.status(200).json(status); + } catch (error) { + req.log.error({ err: error, channelId, tabType }, 'Failed to get fetch status'); + res.status(500).json({ + isFetching: false, + error: 'Failed to get fetch status' + }); + } + }); + /** * @swagger * /api/channels/{channelId}/videos/{youtubeId}/ignore: