diff --git a/.github/workflows/coverage-badges.yml b/.github/workflows/coverage-badges.yml index 45c01e60..718d0058 100644 --- a/.github/workflows/coverage-badges.yml +++ b/.github/workflows/coverage-badges.yml @@ -1,10 +1,16 @@ name: Update Coverage Badges on: - push: - branches: [ main ] + workflow_run: + workflows: ["Production Release"] + types: [completed] + branches: [main] workflow_dispatch: +concurrency: + group: coverage-badges-${{ github.ref }} + cancel-in-progress: true + permissions: contents: write @@ -12,8 +18,11 @@ jobs: update-badges: name: Update Coverage Badges runs-on: ubuntu-latest - # Skip if commit message contains [skip ci] - if: "!contains(github.event.head_commit.message, '[skip ci]')" + # Run on workflow_dispatch or when Production Release succeeds (skip if commit has [skip ci]) + if: > + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + !contains(github.event.workflow_run.head_commit.message, '[skip ci]')) steps: - name: Checkout code @@ -131,6 +140,8 @@ jobs: if [[ -n $(git status --porcelain) ]]; then git add README.md .github/badges/ git commit -m "chore: update coverage badges [skip ci]" + # Pull latest changes before pushing to handle any commits made during workflow run + git pull --rebase origin main git push else echo "No changes to coverage badges" diff --git a/client/src/components/ChannelManager/components/ChannelCard.tsx b/client/src/components/ChannelManager/components/ChannelCard.tsx index c4520b30..c24c0548 100644 --- a/client/src/components/ChannelManager/components/ChannelCard.tsx +++ b/client/src/components/ChannelManager/components/ChannelCard.tsx @@ -3,7 +3,7 @@ import { Avatar, Box, Card, CardActionArea, CardContent, Chip, IconButton, Toolt import DeleteIcon from '@mui/icons-material/Delete'; import ImageIcon from '@mui/icons-material/Image'; import { Channel } from '../../../types/Channel'; -import { QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip } from './chips'; +import { QualityChip, AutoDownloadChips, DurationFilterChip, TitleFilterChip, DownloadFormatConfigIndicator } from './chips'; import FolderIcon from '@mui/icons-material/Folder'; interface ChannelCardProps { @@ -223,7 +223,8 @@ interface CardDetailsProps { const CardDetails: React.FC = ({ channel, isMobile, onRegexClick }) => { return ( - + + = ({ + = ({ alignItems: 'center', }} > + ({ onClick: (e: any) => onRegexClick(e, titleFilterRegex), }, `Title Filter: ${titleFilterRegex}`); }, + DownloadFormatConfigIndicator: function MockDownloadFormatConfigIndicator({ audioFormat }: any) { + const React = require('react'); + return React.createElement('div', { + 'data-testid': 'download-format-config-indicator', + 'data-audio-format': audioFormat, + }, 'Format'); + }, })); describe('ChannelCard Component', () => { diff --git a/client/src/components/ChannelManager/components/__tests__/ChannelListRow.test.tsx b/client/src/components/ChannelManager/components/__tests__/ChannelListRow.test.tsx index 9119f27b..86ec327d 100644 --- a/client/src/components/ChannelManager/components/__tests__/ChannelListRow.test.tsx +++ b/client/src/components/ChannelManager/components/__tests__/ChannelListRow.test.tsx @@ -67,6 +67,17 @@ jest.mock('../chips', () => ({ 'Title Filter' ); }, + DownloadFormatConfigIndicator: function MockDownloadFormatConfigIndicator({ audioFormat }: any) { + const React = require('react'); + return React.createElement( + 'div', + { + 'data-testid': 'download-format-config-indicator', + 'data-audio-format': audioFormat, + }, + 'Format' + ); + }, })); describe('ChannelListRow', () => { diff --git a/client/src/components/ChannelManager/components/chips/DownloadFormatConfigIndicator.tsx b/client/src/components/ChannelManager/components/chips/DownloadFormatConfigIndicator.tsx new file mode 100644 index 00000000..9a437e74 --- /dev/null +++ b/client/src/components/ChannelManager/components/chips/DownloadFormatConfigIndicator.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Box, Tooltip } from '@mui/material'; +import { MovieOutlined as VideoIcon, AudiotrackOutlined as AudioIcon } from '@mui/icons-material'; + +interface DownloadFormatConfigIndicatorProps { + audioFormat: string | null | undefined; +} + +const DownloadFormatConfigIndicator: React.FC = ({ + audioFormat, +}) => { + // Determine which icons to show based on audio_format setting: + // - null or undefined: video only (default) + // - 'video_mp3': both video and mp3 + // - 'mp3_only': mp3 only + const showVideo = !audioFormat || audioFormat === 'video_mp3'; + const showAudio = audioFormat === 'video_mp3' || audioFormat === 'mp3_only'; + + return ( + + {showVideo && ( + + + + )} + {showAudio && ( + + + + )} + + ); +}; + +export default DownloadFormatConfigIndicator; diff --git a/client/src/components/ChannelManager/components/chips/__tests__/DownloadFormatConfigIndicator.test.tsx b/client/src/components/ChannelManager/components/chips/__tests__/DownloadFormatConfigIndicator.test.tsx new file mode 100644 index 00000000..89ea3916 --- /dev/null +++ b/client/src/components/ChannelManager/components/chips/__tests__/DownloadFormatConfigIndicator.test.tsx @@ -0,0 +1,40 @@ +import { screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import DownloadFormatConfigIndicator from '../DownloadFormatConfigIndicator'; +import { renderWithProviders } from '../../../../../test-utils'; + +describe('DownloadFormatConfigIndicator', () => { + test('shows only video icon when audioFormat is null (default)', () => { + renderWithProviders(); + + expect(screen.getByTestId('video-format-icon')).toBeInTheDocument(); + expect(screen.queryByTestId('audio-format-icon')).not.toBeInTheDocument(); + }); + + test('shows only video icon when audioFormat is undefined', () => { + renderWithProviders(); + + expect(screen.getByTestId('video-format-icon')).toBeInTheDocument(); + expect(screen.queryByTestId('audio-format-icon')).not.toBeInTheDocument(); + }); + + test('shows both video and audio icons when audioFormat is video_mp3', () => { + renderWithProviders(); + + expect(screen.getByTestId('video-format-icon')).toBeInTheDocument(); + expect(screen.getByTestId('audio-format-icon')).toBeInTheDocument(); + }); + + test('shows only audio icon when audioFormat is mp3_only', () => { + renderWithProviders(); + + expect(screen.queryByTestId('video-format-icon')).not.toBeInTheDocument(); + expect(screen.getByTestId('audio-format-icon')).toBeInTheDocument(); + }); + + test('renders container with correct testid', () => { + renderWithProviders(); + + expect(screen.getByTestId('download-format-config-indicator')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ChannelManager/components/chips/index.ts b/client/src/components/ChannelManager/components/chips/index.ts index 38a8e972..f328e462 100644 --- a/client/src/components/ChannelManager/components/chips/index.ts +++ b/client/src/components/ChannelManager/components/chips/index.ts @@ -3,3 +3,4 @@ export { default as QualityChip } from './QualityChip'; export { default as AutoDownloadChips } from './AutoDownloadChips'; export { default as DurationFilterChip } from './DurationFilterChip'; export { default as TitleFilterChip } from './TitleFilterChip'; +export { default as DownloadFormatConfigIndicator } from './DownloadFormatConfigIndicator'; diff --git a/client/src/components/ChannelPage.tsx b/client/src/components/ChannelPage.tsx index 1ecb0393..efe7c6b3 100644 --- a/client/src/components/ChannelPage.tsx +++ b/client/src/components/ChannelPage.tsx @@ -29,6 +29,7 @@ function ChannelPage({ token }: ChannelPageProps) { const handleSettingsSaved = (updated: { sub_folder: string | null; video_quality: string | null; + audio_format: string | null; min_duration: number | null; max_duration: number | null; title_filter_regex: string | null; @@ -41,6 +42,7 @@ function ChannelPage({ token }: ChannelPageProps) { ...prev, sub_folder: updated.sub_folder, video_quality: updated.video_quality, + audio_format: updated.audio_format, min_duration: updated.min_duration, max_duration: updated.max_duration, title_filter_regex: updated.title_filter_regex, @@ -331,6 +333,7 @@ function ChannelPage({ token }: ChannelPageProps) { channelAutoDownloadTabs={channel?.auto_download_enabled_tabs} channelId={channel_id || undefined} channelVideoQuality={channel?.video_quality || null} + channelAudioFormat={channel?.audio_format || null} /> {channel && channel_id && ( diff --git a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx index 1dd86d7d..e4111166 100644 --- a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx +++ b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx @@ -36,6 +36,7 @@ interface ChannelSettings { min_duration: number | null; max_duration: number | null; title_filter_regex: string | null; + audio_format: string | null; } interface FilterPreviewVideo { @@ -91,14 +92,16 @@ function ChannelSettingsDialog({ video_quality: null, min_duration: null, max_duration: null, - title_filter_regex: null + title_filter_regex: null, + audio_format: null }); const [originalSettings, setOriginalSettings] = useState({ sub_folder: null, video_quality: null, min_duration: null, max_duration: null, - title_filter_regex: null + title_filter_regex: null, + audio_format: null }); const [subfolders, setSubfolders] = useState([]); const [loading, setLoading] = useState(true); @@ -174,7 +177,8 @@ function ChannelSettingsDialog({ video_quality: settingsData.video_quality || null, min_duration: settingsData.min_duration || null, max_duration: settingsData.max_duration || null, - title_filter_regex: settingsData.title_filter_regex || null + title_filter_regex: settingsData.title_filter_regex || null, + audio_format: settingsData.audio_format || null }; setSettings(loadedSettings); setOriginalSettings(loadedSettings); @@ -229,7 +233,8 @@ function ChannelSettingsDialog({ video_quality: settings.video_quality || null, min_duration: settings.min_duration, max_duration: settings.max_duration, - title_filter_regex: settings.title_filter_regex || null + title_filter_regex: settings.title_filter_regex || null, + audio_format: settings.audio_format || null }) }); @@ -256,7 +261,8 @@ function ChannelSettingsDialog({ video_quality: result?.settings?.video_quality ?? settings.video_quality ?? null, min_duration: result?.settings?.min_duration ?? settings.min_duration ?? null, max_duration: result?.settings?.max_duration ?? settings.max_duration ?? null, - title_filter_regex: result?.settings?.title_filter_regex ?? settings.title_filter_regex ?? null + title_filter_regex: result?.settings?.title_filter_regex ?? settings.title_filter_regex ?? null, + audio_format: result?.settings?.audio_format ?? settings.audio_format ?? null }; setSettings(updatedSettings); @@ -294,7 +300,8 @@ function ChannelSettingsDialog({ settings.video_quality !== originalSettings.video_quality || settings.min_duration !== originalSettings.min_duration || settings.max_duration !== originalSettings.max_duration || - settings.title_filter_regex !== originalSettings.title_filter_regex; + settings.title_filter_regex !== originalSettings.title_filter_regex || + settings.audio_format !== originalSettings.audio_format; }; const handlePreviewFilter = async () => { @@ -415,6 +422,33 @@ function ChannelSettingsDialog({ Effective channel quality: {effectiveQualityDisplay}. + + Download Type + + + + {settings.audio_format && ( + + MP3 files are saved at 192kbps in the same folder as videos. + + )} + Subfolder Organization diff --git a/client/src/components/ChannelPage/ChannelVideos.tsx b/client/src/components/ChannelPage/ChannelVideos.tsx index a11c1f76..f3186edd 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'; @@ -51,13 +54,14 @@ interface ChannelVideosProps { channelAutoDownloadTabs?: string; channelId?: string; channelVideoQuality?: string | null; + channelAudioFormat?: string | null; } type ViewMode = 'table' | 'grid' | 'list'; type SortBy = 'date' | 'title' | 'duration' | 'size'; type SortOrder = 'asc' | 'desc'; -function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelId, channelVideoQuality }: ChannelVideosProps) { +function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelId, channelVideoQuality, channelAudioFormat }: ChannelVideosProps) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -67,6 +71,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 +96,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 +251,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,21 +267,48 @@ 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); const defaultResolution = channelVideoQuality || config.preferredResolution || '1080'; const defaultResolutionSource: 'channel' | 'global' = hasChannelOverride ? 'channel' : 'global'; + const hasChannelAudioOverride = Boolean(channelAudioFormat); + const defaultAudioFormat = channelAudioFormat || null; + const defaultAudioFormatSource: 'channel' | 'global' = hasChannelAudioOverride ? 'channel' : 'global'; + const { triggerDownloads } = useTriggerDownloads(token); 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) @@ -322,6 +372,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI resolution: settings.resolution, allowRedownload: settings.allowRedownload, subfolder: settings.subfolder, + audioFormat: settings.audioFormat, } : undefined; @@ -337,6 +388,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 +557,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 +814,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 +869,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 +891,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'} ) : ( @@ -912,6 +988,8 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI selectedForDeletion={selectedForDeletion.length} defaultResolution={defaultResolution} defaultResolutionSource={defaultResolutionSource} + defaultAudioFormat={defaultAudioFormat} + defaultAudioFormatSource={defaultAudioFormatSource} selectedTab={selectedTab || 'videos'} tabLabel={getTabLabel(selectedTab || 'videos')} onDownloadDialogClose={() => setDownloadDialogOpen(false)} diff --git a/client/src/components/ChannelPage/ChannelVideosDialogs.tsx b/client/src/components/ChannelPage/ChannelVideosDialogs.tsx index 06d23d37..87c41c1c 100644 --- a/client/src/components/ChannelPage/ChannelVideosDialogs.tsx +++ b/client/src/components/ChannelPage/ChannelVideosDialogs.tsx @@ -27,6 +27,8 @@ export interface ChannelVideosDialogsProps { selectedForDeletion: number; defaultResolution: string; defaultResolutionSource: 'channel' | 'global'; + defaultAudioFormat?: string | null; + defaultAudioFormatSource?: 'channel' | 'global'; selectedTab: string; tabLabel: string; onDownloadDialogClose: () => void; @@ -55,6 +57,8 @@ function ChannelVideosDialogs({ selectedForDeletion, defaultResolution, defaultResolutionSource, + defaultAudioFormat, + defaultAudioFormatSource, selectedTab, tabLabel, onDownloadDialogClose, @@ -79,11 +83,13 @@ function ChannelVideosDialogs({ missingVideoCount={missingVideoCount} defaultResolution={defaultResolution} defaultResolutionSource={defaultResolutionSource} + defaultAudioFormat={defaultAudioFormat} + defaultAudioFormatSource={defaultAudioFormatSource} mode="manual" 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/client/src/components/Configuration/sections/__tests__/NotificationsSection.test.tsx b/client/src/components/Configuration/sections/__tests__/NotificationsSection.test.tsx index 2718bf94..2f655f9c 100644 --- a/client/src/components/Configuration/sections/__tests__/NotificationsSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/NotificationsSection.test.tsx @@ -33,6 +33,9 @@ const expandAccordion = async (user: ReturnType) => { await user.click(accordionButton); }; +// Use delay: null to prevent timer-related flakiness when running with other tests +const setupUser = () => userEvent.setup({ delay: null }); + describe('NotificationsSection Component', () => { beforeEach(() => { jest.clearAllMocks(); @@ -53,7 +56,7 @@ describe('NotificationsSection Component', () => { }); test('renders Apprise link and info text', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps(); renderWithProviders(); @@ -90,7 +93,7 @@ describe('NotificationsSection Component', () => { describe('Enable Notifications Switch', () => { test('renders Enable Notifications switch', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps(); renderWithProviders(); @@ -100,7 +103,7 @@ describe('NotificationsSection Component', () => { }); test('switch reflects notificationsEnabled state when false', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: false }) }); @@ -113,7 +116,7 @@ describe('NotificationsSection Component', () => { }); test('switch reflects notificationsEnabled state when true', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true }) }); @@ -126,7 +129,7 @@ describe('NotificationsSection Component', () => { }); test('calls onConfigChange when switch is toggled on', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onConfigChange = jest.fn(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: false }), @@ -144,7 +147,7 @@ describe('NotificationsSection Component', () => { }); test('calls onConfigChange when switch is toggled off', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onConfigChange = jest.fn(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true }), @@ -164,7 +167,7 @@ describe('NotificationsSection Component', () => { describe('Apprise URLs Management - Visibility', () => { test('does not show URL input field when notifications are disabled', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: false }) }); @@ -176,7 +179,7 @@ describe('NotificationsSection Component', () => { }); test('shows URL input field when notifications are enabled', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true }) }); @@ -188,7 +191,7 @@ describe('NotificationsSection Component', () => { }); test('does not show add service section when notifications are disabled', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: false }) }); @@ -200,7 +203,7 @@ describe('NotificationsSection Component', () => { }); test('shows add service section when notifications are enabled', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true }) }); @@ -214,7 +217,7 @@ describe('NotificationsSection Component', () => { describe('Apprise URLs List', () => { test('displays configured URLs count', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true, @@ -233,7 +236,7 @@ describe('NotificationsSection Component', () => { }); test('displays user-friendly names for known services', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true, @@ -252,7 +255,7 @@ describe('NotificationsSection Component', () => { }); test('shows delete button for each URL', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true, @@ -269,7 +272,7 @@ describe('NotificationsSection Component', () => { }); test('removes URL when delete button is clicked and confirmed', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onConfigChange = jest.fn(); const props = createSectionProps({ config: createConfig({ @@ -301,7 +304,7 @@ describe('NotificationsSection Component', () => { describe('Adding New URLs', () => { test('adds URL when Add button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onConfigChange = jest.fn(); const props = createSectionProps({ config: createConfig({ @@ -326,7 +329,7 @@ describe('NotificationsSection Component', () => { }); test('adds URL when Enter is pressed', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onConfigChange = jest.fn(); const props = createSectionProps({ config: createConfig({ @@ -348,7 +351,7 @@ describe('NotificationsSection Component', () => { }); test('shows warning when trying to add empty URL', async () => { - const user = userEvent.setup(); + const user = setupUser(); const setSnackbar = jest.fn(); const props = createSectionProps({ config: createConfig({ @@ -372,7 +375,7 @@ describe('NotificationsSection Component', () => { }); test('shows warning when trying to add duplicate URL', async () => { - const user = userEvent.setup(); + const user = setupUser(); const setSnackbar = jest.fn(); const props = createSectionProps({ config: createConfig({ @@ -401,7 +404,7 @@ describe('NotificationsSection Component', () => { }); test('clears input after successful add', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onConfigChange = jest.fn(); const props = createSectionProps({ config: createConfig({ @@ -426,7 +429,7 @@ describe('NotificationsSection Component', () => { describe('Individual Webhook Test Buttons', () => { test('renders test button for each configured webhook', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true, @@ -445,7 +448,7 @@ describe('NotificationsSection Component', () => { }); test('displays configured webhooks count when webhooks are configured', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true, @@ -460,7 +463,7 @@ describe('NotificationsSection Component', () => { }); test('sends test notification to single webhook on click', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -498,7 +501,7 @@ describe('NotificationsSection Component', () => { }); test('shows success message after successful test', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -524,7 +527,7 @@ describe('NotificationsSection Component', () => { }); test('shows error message when test fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockFetch.mockResolvedValueOnce({ ok: false, status: 500, @@ -552,7 +555,7 @@ describe('NotificationsSection Component', () => { describe('Supported URL Formats', () => { test('displays supported URL format examples', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true }) }); @@ -571,7 +574,7 @@ describe('NotificationsSection Component', () => { describe('InfoTooltip Integration', () => { test('renders section with InfoTooltip support', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps(); renderWithProviders(); @@ -581,7 +584,7 @@ describe('NotificationsSection Component', () => { }); test('works without onMobileTooltipClick prop', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ onMobileTooltipClick: undefined }); renderWithProviders(); @@ -593,7 +596,7 @@ describe('NotificationsSection Component', () => { describe('Integration Tests', () => { test('enabling notifications shows URL input', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: false }) }); @@ -615,7 +618,7 @@ describe('NotificationsSection Component', () => { }); test('disabling notifications hides URL input', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true }) }); @@ -639,7 +642,7 @@ describe('NotificationsSection Component', () => { describe('Accessibility', () => { test('switch has accessible structure', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps(); renderWithProviders(); @@ -650,7 +653,7 @@ describe('NotificationsSection Component', () => { }); test('text input has accessible label', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true }) }); @@ -662,7 +665,7 @@ describe('NotificationsSection Component', () => { }); test('add button has accessible text', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true }) }); @@ -674,7 +677,7 @@ describe('NotificationsSection Component', () => { }); test('info text is present', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps(); renderWithProviders(); @@ -693,7 +696,7 @@ describe('NotificationsSection Component', () => { }); test('delete buttons have accessible labels', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = createSectionProps({ config: createConfig({ notificationsEnabled: true, diff --git a/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx b/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx index 349417a4..52caf1d2 100644 --- a/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx +++ b/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx @@ -6,6 +6,7 @@ import { DialogActions, Button, FormControl, + FormHelperText, InputLabel, Select, MenuItem, @@ -22,7 +23,8 @@ import { Download as DownloadIcon, Settings as SettingsIcon, FolderOpen as FolderIcon, - HighQuality as QualityIcon + HighQuality as QualityIcon, + Videocam as VideocamIcon } from '@mui/icons-material'; import { DownloadSettings } from './types'; import { SubfolderAutocomplete } from '../../shared/SubfolderAutocomplete'; @@ -38,6 +40,8 @@ interface DownloadSettingsDialogProps { defaultVideoCount?: number; // For channel downloads mode?: 'manual' | 'channel'; // To differentiate between modes defaultResolutionSource?: 'channel' | 'global'; + defaultAudioFormat?: string | null; // For channel audio format default + defaultAudioFormatSource?: 'channel' | 'global'; token?: string | null; // For fetching subfolders } @@ -60,6 +64,8 @@ const DownloadSettingsDialog: React.FC = ({ defaultVideoCount = 3, mode = 'manual', defaultResolutionSource = 'global', + defaultAudioFormat = null, + defaultAudioFormatSource = 'global', token = null }) => { const [useCustomSettings, setUseCustomSettings] = useState(false); @@ -68,6 +74,7 @@ const DownloadSettingsDialog: React.FC = ({ const [allowRedownload, setAllowRedownload] = useState(false); const [hasUserInteracted, setHasUserInteracted] = useState(false); const [subfolderOverride, setSubfolderOverride] = useState(null); + const [audioFormat, setAudioFormat] = useState(defaultAudioFormat); // Fetch available subfolders const { subfolders, loading: subfoldersLoading } = useSubfolders(token); @@ -77,11 +84,21 @@ const DownloadSettingsDialog: React.FC = ({ ? selectedDefaultOption.label : `${defaultResolution}p`; + const getAudioFormatLabel = (format: string | null) => { + if (!format) return 'Per channel settings (or video only)'; + if (format === 'video_mp3') return 'Video + MP3'; + if (format === 'mp3_only') return 'Audio Only (MP3)'; + return 'Video Only'; + }; + + const defaultAudioFormatLabel = getAudioFormatLabel(defaultAudioFormat); + // Auto-detect re-download need useEffect(() => { if (open && !hasUserInteracted) { setResolution(defaultResolution); setChannelVideoCount(defaultVideoCount); + setAudioFormat(defaultAudioFormat); // Auto-check re-download if there are missing videos or previously downloaded videos in manual mode if (missingVideoCount > 0) { setAllowRedownload(true); @@ -89,7 +106,7 @@ const DownloadSettingsDialog: React.FC = ({ setAllowRedownload(false); } } - }, [open, hasUserInteracted, mode, missingVideoCount, defaultResolution, defaultVideoCount]); + }, [open, hasUserInteracted, mode, missingVideoCount, defaultResolution, defaultVideoCount, defaultAudioFormat]); useEffect(() => { if (!open) { @@ -97,8 +114,9 @@ const DownloadSettingsDialog: React.FC = ({ setUseCustomSettings(false); setAllowRedownload(false); setSubfolderOverride(null); + setAudioFormat(defaultAudioFormat); } - }, [open]); + }, [open, defaultAudioFormat]); const handleUseCustomToggle = (event: React.ChangeEvent) => { setUseCustomSettings(event.target.checked); @@ -154,14 +172,17 @@ const DownloadSettingsDialog: React.FC = ({ } // Include subfolder override if set (only for manual mode) - const hasOverride = useCustomSettings || allowRedownload || (mode === 'manual' && subfolderOverride !== null); + const hasOverride = useCustomSettings || allowRedownload || + (mode === 'manual' && subfolderOverride !== null) || + (mode === 'manual' && audioFormat !== null); if (hasOverride) { onConfirm({ resolution: useCustomSettings ? resolution : defaultResolution, videoCount: mode === 'channel' ? (useCustomSettings ? channelVideoCount : defaultVideoCount) : 0, allowRedownload, - subfolder: mode === 'manual' ? subfolderOverride : undefined + subfolder: mode === 'manual' ? subfolderOverride : undefined, + audioFormat: mode === 'manual' ? audioFormat : undefined }); } else { onConfirm(null); // Use defaults @@ -247,6 +268,18 @@ const DownloadSettingsDialog: React.FC = ({ + + + + Download Type: {defaultAudioFormatLabel} + {defaultAudioFormatSource === 'channel' && ( + + (channel) + + )} + + + {mode === 'channel' && ( @@ -258,7 +291,7 @@ const DownloadSettingsDialog: React.FC = ({ Configured channels will use their subfolder settings. - Unconfigured channels will use the global default. + Enable custom settings to download MP3 audio. @@ -315,6 +348,9 @@ const DownloadSettingsDialog: React.FC = ({ ))} + + YouTube will provide the best available quality up to your selected resolution. + {resolution === '2160' && ( @@ -370,17 +406,39 @@ const DownloadSettingsDialog: React.FC = ({ label="Override Destination" helperText="Configured channels use their subfolder, unconfigured channels use global default." /> + + + Download Type + + + + Download Type + + {audioFormat && ( + + MP3 files are saved at 192kbps in the same folder as videos. + + )} + )} - - {/* Note about YouTube quality */} - - - Note: YouTube will provide the best available quality up to your selected resolution. - - diff --git a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx index e37030d2..8eaacb14 100644 --- a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx +++ b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx @@ -484,6 +484,7 @@ describe('DownloadSettingsDialog', () => { videoCount: 0, allowRedownload: false, subfolder: null, + audioFormat: null, }); }); @@ -607,6 +608,7 @@ describe('DownloadSettingsDialog', () => { videoCount: 0, allowRedownload: true, subfolder: null, + audioFormat: null, }); }); @@ -629,6 +631,7 @@ describe('DownloadSettingsDialog', () => { videoCount: 0, allowRedownload: true, subfolder: null, + audioFormat: null, }); }); diff --git a/client/src/components/DownloadManager/ManualDownload/types.ts b/client/src/components/DownloadManager/ManualDownload/types.ts index 0a5d54fb..74a670e9 100644 --- a/client/src/components/DownloadManager/ManualDownload/types.ts +++ b/client/src/components/DownloadManager/ManualDownload/types.ts @@ -16,6 +16,7 @@ export interface DownloadSettings { videoCount: number; allowRedownload?: boolean; subfolder?: string | null; + audioFormat?: string | null; } export interface ValidationResponse { diff --git a/client/src/components/VideosPage.tsx b/client/src/components/VideosPage.tsx index c97b09a0..c926da60 100644 --- a/client/src/components/VideosPage.tsx +++ b/client/src/components/VideosPage.tsx @@ -31,8 +31,6 @@ import { import Pagination from '@mui/material/Pagination'; import FilterListIcon from '@mui/icons-material/FilterList'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import StorageIcon from '@mui/icons-material/Storage'; import DeleteIcon from '@mui/icons-material/Delete'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useTheme } from '@mui/material/styles'; @@ -49,6 +47,7 @@ import VideoLibraryIcon from '@mui/icons-material/VideoLibrary'; import ScheduleIcon from '@mui/icons-material/Schedule'; import DeleteVideosDialog from './shared/DeleteVideosDialog'; import { useVideoDeletion } from './shared/useVideoDeletion'; +import DownloadFormatIndicator from './shared/DownloadFormatIndicator'; interface VideosPageProps { token: string | null; @@ -176,23 +175,6 @@ function VideosPage({ token }: VideosPageProps) { setPage(1); }; - const formatFileSize = (bytes: string | null | undefined): string => { - if (!bytes) return ''; - const size = parseInt(bytes); - if (isNaN(size)) return ''; - - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let unitIndex = 0; - let formattedSize = size; - - while (formattedSize >= 1024 && unitIndex < units.length - 1) { - formattedSize /= 1024; - unitIndex++; - } - - return `${formattedSize.toFixed(1)} ${units[unitIndex]}`; - }; - const getMediaTypeInfo = (mediaType?: string) => { switch (mediaType) { case 'short': @@ -710,6 +692,14 @@ function VideosPage({ token }: VideosPageProps) { alignItems="center" sx={{ mt: 0.5 }} > + {!video.removed && (video.filePath || video.audioFilePath) && ( + + )} {(() => { const mediaTypeInfo = getMediaTypeInfo(video.media_type); return mediaTypeInfo ? ( @@ -723,18 +713,7 @@ function VideosPage({ token }: VideosPageProps) { /> ) : null; })()} - {video.fileSize && ( - - } - label={formatFileSize(video.fileSize)} - variant="outlined" - sx={{ height: 20, fontSize: '0.7rem' }} - /> - - )} - {video.removed ? ( + {!!video.removed && ( - ) : video.fileSize ? ( - - } - label="Available" - color="success" - variant="outlined" - sx={{ height: 20, fontSize: '0.7rem' }} - /> - - ) : null} + )} @@ -903,6 +871,14 @@ function VideosPage({ token }: VideosPageProps) { + {!video.removed && (video.filePath || video.audioFilePath) && ( + + )} {(() => { const mediaTypeInfo = getMediaTypeInfo(video.media_type); return mediaTypeInfo ? ( @@ -915,17 +891,7 @@ function VideosPage({ token }: VideosPageProps) { /> ) : null; })()} - {video.fileSize && ( - - } - label={formatFileSize(video.fileSize)} - variant="outlined" - /> - - )} - {video.removed ? ( + {!!video.removed && ( - ) : video.fileSize ? ( - - } - label="Available" - color="success" - variant="outlined" - /> - - ) : null} + )} diff --git a/client/src/components/__tests__/VideosPage.test.tsx b/client/src/components/__tests__/VideosPage.test.tsx index d06566ee..9b722c89 100644 --- a/client/src/components/__tests__/VideosPage.test.tsx +++ b/client/src/components/__tests__/VideosPage.test.tsx @@ -116,6 +116,9 @@ const mockPaginatedResponse = (videos: VideoData[], page = 1, limit = 12) => { }; }; +// Use delay: null to prevent timer-related flakiness when running with other tests +const setupUser = () => userEvent.setup({ delay: null }); + describe('VideosPage Component', () => { const mockToken = 'test-token'; const useMediaQuery = require('@mui/material/useMediaQuery'); @@ -196,7 +199,7 @@ describe('VideosPage Component', () => { }); test('filters videos by channel name', async () => { - const user = userEvent.setup(); + const user = setupUser(); // First call returns all videos axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); // Second call returns filtered videos @@ -223,7 +226,7 @@ describe('VideosPage Component', () => { }); test('resets filter when "All" is selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Initial load axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); // After filtering to Tech Channel @@ -257,7 +260,7 @@ describe('VideosPage Component', () => { }); test('sorts videos by published date', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Initial load axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); // After sort click @@ -279,7 +282,7 @@ describe('VideosPage Component', () => { }); test('sorts videos by added date', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Initial load axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); // After sort click @@ -301,7 +304,7 @@ describe('VideosPage Component', () => { }); test('handles pagination correctly', async () => { - const user = userEvent.setup(); + const user = setupUser(); const manyVideos = Array.from({ length: 15 }, (_, i) => ({ id: i, youtubeId: `video${i}`, @@ -452,7 +455,7 @@ describe('VideosPage Component', () => { }); test('handles mobile filter menu interaction', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); render(); @@ -472,7 +475,7 @@ describe('VideosPage Component', () => { describe('Search and File Status Features', () => { test('displays search bar and allows searching videos', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Initial load - all videos axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -508,7 +511,11 @@ describe('VideosPage Component', () => { }); test('displays file size information when available', async () => { - axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[0]]) }); + const videoWithFile = { + ...mockVideos[0], + filePath: '/path/to/video.mp4' + }; + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([videoWithFile]) }); render(); @@ -516,9 +523,9 @@ describe('VideosPage Component', () => { expect(screen.getByText('How to Code')).toBeInTheDocument(); }); - // Check file size display (1GB formatted) - expect(screen.getByText('1.0 GB')).toBeInTheDocument(); - expect(screen.getByText('Available')).toBeInTheDocument(); + // Check file size display in format indicator chip (1GB formatted) + expect(screen.getByText('1.0GB')).toBeInTheDocument(); + expect(screen.getByTestId('MovieOutlinedIcon')).toBeInTheDocument(); }); test('displays missing file status for removed videos', async () => { @@ -639,7 +646,7 @@ describe('VideosPage Component', () => { }); test('handles multiple sort operations', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Mock multiple API calls for sort operations axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -667,7 +674,7 @@ describe('VideosPage Component', () => { }); test('resets page to 1 when filter changes', async () => { - const user = userEvent.setup(); + const user = setupUser(); const manyVideos = Array.from({ length: 15 }, (_, i) => ({ id: i, youtubeId: `video${i}`, @@ -739,7 +746,7 @@ describe('VideosPage Component', () => { }); test('allows selecting and deselecting individual videos', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); render(); @@ -772,7 +779,7 @@ describe('VideosPage Component', () => { }); test('select all checkbox selects all non-removed videos', async () => { - const user = userEvent.setup(); + const user = setupUser(); const videosWithRemoved = [ ...mockVideos, { @@ -808,7 +815,7 @@ describe('VideosPage Component', () => { }); test('shows delete button when videos are selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); render(); @@ -831,7 +838,7 @@ describe('VideosPage Component', () => { }); test('clears selection when clear button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); render(); @@ -858,7 +865,7 @@ describe('VideosPage Component', () => { }); test('opens delete dialog when delete button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); render(); @@ -892,7 +899,7 @@ describe('VideosPage Component', () => { }); test('single video delete button opens dialog with one video', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); render(); @@ -957,7 +964,7 @@ describe('VideosPage Component', () => { }); test('toggles video selection when delete icon is clicked in mobile', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[0]]) }); render(); @@ -980,7 +987,7 @@ describe('VideosPage Component', () => { }); test('shows FAB with badge when videos are selected for deletion in mobile', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos.slice(0, 2)) }); render(); @@ -1002,7 +1009,7 @@ describe('VideosPage Component', () => { describe('Delete Confirmation and Execution', () => { test('successfully deletes videos and shows success message', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Initial load axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -1050,7 +1057,7 @@ describe('VideosPage Component', () => { }); test('handles partial deletion failure', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[1], mockVideos[2]]) }); @@ -1084,7 +1091,7 @@ describe('VideosPage Component', () => { }); test('handles complete deletion failure', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -1118,7 +1125,7 @@ describe('VideosPage Component', () => { }); test('cancels deletion when cancel button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -1146,7 +1153,7 @@ describe('VideosPage Component', () => { }); test('clears selection after successful deletion', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[2]]) }); @@ -1180,7 +1187,7 @@ describe('VideosPage Component', () => { }); test('disables delete button while deletion is in progress', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -1210,7 +1217,7 @@ describe('VideosPage Component', () => { describe('Snackbar Messages', () => { test('success snackbar can be dismissed', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[2]]) }); @@ -1240,7 +1247,7 @@ describe('VideosPage Component', () => { }); test('error snackbar shows when deletion fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); diff --git a/client/src/components/shared/DownloadFormatIndicator.tsx b/client/src/components/shared/DownloadFormatIndicator.tsx new file mode 100644 index 00000000..d91a0b77 --- /dev/null +++ b/client/src/components/shared/DownloadFormatIndicator.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Box, Chip, Tooltip } from '@mui/material'; +import { MovieOutlined as VideoIcon, AudiotrackOutlined as AudioIcon } from '@mui/icons-material'; +import { formatFileSize } from '../../utils/formatters'; + +interface DownloadFormatIndicatorProps { + filePath?: string | null; + audioFilePath?: string | null; + fileSize?: number | string | null; + audioFileSize?: number | string | null; +} + +// Strip internal Docker paths from display +const stripInternalPath = (path: string): string => { + const internalPrefixes = ['/usr/src/app/data/']; + for (const prefix of internalPrefixes) { + if (path.startsWith(prefix)) { + return path.slice(prefix.length); + } + } + return path; +}; + +const DownloadFormatIndicator: React.FC = ({ + filePath, + audioFilePath, + fileSize, + audioFileSize +}) => { + const hasVideo = !!filePath; + const hasAudio = !!audioFilePath; + + // Don't render anything if no files exist + if (!hasVideo && !hasAudio) { + return null; + } + + // Parse file sizes to numbers for formatting + const videoSizeNum = typeof fileSize === 'string' ? parseInt(fileSize, 10) : fileSize; + const audioSizeNum = typeof audioFileSize === 'string' ? parseInt(audioFileSize, 10) : audioFileSize; + + // Strip internal Docker paths for display + const displayVideoPath = filePath ? stripInternalPath(filePath) : ''; + const displayAudioPath = audioFilePath ? stripInternalPath(audioFilePath) : ''; + + // Format size labels + const videoSizeLabel = videoSizeNum ? formatFileSize(videoSizeNum) : 'Unknown'; + const audioSizeLabel = audioSizeNum ? formatFileSize(audioSizeNum) : 'Unknown'; + + return ( + + {hasVideo && ( + + } + label={videoSizeLabel} + variant="outlined" + sx={{ height: 20, fontSize: '0.7rem' }} + /> + + )} + {hasAudio && ( + + } + label={audioSizeLabel} + variant="outlined" + sx={{ height: 20, fontSize: '0.7rem' }} + /> + + )} + + ); +}; + +export default DownloadFormatIndicator; diff --git a/client/src/components/shared/__tests__/DownloadFormatIndicator.test.tsx b/client/src/components/shared/__tests__/DownloadFormatIndicator.test.tsx new file mode 100644 index 00000000..d259a423 --- /dev/null +++ b/client/src/components/shared/__tests__/DownloadFormatIndicator.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import DownloadFormatIndicator from '../DownloadFormatIndicator'; + +describe('DownloadFormatIndicator', () => { + describe('Rendering', () => { + test('returns null when no file paths are provided', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + test('returns null when file paths are null', () => { + const { container } = render( + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + test('renders video chip when filePath is provided', () => { + render(); + + expect(screen.getByText('100MB')).toBeInTheDocument(); + expect(screen.getByTestId('MovieOutlinedIcon')).toBeInTheDocument(); + }); + + test('renders audio chip when audioFilePath is provided', () => { + render(); + + expect(screen.getByText('50MB')).toBeInTheDocument(); + expect(screen.getByTestId('AudiotrackOutlinedIcon')).toBeInTheDocument(); + }); + + test('renders both chips when both paths are provided', () => { + render( + + ); + + expect(screen.getByText('1.0GB')).toBeInTheDocument(); + expect(screen.getByText('50MB')).toBeInTheDocument(); + }); + + test('shows "Unknown" when file size is not provided', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + test('handles file size as string', () => { + render(); + + expect(screen.getByText('100MB')).toBeInTheDocument(); + }); + }); + + describe('Path stripping', () => { + test('strips Docker internal path prefix from video path in tooltip', async () => { + const user = userEvent.setup(); + render( + + ); + + const chip = screen.getByText('100MB'); + await user.hover(chip); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('channel/video.mp4'); + expect(tooltip).not.toHaveTextContent('/usr/src/app/data/'); + }); + + test('preserves non-Docker paths in tooltip', async () => { + const user = userEvent.setup(); + render( + + ); + + const chip = screen.getByText('100MB'); + await user.hover(chip); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('/custom/path/video.mp4'); + }); + }); +}); diff --git a/client/src/hooks/useTriggerDownloads.ts b/client/src/hooks/useTriggerDownloads.ts index 5b9fbaa3..733c634b 100644 --- a/client/src/hooks/useTriggerDownloads.ts +++ b/client/src/hooks/useTriggerDownloads.ts @@ -4,6 +4,7 @@ interface DownloadOverrideSettings { resolution: string; allowRedownload?: boolean; subfolder?: string | null; + audioFormat?: string | null; } interface TriggerDownloadsParams { @@ -40,6 +41,7 @@ export function useTriggerDownloads(token: string | null): UseTriggerDownloadsRe resolution: overrideSettings.resolution, allowRedownload: overrideSettings.allowRedownload, subfolder: overrideSettings.subfolder, + audioFormat: overrideSettings.audioFormat, }; } diff --git a/client/src/types/Channel.ts b/client/src/types/Channel.ts index 5f8ff966..7d8d88d2 100644 --- a/client/src/types/Channel.ts +++ b/client/src/types/Channel.ts @@ -11,4 +11,5 @@ export interface Channel { min_duration?: number | null; max_duration?: number | null; title_filter_regex?: string | null; + audio_format?: string | null; } diff --git a/client/src/types/ChannelVideo.ts b/client/src/types/ChannelVideo.ts index e2ab855e..e290acc0 100644 --- a/client/src/types/ChannelVideo.ts +++ b/client/src/types/ChannelVideo.ts @@ -17,6 +17,9 @@ export interface ChannelVideo { duration: number; availability?: string | null; fileSize?: number | null; + filePath?: string | null; + audioFilePath?: string | null; + audioFileSize?: number | null; media_type?: string | null; live_status?: string | null; ignored?: boolean; diff --git a/client/src/types/VideoData.ts b/client/src/types/VideoData.ts index a4e3d629..62c3df0a 100644 --- a/client/src/types/VideoData.ts +++ b/client/src/types/VideoData.ts @@ -16,6 +16,8 @@ export interface VideoData { description: string | null; filePath?: string | null; fileSize?: string | null; + audioFilePath?: string | null; + audioFileSize?: string | null; removed?: boolean; youtube_removed?: boolean; channel_id?: string | null; diff --git a/migrations/20260111170602-add-audio-format-to-channels.js b/migrations/20260111170602-add-audio-format-to-channels.js new file mode 100644 index 00000000..db99e1d4 --- /dev/null +++ b/migrations/20260111170602-add-audio-format-to-channels.js @@ -0,0 +1,20 @@ +'use strict'; + +const { addColumnIfMissing, removeColumnIfExists } = require('./helpers'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add audio_format column to support per-channel audio format setting + // Values: null (video only - default), 'video_mp3' (video + mp3), 'mp3_only' (audio only) + await addColumnIfMissing(queryInterface, 'channels', 'audio_format', { + type: Sequelize.STRING(20), + allowNull: true, + defaultValue: null + }); + }, + + async down(queryInterface, Sequelize) { + await removeColumnIfExists(queryInterface, 'channels', 'audio_format'); + } +}; diff --git a/migrations/20260112013359-add-audio-file-path-to-videos.js b/migrations/20260112013359-add-audio-file-path-to-videos.js new file mode 100644 index 00000000..858a7f25 --- /dev/null +++ b/migrations/20260112013359-add-audio-file-path-to-videos.js @@ -0,0 +1,27 @@ +'use strict'; + +const { addColumnIfMissing, removeColumnIfExists } = require('./helpers'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add audioFilePath column to track MP3 file location separately from video file + await addColumnIfMissing(queryInterface, 'Videos', 'audioFilePath', { + type: Sequelize.STRING(500), + allowNull: true, + defaultValue: null + }); + + // Add audioFileSize column to track MP3 file size + await addColumnIfMissing(queryInterface, 'Videos', 'audioFileSize', { + type: Sequelize.BIGINT, + allowNull: true, + defaultValue: null + }); + }, + + async down(queryInterface, Sequelize) { + await removeColumnIfExists(queryInterface, 'Videos', 'audioFilePath'); + await removeColumnIfExists(queryInterface, 'Videos', 'audioFileSize'); + } +}; 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/models/channel.js b/server/models/channel.js index d1fbfcca..b39cb480 100644 --- a/server/models/channel.js +++ b/server/models/channel.js @@ -81,6 +81,11 @@ Channel.init( allowNull: true, defaultValue: null, }, + audio_format: { + type: DataTypes.STRING(20), + allowNull: true, + defaultValue: null, + }, }, { sequelize, diff --git a/server/models/video.js b/server/models/video.js index eacbb11f..3f51fe08 100644 --- a/server/models/video.js +++ b/server/models/video.js @@ -48,6 +48,14 @@ Video.init( type: DataTypes.BIGINT, allowNull: true, }, + audioFilePath: { + type: DataTypes.STRING(500), + allowNull: true, + }, + audioFileSize: { + type: DataTypes.BIGINT, + allowNull: true, + }, removed: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/server/modules/__tests__/channelDownloadGrouper.test.js b/server/modules/__tests__/channelDownloadGrouper.test.js index 9cbde429..04e80d5f 100644 --- a/server/modules/__tests__/channelDownloadGrouper.test.js +++ b/server/modules/__tests__/channelDownloadGrouper.test.js @@ -61,14 +61,14 @@ describe('ChannelDownloadGrouper', () => { const filterConfig = new ChannelFilterConfig(300, 3600, 'test.*regex'); const key = filterConfig.buildFilterKey(); - expect(key).toBe('{"min":300,"max":3600,"regex":"test.*regex"}'); + expect(key).toBe('{"min":300,"max":3600,"regex":"test.*regex","audio":null}'); }); it('should build unique key with null values', () => { const filterConfig = new ChannelFilterConfig(null, null, null); const key = filterConfig.buildFilterKey(); - expect(key).toBe('{"min":null,"max":null,"regex":null}'); + expect(key).toBe('{"min":null,"max":null,"regex":null,"audio":null}'); }); it('should build different keys for different filters', () => { @@ -174,7 +174,8 @@ describe('ChannelDownloadGrouper', () => { auto_download_enabled_tabs: 'all', min_duration: null, max_duration: null, - title_filter_regex: null + title_filter_regex: null, + audio_format: null } ]; @@ -192,7 +193,8 @@ describe('ChannelDownloadGrouper', () => { 'auto_download_enabled_tabs', 'min_duration', 'max_duration', - 'title_filter_regex' + 'title_filter_regex', + 'audio_format' ] }); expect(result).toEqual(mockChannels); diff --git a/server/modules/__tests__/channelModule.test.js b/server/modules/__tests__/channelModule.test.js index c6b3de7b..6a8ce148 100644 --- a/server/modules/__tests__/channelModule.test.js +++ b/server/modules/__tests__/channelModule.test.js @@ -439,6 +439,7 @@ describe('ChannelModule', () => { available_tabs: null, sub_folder: null, video_quality: null, + audio_format: null, min_duration: null, max_duration: null, title_filter_regex: null, @@ -788,7 +789,7 @@ describe('ChannelModule', () => { where: { youtubeId: ['video1', 'video2', 'video3'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath'] + attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize'] }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -817,7 +818,7 @@ describe('ChannelModule', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath'] + attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize'] }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -874,7 +875,7 @@ describe('ChannelModule', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath'] + attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize'] }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -903,7 +904,7 @@ describe('ChannelModule', () => { where: { youtubeId: ['video1', 'video2', 'video3'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath'] + attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize'] }); // Video1 - not downloaded @@ -931,7 +932,7 @@ describe('ChannelModule', () => { where: { youtubeId: [] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath'] + attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize'] }); expect(result).toEqual([]); }); @@ -1037,7 +1038,7 @@ describe('ChannelModule', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath'] + attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize'] }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -1597,6 +1598,7 @@ describe('ChannelModule', () => { min_duration: null, max_duration: null, title_filter_regex: null, + audio_format: null, }, { url: 'https://youtube.com/@channel2', @@ -1609,6 +1611,7 @@ describe('ChannelModule', () => { min_duration: null, max_duration: null, title_filter_regex: null, + audio_format: null, } ]); }); @@ -1694,6 +1697,7 @@ describe('ChannelModule', () => { min_duration: null, max_duration: null, title_filter_regex: null, + audio_format: null, } ], total: 25, @@ -2198,10 +2202,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 +2214,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 +2284,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__/downloadModule.test.js b/server/modules/__tests__/downloadModule.test.js index 637b25de..03cbb187 100644 --- a/server/modules/__tests__/downloadModule.test.js +++ b/server/modules/__tests__/downloadModule.test.js @@ -753,7 +753,7 @@ describe('DownloadModule', () => { await downloadModule.executeGroupDownload(group, mockJobId, 'Channel Downloads - Group 1/1 (480p, lowres)', jobData, true); // Subfolder should NOT be passed to download - post-processing handles subfolder routing - expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('480', false, null, undefined); + expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('480', false, null, undefined, null); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--playlist-end', '5' @@ -783,7 +783,7 @@ describe('DownloadModule', () => { await downloadModule.executeGroupDownload(group, mockJobId, 'Channel Downloads - Group 1/1 (1080p)', jobData, true); // Subfolder should NOT be passed - post-processing handles it - expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('1080', true, null, undefined); + expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('1080', true, null, undefined, null); }); it('should pass filterConfig to YtdlpCommandBuilder when group has filters', async () => { @@ -807,8 +807,8 @@ describe('DownloadModule', () => { await downloadModule.executeGroupDownload(group, mockJobId, 'Channel Downloads - Group 1/1 (1080p)', {}, true); - // Verify filterConfig is passed as the 4th parameter - expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('1080', false, null, mockFilterConfig); + // Verify filterConfig is passed as the 4th parameter, audioFormat is null when not in filterConfig + expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('1080', false, null, mockFilterConfig, null); }); it('should pass skipJobTransition flag to doDownload', async () => { @@ -885,7 +885,7 @@ describe('DownloadModule', () => { }), false ); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false, null); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--format', 'best[height<=1080]', @@ -974,7 +974,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('480', false); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('480', false, null); }); it('should respect channel-level quality override when present', async () => { @@ -992,9 +992,63 @@ describe('DownloadModule', () => { expect(ChannelModelMock.findOne).toHaveBeenCalledWith({ where: { channel_id: 'UC123456' }, - attributes: ['video_quality'] + attributes: ['video_quality', 'audio_format'] }); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, null); + }); + + it('should respect channel-level audio_format when no override provided', async () => { + jobModuleMock.getJob.mockReturnValue({ status: 'In Progress' }); + ChannelModelMock.findOne.mockResolvedValue({ video_quality: '720', audio_format: 'mp3_only' }); + + const request = { + body: { + urls: ['https://youtube.com/watch?v=test'], + channelId: 'UC123456' + } + }; + + await downloadModule.doSpecificDownloads(request); + + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, 'mp3_only'); + }); + + it('should prioritize override audioFormat over channel audio_format', async () => { + jobModuleMock.getJob.mockReturnValue({ status: 'In Progress' }); + ChannelModelMock.findOne.mockResolvedValue({ video_quality: '720', audio_format: 'mp3_only' }); + + const request = { + body: { + urls: ['https://youtube.com/watch?v=test'], + channelId: 'UC123456', + overrideSettings: { + audioFormat: 'video_mp3' + } + } + }; + + await downloadModule.doSpecificDownloads(request); + + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, 'video_mp3'); + }); + + it('should allow null audioFormat override to bypass channel mp3_only setting', async () => { + jobModuleMock.getJob.mockReturnValue({ status: 'In Progress' }); + ChannelModelMock.findOne.mockResolvedValue({ video_quality: '720', audio_format: 'mp3_only' }); + + const request = { + body: { + urls: ['https://youtube.com/watch?v=test'], + channelId: 'UC123456', + overrideSettings: { + audioFormat: null // Explicitly override to "Video Only" + } + } + }; + + await downloadModule.doSpecificDownloads(request); + + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, null); }); it('should handle allowRedownload override setting', async () => { @@ -1011,7 +1065,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', true); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', true, null); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--format', 'best[height<=720]', @@ -1046,7 +1100,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('480', false); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('480', false, null); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--format', 'best[height<=480]', @@ -1077,7 +1131,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false, null); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--download-archive', './config/complete.list', diff --git a/server/modules/__tests__/fileCheckModule.test.js b/server/modules/__tests__/fileCheckModule.test.js index 82d7361c..2a6577fc 100644 --- a/server/modules/__tests__/fileCheckModule.test.js +++ b/server/modules/__tests__/fileCheckModule.test.js @@ -104,7 +104,7 @@ describe('FileCheckModule', () => { } ]); expect(result.updates).toEqual([ - { id: 1, fileSize: 1000, removed: false } + { id: 1, removed: false } ]); expect(mockFs.stat).toHaveBeenCalledWith('/videos/channel/video.mp4'); }); @@ -134,7 +134,7 @@ describe('FileCheckModule', () => { } ]); expect(result.updates).toEqual([ - { id: 1, fileSize: 2000, removed: false } + { id: 1, fileSize: 2000 } ]); expect(mockFs.stat).toHaveBeenCalledWith('/videos/channel/video.mp4'); }); @@ -226,8 +226,8 @@ describe('FileCheckModule', () => { ]); expect(result.updates).toEqual([ { id: 1, removed: true }, - { id: 2, fileSize: 2000, removed: false }, - { id: 4, fileSize: 4000, removed: false } + { id: 2, removed: false }, + { id: 4, fileSize: 4000 } ]); expect(mockFs.stat).toHaveBeenCalledTimes(3); }); @@ -296,7 +296,7 @@ describe('FileCheckModule', () => { expect(result.videos[0].fileSize).toBe('2000'); expect(result.updates).toEqual([ - { id: 1, fileSize: 2000, removed: false } + { id: 1, fileSize: 2000 } ]); }); @@ -316,7 +316,7 @@ describe('FileCheckModule', () => { expect(result.videos[0].fileSize).toBe('1000'); expect(result.updates).toEqual([ - { id: 1, fileSize: 1000, removed: false } + { id: 1, fileSize: 1000 } ]); }); @@ -338,7 +338,7 @@ describe('FileCheckModule', () => { expect(result.videos[0].fileSize).toBe(largeSize.toString()); expect(result.updates).toEqual([ - { id: 1, fileSize: largeSize, removed: false } + { id: 1, fileSize: largeSize } ]); }); }); diff --git a/server/modules/__tests__/videosModule.test.js b/server/modules/__tests__/videosModule.test.js index da2f6209..b7322cd0 100644 --- a/server/modules/__tests__/videosModule.test.js +++ b/server/modules/__tests__/videosModule.test.js @@ -557,12 +557,16 @@ describe('VideosModule', () => { expect(fileMap.size).toBe(2); expect(fileMap.get('root123')).toEqual({ - filePath: '/test/dir/video [root123].mp4', - fileSize: 1000 + videoFilePath: '/test/dir/video [root123].mp4', + videoFileSize: 1000, + audioFilePath: null, + audioFileSize: null }); expect(fileMap.get('channel1_123')).toEqual({ - filePath: '/test/dir/Channel1/video [channel1_123].mp4', - fileSize: 2000 + videoFilePath: '/test/dir/Channel1/video [channel1_123].mp4', + videoFileSize: 2000, + audioFilePath: null, + audioFileSize: null }); expect(duplicates.size).toBe(0); }); @@ -580,9 +584,9 @@ describe('VideosModule', () => { const { fileMap, duplicates } = await VideosModule.scanForVideoFiles('/test'); expect(fileMap.size).toBe(1); - expect(fileMap.get('abc123').fileSize).toBe(2000); + expect(fileMap.get('abc123').videoFileSize).toBe(2000); expect(duplicates.size).toBe(1); - expect(duplicates.get('abc123')).toHaveLength(2); + expect(duplicates.get('abc123')).toHaveLength(1); // Only one duplicate path tracked (the smaller one) }); test('should ignore non-mp4 files', async () => { @@ -716,7 +720,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/channelDownloadGrouper.js b/server/modules/channelDownloadGrouper.js index 89763958..7ff6616b 100644 --- a/server/modules/channelDownloadGrouper.js +++ b/server/modules/channelDownloadGrouper.js @@ -7,10 +7,11 @@ const { buildOutputTemplate, buildThumbnailTemplate } = require('./filesystem'); * Encapsulates channel filter settings for download filtering */ class ChannelFilterConfig { - constructor(minDuration = null, maxDuration = null, titleFilterRegex = null) { + constructor(minDuration = null, maxDuration = null, titleFilterRegex = null, audioFormat = null) { this.minDuration = minDuration; this.maxDuration = maxDuration; this.titleFilterRegex = titleFilterRegex; + this.audioFormat = audioFormat; } /** @@ -23,7 +24,8 @@ class ChannelFilterConfig { return JSON.stringify({ min: this.minDuration, max: this.maxDuration, - regex: this.titleFilterRegex + regex: this.titleFilterRegex, + audio: this.audioFormat }); } @@ -32,7 +34,10 @@ class ChannelFilterConfig { * @returns {boolean} - True if at least one filter is configured */ hasFilters() { - return this.minDuration !== null || this.maxDuration !== null || this.titleFilterRegex !== null; + return this.minDuration !== null || + this.maxDuration !== null || + this.titleFilterRegex !== null || + this.audioFormat !== null; } /** @@ -44,7 +49,8 @@ class ChannelFilterConfig { return new ChannelFilterConfig( channel.min_duration, channel.max_duration, - channel.title_filter_regex + channel.title_filter_regex, + channel.audio_format ); } } @@ -69,7 +75,8 @@ class ChannelDownloadGrouper { 'auto_download_enabled_tabs', 'min_duration', 'max_duration', - 'title_filter_regex' + 'title_filter_regex', + 'audio_format' ] }); diff --git a/server/modules/channelModule.js b/server/modules/channelModule.js index d96c14bd..5108d57a 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 @@ -254,6 +295,7 @@ class ChannelModule { available_tabs: channel.available_tabs || null, sub_folder: channel.sub_folder || null, video_quality: channel.video_quality || null, + audio_format: channel.audio_format || null, min_duration: channel.min_duration || null, max_duration: channel.max_duration || null, title_filter_regex: channel.title_filter_regex || null, @@ -277,6 +319,7 @@ class ChannelModule { min_duration: channel.min_duration || null, max_duration: channel.max_duration || null, title_filter_regex: channel.title_filter_regex || null, + audio_format: channel.audio_format || null, }; } @@ -1203,7 +1246,7 @@ class ChannelModule { where: { youtubeId: youtubeIds }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath'] + attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize'] }); // Create Maps for O(1) lookup of download status @@ -1216,11 +1259,13 @@ class ChannelModule { added: true, removed: v.removed, fileSize: v.fileSize, - filePath: v.filePath + filePath: v.filePath, + audioFilePath: v.audioFilePath, + audioFileSize: v.audioFileSize }); - // Collect videos that need file checking (only if checkFiles is true) - if (checkFiles && v.filePath) { + // Collect videos that need file checking (only if checkFiles is true and have any file path) + if (checkFiles && (v.filePath || v.audioFilePath)) { videosToCheck.push(v); } }); @@ -1235,6 +1280,7 @@ class ChannelModule { if (status) { status.removed = v.removed; status.fileSize = v.fileSize; + status.audioFileSize = v.audioFileSize; } }); @@ -1254,11 +1300,17 @@ class ChannelModule { plainVideoObject.added = true; plainVideoObject.removed = status.removed; plainVideoObject.fileSize = status.fileSize; + plainVideoObject.filePath = status.filePath; + plainVideoObject.audioFilePath = status.audioFilePath; + plainVideoObject.audioFileSize = status.audioFileSize; } else { // Video never downloaded plainVideoObject.added = false; plainVideoObject.removed = false; plainVideoObject.fileSize = null; + plainVideoObject.filePath = null; + plainVideoObject.audioFilePath = null; + plainVideoObject.audioFileSize = null; } // Replace thumbnail with template format (unless video is removed from YouTube) @@ -1270,6 +1322,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 +1373,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 +1407,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 +1477,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 +1512,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 +1965,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 +2005,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 +2024,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 +2101,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 +2168,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 +2197,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 +2266,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..4f1b5a12 100644 --- a/server/modules/channelSettingsModule.js +++ b/server/modules/channelSettingsModule.js @@ -218,6 +218,28 @@ class ChannelSettingsModule { return { valid: true }; } + /** + * Validate audio format setting + * @param {string|null} audioFormat - Audio format setting to validate + * @returns {Object} - { valid: boolean, error?: string } + */ + validateAudioFormat(audioFormat) { + // NULL is valid (video only - default) + if (audioFormat === null || audioFormat === undefined) { + return { valid: true }; + } + + const validFormats = ['video_mp3', 'mp3_only']; + if (!validFormats.includes(audioFormat)) { + return { + valid: false, + error: 'Invalid audio format. Valid values: video_mp3, mp3_only, or null for video only', + }; + } + + return { valid: true }; + } + /** * Get the full directory path for a channel, including subfolder if set * @param {Object} channel - Channel database record @@ -347,13 +369,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 @@ -440,6 +462,7 @@ class ChannelSettingsModule { min_duration: channel.min_duration, max_duration: channel.max_duration, title_filter_regex: channel.title_filter_regex, + audio_format: channel.audio_format, }; } @@ -512,6 +535,14 @@ class ChannelSettingsModule { } } + // Validate audio format if provided + if (settings.audio_format !== undefined) { + const validation = this.validateAudioFormat(settings.audio_format); + if (!validation.valid) { + throw new Error(validation.error); + } + } + // Store old subfolder for potential move const oldSubFolder = channel.sub_folder; const newSubFolder = settings.sub_folder !== undefined ? @@ -540,6 +571,9 @@ class ChannelSettingsModule { ? settings.title_filter_regex.trim() : null; } + if (settings.audio_format !== undefined) { + updateData.audio_format = settings.audio_format; + } // Update database FIRST to ensure changes are persisted before slow file operations // This prevents issues where HTTP requests timeout during file operations @@ -584,6 +618,7 @@ class ChannelSettingsModule { min_duration: updatedChannel.min_duration, max_duration: updatedChannel.max_duration, title_filter_regex: updatedChannel.title_filter_regex, + audio_format: updatedChannel.audio_format, }, folderMoved: subFolderChanged, moveResult diff --git a/server/modules/download/DownloadProgressMonitor.js b/server/modules/download/DownloadProgressMonitor.js index 42f1f1de..155639e1 100644 --- a/server/modules/download/DownloadProgressMonitor.js +++ b/server/modules/download/DownloadProgressMonitor.js @@ -229,7 +229,7 @@ class DownloadProgressMonitor { const stripExtension = (name) => { if (!name) return ''; let result = name; - result = result.replace(/\.f\d+\.[^.]+$/, ''); + result = result.replace(/\.f[\d-]+\.[^.]+$/, ''); result = result.replace(/\.[^.]+$/, ''); return result; }; @@ -291,8 +291,8 @@ class DownloadProgressMonitor { const path = line.split('Destination:')[1].trim(); // Detect subtitle file downloads if (path.match(/\.(vtt|srt)$/i)) return 'downloading_subtitles'; - if (path.match(/\.f\d+\.mp4$/)) return 'downloading_video'; - if (path.match(/\.f\d+\.m4a$/)) return 'downloading_audio'; + if (path.match(/\.f[\d-]+\.mp4$/)) return 'downloading_video'; + if (path.match(/\.f[\d-]+\.m4a$/)) return 'downloading_audio'; if (path.includes('poster') || path.includes('thumbnail')) return 'downloading_thumbnail'; } diff --git a/server/modules/download/__tests__/downloadExecutor.test.js b/server/modules/download/__tests__/downloadExecutor.test.js index a5008742..0c8906b4 100644 --- a/server/modules/download/__tests__/downloadExecutor.test.js +++ b/server/modules/download/__tests__/downloadExecutor.test.js @@ -1043,9 +1043,9 @@ describe('DownloadExecutor', () => { expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ youtubeId: 'failed456', - error: 'Video file not found or incomplete' + error: 'Media file not found or incomplete' }), - 'Video download failed' + 'Download failed' ); // Job data should include failedVideos @@ -1061,7 +1061,7 @@ describe('DownloadExecutor', () => { youtubeId: 'failed456', title: 'Failed Video', channel: 'Test Channel', - error: 'Video file not found or incomplete' + error: 'Media file not found or incomplete' }) ]) }) diff --git a/server/modules/download/__tests__/videoMetadataProcessor.test.js b/server/modules/download/__tests__/videoMetadataProcessor.test.js index f1da4dc9..d8a9939a 100644 --- a/server/modules/download/__tests__/videoMetadataProcessor.test.js +++ b/server/modules/download/__tests__/videoMetadataProcessor.test.js @@ -137,6 +137,8 @@ describe('VideoMetadataProcessor', () => { channel_id: 'UC123456', filePath: '/output/directory/Test Channel/Test Channel - Test Video Title - abc123/Test Channel - Test Video Title [abc123].mp4', fileSize: '1024000', + audioFilePath: null, + audioFileSize: null, media_type: 'video', removed: false }); @@ -218,7 +220,7 @@ describe('VideoMetadataProcessor', () => { expect(result[0].fileSize).toBeNull(); expect(logger.warn).toHaveBeenCalledWith( { videoId: 'missingpath' }, - 'Video file path could not be determined from metadata or filesystem search' + 'No file paths could be determined from metadata or filesystem search' ); }); @@ -384,6 +386,8 @@ describe('VideoMetadataProcessor', () => { channel_id: undefined, filePath: '/output/directory/Unknown Channel/Unknown Channel - Incomplete Video - incomplete123/Unknown Channel - Incomplete Video [incomplete123].mp4', fileSize: null, + audioFilePath: null, + audioFileSize: null, media_type: 'video', removed: false }); diff --git a/server/modules/download/downloadExecutor.js b/server/modules/download/downloadExecutor.js index 9b8d8023..7b5017bc 100644 --- a/server/modules/download/downloadExecutor.js +++ b/server/modules/download/downloadExecutor.js @@ -808,14 +808,19 @@ class DownloadExecutor { // Check if this video was explicitly marked as failed during download const wasMarkedFailed = failedVideos.has(video.youtubeId); - // Check if video file actually exists and has size + // Check if video or audio file actually exists and has size + // For video_mp3 mode: both fileSize and audioFileSize will be set + // For mp3_only mode: only audioFileSize will be set + // For standard video: only fileSize will be set const hasVideoFile = video.fileSize && video.fileSize !== 'null' && video.fileSize !== '0'; + const hasAudioFile = video.audioFileSize && video.audioFileSize !== 'null' && video.audioFileSize !== '0'; + const hasAnyFile = hasVideoFile || hasAudioFile; - if (wasMarkedFailed || !hasVideoFile) { + if (wasMarkedFailed || !hasAnyFile) { // This video failed const failureInfo = failedVideos.get(video.youtubeId) || { youtubeId: video.youtubeId, - error: hasVideoFile ? 'Unknown error' : 'Video file not found or incomplete', + error: hasAnyFile ? 'Unknown error' : 'Media file not found or incomplete', url: urlsToProcess.find(u => u.includes(video.youtubeId)) }; @@ -830,8 +835,9 @@ class DownloadExecutor { logger.warn({ youtubeId: video.youtubeId, error: failureInfo.error, - hasVideoFile - }, 'Video download failed'); + hasVideoFile, + hasAudioFile + }, 'Download failed'); } else { // This video succeeded successfulVideos.push(video); @@ -861,10 +867,12 @@ class DownloadExecutor { logger.debug({ videoCount: videoData.length }, 'Updating archive for videos (allowRedownload was true)'); for (const video of videoData) { - if (video.youtubeId && video.filePath) { - // Only add to archive if the video file actually exists (was successfully downloaded) + // Check for either video file or audio file (supports mp3_only mode) + const fileToCheck = video.filePath || video.audioFilePath; + if (video.youtubeId && fileToCheck) { + // Only add to archive if the file actually exists (was successfully downloaded) const fs = require('fs'); - if (fs.existsSync(video.filePath)) { + if (fs.existsSync(fileToCheck)) { await archiveModule.addVideoToArchive(video.youtubeId); } else { logger.debug({ youtubeId: video.youtubeId }, 'Skipping archive update - file not found'); diff --git a/server/modules/download/videoMetadataProcessor.js b/server/modules/download/videoMetadataProcessor.js index 7395e5d9..1133e5c3 100644 --- a/server/modules/download/videoMetadataProcessor.js +++ b/server/modules/download/videoMetadataProcessor.js @@ -96,42 +96,51 @@ class VideoMetadataProcessor { return null; } - const targetSuffix = `[${videoId}].mp4`; - const visited = new Set(); + // Search for both .mp4 and .mp3 files (video and audio-only downloads) + const suffixesToTry = [`[${videoId}].mp4`, `[${videoId}].mp3`]; - if (preferredChannelName) { - const normalizedTarget = this.normalizeForDirectoryMatch(preferredChannelName); + for (const targetSuffix of suffixesToTry) { + const visited = new Set(); - try { - const channelDirs = await fsPromises.readdir(baseDir, { withFileTypes: true }); + if (preferredChannelName) { + const normalizedTarget = this.normalizeForDirectoryMatch(preferredChannelName); - for (const dirent of channelDirs) { - if (!dirent.isDirectory()) { - continue; - } + try { + const channelDirs = await fsPromises.readdir(baseDir, { withFileTypes: true }); + + for (const dirent of channelDirs) { + if (!dirent.isDirectory()) { + continue; + } - const dirPath = path.join(baseDir, dirent.name); - const normalizedDir = this.normalizeForDirectoryMatch(dirent.name); - - if ( - normalizedDir && - normalizedTarget && - (normalizedDir === normalizedTarget || - normalizedDir.includes(normalizedTarget) || - normalizedTarget.includes(normalizedDir)) - ) { - const locatedPath = await this.searchForVideoFile(dirPath, targetSuffix, visited); - if (locatedPath) { - return locatedPath; + const dirPath = path.join(baseDir, dirent.name); + const normalizedDir = this.normalizeForDirectoryMatch(dirent.name); + + if ( + normalizedDir && + normalizedTarget && + (normalizedDir === normalizedTarget || + normalizedDir.includes(normalizedTarget) || + normalizedTarget.includes(normalizedDir)) + ) { + const locatedPath = await this.searchForVideoFile(dirPath, targetSuffix, visited); + if (locatedPath) { + return locatedPath; + } } } + } catch (err) { + logger.warn({ err, baseDir }, 'Error scanning channel directories while locating video file'); } - } catch (err) { - logger.warn({ err, baseDir }, 'Error scanning channel directories while locating video file'); + } + + const result = await this.searchForVideoFile(baseDir, targetSuffix, visited); + if (result) { + return result; } } - return this.searchForVideoFile(baseDir, targetSuffix, visited); + return null; } static async processVideoMetadata(newVideoUrls) { @@ -165,41 +174,72 @@ class VideoMetadataProcessor { originalDate: data.upload_date, channel_id: data.channel_id, media_type: data.media_type || 'video', + filePath: null, + fileSize: null, + audioFilePath: null, + audioFileSize: null, + removed: false, }; - let fullPath = data._actual_filepath; - - if (!fullPath) { - fullPath = await this.findVideoFilePath(data.id, preferredChannelName); + // Get file paths from JSON (supports dual-format downloads) + let videoFilePath = data._actual_video_filepath || null; + let audioFilePath = data._actual_audio_filepath || null; + + // Fallback: if only _actual_filepath exists (old format or single-file download) + if (!videoFilePath && !audioFilePath && data._actual_filepath) { + const ext = path.extname(data._actual_filepath).toLowerCase(); + if (ext === '.mp3') { + audioFilePath = data._actual_filepath; + } else { + videoFilePath = data._actual_filepath; + } + } - if (fullPath) { - logger.info({ filepath: fullPath, videoId: id }, 'Located video file path via filesystem search'); + // If no paths found in JSON, try filesystem search + if (!videoFilePath && !audioFilePath) { + const foundPath = await this.findVideoFilePath(data.id, preferredChannelName); + if (foundPath) { + logger.info({ filepath: foundPath, videoId: id }, 'Located file path via filesystem search'); + const ext = path.extname(foundPath).toLowerCase(); + if (ext === '.mp3') { + audioFilePath = foundPath; + } else { + videoFilePath = foundPath; + } } } - if (!fullPath) { - logger.warn({ videoId: id }, 'Video file path could not be determined from metadata or filesystem search'); - videoMetadata.filePath = null; - videoMetadata.fileSize = null; - videoMetadata.removed = false; + // If still no paths, log warning and continue + if (!videoFilePath && !audioFilePath) { + logger.warn({ videoId: id }, 'No file paths could be determined from metadata or filesystem search'); processedVideos.push(videoMetadata); continue; } - // Check if file exists and get file size - with retry logic for production environments - let stats = await this.waitForFile(fullPath); - - if (stats) { - videoMetadata.filePath = fullPath; - videoMetadata.fileSize = stats.size.toString(); // Convert to string as DB expects BIGINT as string - videoMetadata.removed = false; - logger.info({ filepath: fullPath, fileSize: stats.size, videoId: id }, 'Found video file'); - } else { - // File doesn't exist after retries - logger.warn({ videoId: id, expectedPath: fullPath }, 'Video file not found after all retries'); - videoMetadata.filePath = fullPath; // Store expected path anyway - videoMetadata.fileSize = null; - videoMetadata.removed = false; // Assume it's not removed, just not found yet + // Get video file info + if (videoFilePath) { + const videoStats = await this.waitForFile(videoFilePath); + if (videoStats) { + videoMetadata.filePath = videoFilePath; + videoMetadata.fileSize = videoStats.size.toString(); + logger.info({ filepath: videoFilePath, fileSize: videoStats.size, videoId: id }, 'Found video file'); + } else { + videoMetadata.filePath = videoFilePath; // Store expected path anyway + logger.warn({ videoId: id, expectedPath: videoFilePath }, 'Video file not found after retries'); + } + } + + // Get audio file info + if (audioFilePath) { + const audioStats = await this.waitForFile(audioFilePath); + if (audioStats) { + videoMetadata.audioFilePath = audioFilePath; + videoMetadata.audioFileSize = audioStats.size.toString(); + logger.info({ filepath: audioFilePath, fileSize: audioStats.size, videoId: id }, 'Found audio file'); + } else { + videoMetadata.audioFilePath = audioFilePath; // Store expected path anyway + logger.warn({ videoId: id, expectedPath: audioFilePath }, 'Audio file not found after retries'); + } } processedVideos.push(videoMetadata); diff --git a/server/modules/download/ytdlpCommandBuilder.js b/server/modules/download/ytdlpCommandBuilder.js index 7d4c9f15..9b8a17af 100644 --- a/server/modules/download/ytdlpCommandBuilder.js +++ b/server/modules/download/ytdlpCommandBuilder.js @@ -44,12 +44,23 @@ class YtdlpCommandBuilder { return path.join(baseOutputPath, CHANNEL_TEMPLATE, VIDEO_FOLDER_TEMPLATE, thumbnailFilename); } } - // Build format string based on resolution and codec preference - static buildFormatString(resolution, videoCodec = 'default') { + /** + * Build format string based on resolution, codec preference, and audio format + * @param {string} resolution - Video resolution (e.g., '1080', '720') + * @param {string} videoCodec - Video codec preference ('h264', 'h265', 'default') + * @param {string|null} audioFormat - Audio format ('mp3_only' for audio-only, null for video) + * @returns {string} - Format string for yt-dlp -f argument + */ + static buildFormatString(resolution, videoCodec = 'default', audioFormat = null) { + // For MP3-only mode, we just need best audio + if (audioFormat === 'mp3_only') { + return 'bestaudio[ext=m4a]/bestaudio/best'; + } + const res = resolution || '1080'; // Base format components - const audioFormat = 'bestaudio[ext=m4a]'; + const audioFmt = 'bestaudio[ext=m4a]'; const fallbackMp4 = 'best[ext=mp4]'; const ultimateFallback = 'best'; @@ -58,18 +69,18 @@ class YtdlpCommandBuilder { switch (videoCodec) { case 'h264': // Prefer H.264/AVC codec, fallback to any codec at preferred resolution, then fallback to best - videoFormat = `bestvideo[height<=${res}][ext=mp4][vcodec^=avc]+${audioFormat}/bestvideo[height<=${res}][ext=mp4]+${audioFormat}/${fallbackMp4}/${ultimateFallback}`; + videoFormat = `bestvideo[height<=${res}][ext=mp4][vcodec^=avc]+${audioFmt}/bestvideo[height<=${res}][ext=mp4]+${audioFmt}/${fallbackMp4}/${ultimateFallback}`; break; case 'h265': // Prefer H.265/HEVC codec, fallback to any codec at preferred resolution, then fallback to best - videoFormat = `bestvideo[height<=${res}][ext=mp4][vcodec^=hev]+${audioFormat}/bestvideo[height<=${res}][ext=mp4]+${audioFormat}/${fallbackMp4}/${ultimateFallback}`; + videoFormat = `bestvideo[height<=${res}][ext=mp4][vcodec^=hev]+${audioFmt}/bestvideo[height<=${res}][ext=mp4]+${audioFmt}/${fallbackMp4}/${ultimateFallback}`; break; case 'default': default: // Default behavior: no codec preference, just resolution and container - videoFormat = `bestvideo[height<=${res}][ext=mp4]+${audioFormat}/${fallbackMp4}/${ultimateFallback}`; + videoFormat = `bestvideo[height<=${res}][ext=mp4]+${audioFmt}/${fallbackMp4}/${ultimateFallback}`; break; } @@ -114,6 +125,29 @@ class YtdlpCommandBuilder { return []; } + /** + * Build audio extraction args for MP3 downloads + * @param {string|null} audioFormat - 'video_mp3' or 'mp3_only', null for video only + * @returns {string[]} - Array of yt-dlp arguments for audio extraction + */ + static buildAudioArgs(audioFormat) { + if (!audioFormat) { + return []; + } + + const args = []; + + if (audioFormat === 'mp3_only') { + // Extract audio only, convert to MP3 at 192kbps + args.push('-x', '--audio-format', 'mp3', '--audio-quality', '192K'); + } else if (audioFormat === 'video_mp3') { + // Keep video AND extract audio as MP3 at 192kbps + args.push('--extract-audio', '--keep-video', '--audio-format', 'mp3', '--audio-quality', '192K'); + } + + return args; + } + /** * Build arguments that ALWAYS apply to any yt-dlp invocation * Includes: IPv4 enforcement, proxy, sleep-requests, and cookies @@ -311,12 +345,21 @@ class YtdlpCommandBuilder { return allFilters.join(' & '); } - // Build yt-dlp command args array for channel downloads + /** + * Build yt-dlp command args array for channel downloads + * @param {string} resolution - Video resolution + * @param {boolean} allowRedownload - Allow re-downloading previously fetched videos + * @param {string|null} subFolder - Subfolder for output + * @param {Object|null} filterConfig - Channel filter configuration + * @param {string|null} audioFormat - Audio format ('video_mp3', 'mp3_only', or null for video only) + * @returns {string[]} - Array of yt-dlp command arguments + */ static getBaseCommandArgs( resolution, allowRedownload = false, subFolder = null, - filterConfig = null + filterConfig = null, + audioFormat = null ) { const config = configModule.getConfig(); const res = resolution || config.preferredResolution || '1080'; @@ -343,11 +386,15 @@ class YtdlpCommandBuilder { '--output-na-placeholder', 'Unknown Channel', // Clean @ prefix from uploader_id when it's used as fallback '--replace-in-metadata', 'uploader_id', '^@', '', - '-f', this.buildFormatString(res, videoCodec), + '-f', this.buildFormatString(res, videoCodec, audioFormat), '--write-thumbnail', '--convert-thumbnails', 'jpg', ]; + // Add audio extraction args if configured + const audioArgs = this.buildAudioArgs(audioFormat); + args.push(...audioArgs); + // Add subtitle args if configured const subtitleArgs = this.buildSubtitleArgs(config); args.push(...subtitleArgs); @@ -381,9 +428,15 @@ class YtdlpCommandBuilder { return args; } - // Build yt-dlp command args array for manual downloads - no duration filter - // Note: Subfolder routing is handled post-download in videoDownloadPostProcessFiles.js - static getBaseCommandArgsForManualDownload(resolution, allowRedownload = false) { + /** + * Build yt-dlp command args array for manual downloads - no duration filter + * Note: Subfolder routing is handled post-download in videoDownloadPostProcessFiles.js + * @param {string} resolution - Video resolution + * @param {boolean} allowRedownload - Allow re-downloading previously fetched videos + * @param {string|null} audioFormat - Audio format ('video_mp3', 'mp3_only', or null for video only) + * @returns {string[]} - Array of yt-dlp command arguments + */ + static getBaseCommandArgsForManualDownload(resolution, allowRedownload = false, audioFormat = null) { const config = configModule.getConfig(); const res = resolution || config.preferredResolution || '1080'; const videoCodec = config.videoCodec || 'default'; @@ -409,11 +462,15 @@ class YtdlpCommandBuilder { '--output-na-placeholder', 'Unknown Channel', // Clean @ prefix from uploader_id when it's used as fallback '--replace-in-metadata', 'uploader_id', '^@', '', - '-f', this.buildFormatString(res, videoCodec), + '-f', this.buildFormatString(res, videoCodec, audioFormat), '--write-thumbnail', '--convert-thumbnails', 'jpg', ]; + // Add audio extraction args if configured + const audioArgs = this.buildAudioArgs(audioFormat); + args.push(...audioArgs); + // Add subtitle args if configured const subtitleArgs = this.buildSubtitleArgs(config); args.push(...subtitleArgs); diff --git a/server/modules/downloadModule.js b/server/modules/downloadModule.js index 63a07a25..91c07a05 100644 --- a/server/modules/downloadModule.js +++ b/server/modules/downloadModule.js @@ -388,7 +388,9 @@ class DownloadModule { // Do NOT pass subfolder to download - post-processing handles subfolder routing with __ prefix // Pass filter config for channel-specific duration and title filtering - const args = YtdlpCommandBuilder.getBaseCommandArgs(group.quality, allowRedownload, null, group.filterConfig); + // Pass audioFormat from filterConfig for MP3 downloads + const audioFormat = group.filterConfig?.audioFormat || null; + const args = YtdlpCommandBuilder.getBaseCommandArgs(group.quality, allowRedownload, null, group.filterConfig, audioFormat); args.push('-a', tempChannelsFile); args.push('--playlist-end', String(videoCount)); @@ -447,15 +449,16 @@ class DownloadModule { const channelId = this.getJobDataValue(jobData, 'channelId'); - if (!effectiveQuality && channelId) { + let channelRecord = null; + if (channelId) { try { const Channel = require('../models/channel'); - const channelRecord = await Channel.findOne({ + channelRecord = await Channel.findOne({ where: { channel_id: channelId }, - attributes: ['video_quality'], + attributes: ['video_quality', 'audio_format'], }); - if (channelRecord && channelRecord.video_quality) { + if (!effectiveQuality && channelRecord && channelRecord.video_quality) { effectiveQuality = channelRecord.video_quality; } } catch (channelErr) { @@ -466,13 +469,18 @@ class DownloadModule { const resolution = effectiveQuality || configModule.config.preferredResolution || '1080'; const allowRedownload = overrideSettings.allowRedownload || false; const subfolderOverride = overrideSettings.subfolder !== undefined ? overrideSettings.subfolder : null; + // Use override audioFormat if explicitly provided (even if null), otherwise fall back to channel's audio_format setting + const audioFormat = overrideSettings.audioFormat !== undefined + ? overrideSettings.audioFormat + : (channelRecord && channelRecord.audio_format) || null; // Persist resolved quality for any subsequent retries of this job this.setJobDataValue(jobData, 'effectiveQuality', resolution); // For manual downloads, we don't apply duration filters but still exclude members-only // Subfolder override is passed to post-processor via environment variable - const args = YtdlpCommandBuilder.getBaseCommandArgsForManualDownload(resolution, allowRedownload); + // Pass audioFormat for MP3 downloads + const args = YtdlpCommandBuilder.getBaseCommandArgsForManualDownload(resolution, allowRedownload, audioFormat); // Check if any URLs are for videos marked as ignored, and remove them from archive // This allows users to manually download videos they've marked to ignore for channel downloads diff --git a/server/modules/fileCheckModule.js b/server/modules/fileCheckModule.js index e619dd56..a10efdcb 100644 --- a/server/modules/fileCheckModule.js +++ b/server/modules/fileCheckModule.js @@ -8,9 +8,10 @@ const fs = require('fs').promises; class FileCheckModule { /** * Check file existence for an array of videos and prepare updates. - * Only checks videos that have a filePath to avoid incorrect marking. + * Checks both video files (filePath) and audio files (audioFilePath). + * Only checks files that have a path stored to avoid incorrect marking. * - * @param {Array} videos - Array of video objects with filePath property + * @param {Array} videos - Array of video objects with filePath and audioFilePath properties * @returns {Promise} - Object with updated videos array and database updates array */ async checkVideoFiles(videos) { @@ -19,45 +20,92 @@ class FileCheckModule { for (let i = 0; i < updatedVideos.length; i++) { const video = updatedVideos[i]; + const update = { id: video.id }; + let hasUpdates = false; + let videoFileExists = false; + let audioFileExists = false; + // Track if we could definitively determine file status (not blocked by permission errors, etc.) + let videoFileStatusKnown = !video.filePath; // If no path, status is "known" (not applicable) + let audioFileStatusKnown = !video.audioFilePath; // If no path, status is "known" (not applicable) - // Only check if we have a filePath stored - don't try to search for missing paths + // Check video file (filePath) if (video.filePath) { try { const stats = await fs.stat(video.filePath); + videoFileExists = true; + videoFileStatusKnown = true; - // File exists - update if marked as removed or size changed - if (video.removed || video.fileSize !== stats.size.toString()) { - updates.push({ - id: video.id, - fileSize: stats.size, - removed: false - }); - // Update the video object for immediate response - updatedVideos[i] = { - ...video, - fileSize: stats.size.toString(), - removed: false - }; + // File exists - update if size changed + if (video.fileSize !== stats.size.toString()) { + update.fileSize = stats.size; + hasUpdates = true; } } catch (err) { - // File doesn't exist - mark as removed if not already - if (err.code === 'ENOENT' && !video.removed) { - updates.push({ - id: video.id, - removed: true - }); - // Update the video object for immediate response - updatedVideos[i] = { - ...video, - removed: true - }; + if (err.code === 'ENOENT') { + // Video file doesn't exist - definitively known + videoFileExists = false; + videoFileStatusKnown = true; } + // For non-ENOENT errors (permissions, etc.), we can't determine status + // videoFileStatusKnown remains false, so we won't change removed status } } - // Intentionally NOT searching for videos without a filePath - // This prevents incorrectly marking videos as removed when we can't find them quickly - // The backfill process will handle finding and updating these videos + + // Check audio file (audioFilePath) + if (video.audioFilePath) { + try { + const stats = await fs.stat(video.audioFilePath); + audioFileExists = true; + audioFileStatusKnown = true; + + // File exists - update if size changed + if (video.audioFileSize !== stats.size.toString()) { + update.audioFileSize = stats.size; + hasUpdates = true; + } + } catch (err) { + if (err.code === 'ENOENT') { + // Audio file doesn't exist - definitively known + audioFileExists = false; + audioFileStatusKnown = true; + } + // For non-ENOENT errors (permissions, etc.), we can't determine status + // audioFileStatusKnown remains false, so we won't change removed status + } + } + + // Determine removed status: removed only if NO files exist (both video and audio missing) + // Only update if we could definitively determine status for all paths + const hasAnyPath = video.filePath || video.audioFilePath; + const hasAnyFile = videoFileExists || audioFileExists; + const canDetermineRemovedStatus = videoFileStatusKnown && audioFileStatusKnown; + + if (hasAnyPath && canDetermineRemovedStatus) { + if (hasAnyFile && video.removed) { + // At least one file exists, mark as not removed + update.removed = false; + hasUpdates = true; + } else if (!hasAnyFile && !video.removed) { + // No files exist, mark as removed + update.removed = true; + hasUpdates = true; + } + } + + if (hasUpdates) { + updates.push(update); + // Update the video object for immediate response + updatedVideos[i] = { + ...video, + ...(update.fileSize !== undefined && { fileSize: update.fileSize.toString() }), + ...(update.audioFileSize !== undefined && { audioFileSize: update.audioFileSize.toString() }), + ...(update.removed !== undefined && { removed: update.removed }) + }; + } } + // Intentionally NOT searching for videos without a filePath or audioFilePath + // This prevents incorrectly marking videos as removed when we can't find them quickly + // The backfill process will handle finding and updating these videos return { videos: updatedVideos, updates }; } @@ -67,7 +115,7 @@ class FileCheckModule { * * @param {Object} sequelize - Sequelize instance * @param {Object} Sequelize - Sequelize library - * @param {Array} updates - Array of update objects with id, fileSize, and/or removed properties + * @param {Array} updates - Array of update objects with id, fileSize, audioFileSize, and/or removed properties * @returns {Promise} */ async applyVideoUpdates(sequelize, Sequelize, updates) { @@ -83,6 +131,10 @@ class FileCheckModule { setClauses.push('fileSize = ?'); values.push(update.fileSize); } + if (update.audioFileSize !== undefined) { + setClauses.push('audioFileSize = ?'); + values.push(update.audioFileSize); + } if (update.removed !== undefined) { setClauses.push('removed = ?'); values.push(update.removed ? 1 : 0); diff --git a/server/modules/filesystem/constants.js b/server/modules/filesystem/constants.js index af90a784..fd7b7433 100644 --- a/server/modules/filesystem/constants.js +++ b/server/modules/filesystem/constants.js @@ -28,6 +28,17 @@ const ROOT_SENTINEL = '##ROOT##'; */ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mkv', '.m4v', '.avi']; +/** + * Audio file extensions for MP3 downloads + */ +const AUDIO_EXTENSIONS = ['.mp3']; + +/** + * All media file extensions (video + audio) + * Used when searching for any downloaded media + */ +const MEDIA_EXTENSIONS = [...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS]; + /** * yt-dlp output template for channel folder name * Uses uploader with fallback to channel, then uploader_id @@ -71,16 +82,30 @@ const YOUTUBE_ID_PATTERN = /^[a-zA-Z0-9_-]{10,12}$/; */ const MAIN_VIDEO_FILE_PATTERN = /\[[a-zA-Z0-9_-]{10,12}\]\.(mp4|mkv|webm)$/; +/** + * Pattern to identify main audio files (MP3 downloads) + * Matches [VideoID].mp3 + */ +const MAIN_AUDIO_FILE_PATTERN = /\[[a-zA-Z0-9_-]{10,12}\]\.mp3$/; + +/** + * Pattern to identify any main media file (video or audio) + * Matches [VideoID].mp4/mkv/webm/mp3 + */ +const MAIN_MEDIA_FILE_PATTERN = /\[[a-zA-Z0-9_-]{10,12}\]\.(mp4|mkv|webm|mp3)$/; + /** * Pattern to identify video fragment files (to exclude) */ -const FRAGMENT_FILE_PATTERN = /\.f\d+\.(mp4|m4a|webm)$/; +const FRAGMENT_FILE_PATTERN = /\.f[\d-]+\.(mp4|m4a|webm|mkv)$/; module.exports = { SUBFOLDER_PREFIX, GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL, VIDEO_EXTENSIONS, + AUDIO_EXTENSIONS, + MEDIA_EXTENSIONS, CHANNEL_TEMPLATE, VIDEO_FOLDER_TEMPLATE, VIDEO_FILE_TEMPLATE, @@ -88,5 +113,7 @@ module.exports = { YOUTUBE_ID_DASH_PATTERN, YOUTUBE_ID_PATTERN, MAIN_VIDEO_FILE_PATTERN, + MAIN_AUDIO_FILE_PATTERN, + MAIN_MEDIA_FILE_PATTERN, FRAGMENT_FILE_PATTERN }; diff --git a/server/modules/videoDownloadPostProcessFiles.js b/server/modules/videoDownloadPostProcessFiles.js index 8b4e00fb..3c5fa7d0 100644 --- a/server/modules/videoDownloadPostProcessFiles.js +++ b/server/modules/videoDownloadPostProcessFiles.js @@ -12,9 +12,10 @@ const { moveWithRetries, safeRemove, buildChannelPath, cleanupEmptyParents } = r const activeJobId = process.env.YOUTARR_JOB_ID; -const videoPath = process.argv[2]; // get the video file path +const videoPath = process.argv[2]; // get the media file path (video or audio) const parsedPath = path.parse(videoPath); -// Note that the mp4 video itself contains embedded metadata for Plex +// Note that MP4 videos contain embedded metadata for Plex +// MP3 audio files have their own embedded metadata from yt-dlp // We only need the .info.json for Youtarr to use const jsonPath = path.format({ dir: parsedPath.dir, @@ -277,9 +278,28 @@ async function copyChannelPosterIfNeeded(channelId, channelFolderPath) { nfoGenerator.writeVideoNfoFile(videoPath, jsonData); } + // Check if this is an audio file (MP3) - skip video-specific metadata embedding + const isAudioFile = parsedPath.ext.toLowerCase() === '.mp3'; + + // Detect companion video file for dual-format downloads (video_mp3 mode) + // When yt-dlp runs with --extract-audio --keep-video, it produces both MP4 and MP3 + // but only calls --exec for the final MP3 file. We need to track the MP4 as well. + let companionVideoPath = null; + if (isAudioFile) { + const potentialVideoPath = path.join(parsedPath.dir, parsedPath.name + '.mp4'); + if (fs.existsSync(potentialVideoPath)) { + companionVideoPath = potentialVideoPath; + logger.info({ audioPath: videoPath, videoPath: companionVideoPath }, + '[Post-Process] Dual-format download detected (video_mp3 mode)'); + } + } + // Add additional metadata to the MP4 file that yt-dlp might have missed // yt-dlp already embeds basic metadata, but we can add more for better Plex compatibility - try { + // Skip for audio-only downloads (MP3 files) + if (isAudioFile) { + logger.info('[Post-Process] Audio file detected, skipping video metadata embedding'); + } else try { const tempPath = videoPath + '.metadata_temp.mp4'; // Build metadata arguments as an array to avoid shell escaping issues @@ -408,13 +428,23 @@ async function copyChannelPosterIfNeeded(channelId, channelFolderPath) { // Set the file timestamps to match the upload date if (uploadDate) { - // Set timestamp for the video file + // Set timestamp for the video/audio file (whatever was passed to post-processor) if (fs.existsSync(videoPath)) { try { fs.utimesSync(videoPath, uploadDate, uploadDate); - logger.info({ timestamp: uploadDate.toISOString() }, 'Set video timestamp'); + logger.info({ timestamp: uploadDate.toISOString() }, 'Set primary file timestamp'); + } catch (err) { + logger.warn({ err }, 'Error setting primary file timestamp'); + } + } + + // Set timestamp for companion video file (dual-format downloads) + if (companionVideoPath && fs.existsSync(companionVideoPath)) { + try { + fs.utimesSync(companionVideoPath, uploadDate, uploadDate); + logger.info({ timestamp: uploadDate.toISOString() }, 'Set companion video timestamp'); } catch (err) { - logger.warn({ err }, 'Error setting video timestamp'); + logger.warn({ err }, 'Error setting companion video timestamp'); } } @@ -472,6 +502,31 @@ async function copyChannelPosterIfNeeded(channelId, channelFolderPath) { // Ensure parent channel directory exists await fs.ensureDir(targetChannelFolderForMove); + // Clean up yt-dlp intermediate files before moving + // In video_mp3 mode with --extract-audio --keep-video, yt-dlp doesn't always + // clean up these intermediate files as it normally would + const filesInDir = await fs.readdir(videoDirectory); + for (const file of filesInDir) { + // Match yt-dlp fragment patterns: .f###.ext or .f###-###.ext where ext is mp4/m4a/webm/mkv + if (/\.f[\d-]+\.(mp4|m4a|webm|mkv)$/i.test(file)) { + const fragmentPath = path.join(videoDirectory, file); + logger.info({ fragmentPath }, '[Post-Process] Removing yt-dlp fragment file'); + await fs.remove(fragmentPath); + } + // Remove original thumbnail files (.webp) - these should have been converted to .jpg + else if (/\.webp$/i.test(file)) { + const webpPath = path.join(videoDirectory, file); + logger.info({ webpPath }, '[Post-Process] Removing original webp thumbnail'); + await fs.remove(webpPath); + } + // Remove original subtitle files (.vtt) - these should have been converted to .srt + else if (/\.vtt$/i.test(file)) { + const vttPath = path.join(videoDirectory, file); + logger.info({ vttPath }, '[Post-Process] Removing original vtt subtitle'); + await fs.remove(vttPath); + } + } + // Check if target video directory already exists (rare, but handle gracefully) const targetExists = await fs.pathExists(targetVideoDirectory); @@ -509,9 +564,37 @@ async function copyChannelPosterIfNeeded(channelId, channelFolderPath) { // Update the JSON file with the final path (after all moves are complete) // This ensures videoMetadataProcessor gets the correct location try { - jsonData._actual_filepath = finalVideoPath; + // Handle dual-format downloads (video_mp3 mode): track both video and audio paths + if (isAudioFile && companionVideoPath) { + // Calculate final path for companion video (same directory as the audio file) + const finalCompanionVideoPath = path.join( + path.dirname(finalVideoPath), + path.basename(companionVideoPath) + ); + + // Store both paths for videoMetadataProcessor + jsonData._actual_video_filepath = finalCompanionVideoPath; + jsonData._actual_audio_filepath = finalVideoPath; + // Keep _actual_filepath as video for backward compatibility + jsonData._actual_filepath = finalCompanionVideoPath; + + logger.info({ + videoPath: finalCompanionVideoPath, + audioPath: finalVideoPath + }, '[Post-Process] Updated dual-format paths in JSON'); + } else if (isAudioFile) { + // Audio-only download (mp3_only mode) + jsonData._actual_audio_filepath = finalVideoPath; + jsonData._actual_filepath = finalVideoPath; + logger.info({ finalVideoPath }, '[Post-Process] Updated _actual_filepath in JSON (audio-only)'); + } else { + // Standard video download + jsonData._actual_video_filepath = finalVideoPath; + jsonData._actual_filepath = finalVideoPath; + logger.info({ finalVideoPath }, '[Post-Process] Updated _actual_filepath in JSON'); + } + fs.writeFileSync(newJsonPath, JSON.stringify(jsonData, null, 2)); - logger.info({ finalVideoPath }, '[Post-Process] Updated _actual_filepath in JSON'); } catch (jsonErr) { logger.error({ err: jsonErr }, '[Post-Process] Error updating JSON file with final path'); // Don't fail the process, but log the error diff --git a/server/modules/videosModule.js b/server/modules/videosModule.js index 02bf8120..3d0de6ee 100644 --- a/server/modules/videosModule.js +++ b/server/modules/videosModule.js @@ -88,6 +88,8 @@ class VideosModule { Videos.channel_id, Videos.filePath, Videos.fileSize, + Videos.audioFilePath, + Videos.audioFileSize, Videos.removed, Videos.youtube_removed, Videos.media_type, @@ -266,36 +268,47 @@ class VideosModule { if (entry.isDirectory()) { // Recursively scan subdirectories await this.scanForVideoFiles(fullPath, fileMap, duplicates); - } else if (entry.isFile() && entry.name.endsWith('.mp4')) { - // Extract YouTube ID from filename - matches files ending with [youtube_id].mp4 - // Accepts ANY characters before the final [id].mp4 pattern - const match = entry.name.match(/\[([^[\]]+)\]\.mp4$/); + } else if (entry.isFile() && (entry.name.endsWith('.mp4') || entry.name.endsWith('.mp3'))) { + // Extract YouTube ID from filename - matches files ending with [youtube_id].mp4 or [youtube_id].mp3 + // Accepts ANY characters before the final [id].ext pattern + const match = entry.name.match(/\[([^[\]]+)\]\.(mp4|mp3)$/); if (match) { const youtubeId = match[1]; + const ext = match[2]; // 'mp4' or 'mp3' + const isAudio = ext === 'mp3'; const stats = await fs.stat(fullPath); - // Check for duplicates - if (fileMap.has(youtubeId)) { + // Get or create entry for this youtubeId + if (!fileMap.has(youtubeId)) { + fileMap.set(youtubeId, { + videoFilePath: null, + videoFileSize: null, + audioFilePath: null, + audioFileSize: null + }); + } + + const existing = fileMap.get(youtubeId); + const pathKey = isAudio ? 'audioFilePath' : 'videoFilePath'; + const sizeKey = isAudio ? 'audioFileSize' : 'videoFileSize'; + + // Check for duplicates of the same type + if (existing[pathKey]) { // Track duplicate for logging if (!duplicates.has(youtubeId)) { - duplicates.set(youtubeId, [fileMap.get(youtubeId).filePath]); + duplicates.set(youtubeId, []); } duplicates.get(youtubeId).push(fullPath); // Keep the larger file (likely the more complete download) - const existingFile = fileMap.get(youtubeId); - if (stats.size > existingFile.fileSize) { - logger.warn({ youtubeId, filePath: fullPath, size: stats.size }, 'Duplicate found: keeping larger file'); - fileMap.set(youtubeId, { - filePath: fullPath, - fileSize: stats.size - }); + if (stats.size > existing[sizeKey]) { + logger.warn({ youtubeId, filePath: fullPath, size: stats.size, type: ext }, 'Duplicate found: keeping larger file'); + existing[pathKey] = fullPath; + existing[sizeKey] = stats.size; } } else { - fileMap.set(youtubeId, { - filePath: fullPath, - fileSize: stats.size - }); + existing[pathKey] = fullPath; + existing[sizeKey] = stats.size; } } } @@ -361,7 +374,7 @@ class VideosModule { // Fetch a chunk of videos const videos = await Video.findAll({ - attributes: ['id', 'youtubeId', 'filePath', 'fileSize', 'removed'], + attributes: ['id', 'youtubeId', 'filePath', 'fileSize', 'audioFilePath', 'audioFileSize', 'removed'], limit: VIDEO_CHUNK_SIZE, offset: offset, raw: true @@ -383,24 +396,58 @@ class VideosModule { const fileInfo = fileMap.get(video.youtubeId); - if (fileInfo) { - // File exists - check if update needed - if (video.filePath !== fileInfo.filePath || - !video.fileSize || - video.fileSize !== fileInfo.fileSize.toString() || - video.removed === true) { + // Check if any file exists (video or audio) + const hasVideoFile = !!fileInfo.videoFilePath; + const hasAudioFile = !!fileInfo.audioFilePath; + const hasAnyFile = hasVideoFile || hasAudioFile; + + if (hasAnyFile) { + // Check if update needed for video file + const videoPathChanged = hasVideoFile && video.filePath !== fileInfo.videoFilePath; + const videoSizeChanged = hasVideoFile && (!video.fileSize || video.fileSize !== fileInfo.videoFileSize.toString()); + + // Check if update needed for audio file + const audioPathChanged = hasAudioFile && video.audioFilePath !== fileInfo.audioFilePath; + const audioSizeChanged = hasAudioFile && (!video.audioFileSize || video.audioFileSize !== fileInfo.audioFileSize.toString()); + + // Check if we need to clear audio fields (audio file was deleted) + const audioFileRemoved = !hasAudioFile && (video.audioFilePath || video.audioFileSize); + + // Check if we need to clear video fields (video file was deleted but audio exists) + const videoFileRemoved = !hasVideoFile && hasAudioFile && (video.filePath || video.fileSize); + + if (videoPathChanged || videoSizeChanged || audioPathChanged || audioSizeChanged || + audioFileRemoved || videoFileRemoved || video.removed === true) { + const update = { + id: video.id, + removed: false + }; + + // Update video file info + if (hasVideoFile) { + update.filePath = fileInfo.videoFilePath; + update.fileSize = fileInfo.videoFileSize; + } else if (videoFileRemoved) { + update.filePath = null; + update.fileSize = null; + } - bulkUpdates.push({ - id: video.id, - filePath: fileInfo.filePath, - fileSize: fileInfo.fileSize, - removed: false - }); - chunkUpdated++; + // Update audio file info + if (hasAudioFile) { + update.audioFilePath = fileInfo.audioFilePath; + update.audioFileSize = fileInfo.audioFileSize; + } else if (audioFileRemoved) { + update.audioFilePath = null; + update.audioFileSize = null; + } + + bulkUpdates.push(update); + chunkUpdated++; + } } } else { - // File doesn't exist in fileMap + // No files exist in fileMap for this video if (!video.removed) { // Only mark as removed, don't touch filePath or fileSize // They might still be valid even if we can't find the file right now @@ -443,6 +490,14 @@ class VideosModule { setClauses.push('fileSize = ?'); replacements.push(update.fileSize); } + if (update.audioFilePath !== undefined) { + setClauses.push('audioFilePath = ?'); + replacements.push(update.audioFilePath); + } + if (update.audioFileSize !== undefined) { + setClauses.push('audioFileSize = ?'); + replacements.push(update.audioFileSize); + } if (update.removed !== undefined) { setClauses.push('removed = ?'); replacements.push(update.removed ? 1 : 0); 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: