From dd57bbc5c8371d9b82cad1eafaaf4617dac7f67d Mon Sep 17 00:00:00 2001 From: dialmaster Date: Fri, 16 Jan 2026 13:23:28 -0800 Subject: [PATCH 1/2] feat(#309): add support for mp3-only download - Add per-channel audio format setting: video only, video+mp3, or mp3 only - Add Download Type selector to channel settings and manual download dialogs - Track audio files separately with audioFilePath and audioFileSize in database - Display format indicators (video/audio icons with file sizes) in video lists - Support dual-format downloads producing both MP4 and MP3 files - Add yt-dlp audio extraction for MP3 conversion at 192kbps - Update file scanning and validation to handle both video and audio files - Add database migrations for audio_format and audio file tracking columns --- .../ChannelManager/components/ChannelCard.tsx | 5 +- .../components/ChannelListRow.tsx | 4 +- .../components/__tests__/ChannelCard.test.tsx | 7 + .../__tests__/ChannelListRow.test.tsx | 11 ++ .../chips/DownloadFormatConfigIndicator.tsx | 54 +++++++ .../DownloadFormatConfigIndicator.test.tsx | 40 +++++ .../ChannelManager/components/chips/index.ts | 1 + client/src/components/ChannelPage.tsx | 3 + .../ChannelPage/ChannelSettingsDialog.tsx | 46 +++++- .../components/ChannelPage/ChannelVideos.tsx | 10 +- .../ChannelPage/ChannelVideosDialogs.tsx | 6 + .../src/components/ChannelPage/VideoCard.tsx | 16 +- .../components/ChannelPage/VideoListItem.tsx | 16 +- .../components/ChannelPage/VideoTableView.tsx | 12 +- .../__tests__/ChannelSettingsDialog.test.tsx | 1 + .../ChannelPage/__tests__/VideoCard.test.tsx | 33 ++-- .../__tests__/VideoListItem.test.tsx | 36 +++-- .../__tests__/VideoTableView.test.tsx | 21 ++- .../__tests__/NotificationsSection.test.tsx | 71 ++++----- .../ManualDownload/DownloadSettingsDialog.tsx | 84 +++++++++-- .../__tests__/DownloadSettingsDialog.test.tsx | 3 + .../DownloadManager/ManualDownload/types.ts | 1 + client/src/components/VideosPage.tsx | 86 +++-------- .../components/__tests__/VideosPage.test.tsx | 65 ++++---- .../shared/DownloadFormatIndicator.tsx | 84 +++++++++++ .../DownloadFormatIndicator.test.tsx | 98 ++++++++++++ client/src/hooks/useTriggerDownloads.ts | 2 + client/src/types/Channel.ts | 1 + client/src/types/ChannelVideo.ts | 3 + client/src/types/VideoData.ts | 2 + ...0111170602-add-audio-format-to-channels.js | 20 +++ ...112013359-add-audio-file-path-to-videos.js | 27 ++++ server/models/channel.js | 5 + server/models/video.js | 8 + .../__tests__/channelDownloadGrouper.test.js | 10 +- .../modules/__tests__/channelModule.test.js | 16 +- .../modules/__tests__/downloadModule.test.js | 76 ++++++++-- .../modules/__tests__/fileCheckModule.test.js | 14 +- server/modules/__tests__/videosModule.test.js | 16 +- server/modules/channelDownloadGrouper.js | 17 ++- server/modules/channelModule.js | 19 ++- server/modules/channelSettingsModule.js | 35 +++++ .../download/DownloadProgressMonitor.js | 6 +- .../__tests__/downloadExecutor.test.js | 6 +- .../__tests__/videoMetadataProcessor.test.js | 6 +- server/modules/download/downloadExecutor.js | 24 ++- .../download/videoMetadataProcessor.js | 142 +++++++++++------- .../modules/download/ytdlpCommandBuilder.js | 83 ++++++++-- server/modules/downloadModule.js | 20 ++- server/modules/fileCheckModule.js | 114 ++++++++++---- server/modules/filesystem/constants.js | 29 +++- .../modules/videoDownloadPostProcessFiles.js | 99 +++++++++++- server/modules/videosModule.js | 121 +++++++++++---- 53 files changed, 1340 insertions(+), 395 deletions(-) create mode 100644 client/src/components/ChannelManager/components/chips/DownloadFormatConfigIndicator.tsx create mode 100644 client/src/components/ChannelManager/components/chips/__tests__/DownloadFormatConfigIndicator.test.tsx create mode 100644 client/src/components/shared/DownloadFormatIndicator.tsx create mode 100644 client/src/components/shared/__tests__/DownloadFormatIndicator.test.tsx create mode 100644 migrations/20260111170602-add-audio-format-to-channels.js create mode 100644 migrations/20260112013359-add-audio-file-path-to-videos.js 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 f776ac2a..f3186edd 100644 --- a/client/src/components/ChannelPage/ChannelVideos.tsx +++ b/client/src/components/ChannelPage/ChannelVideos.tsx @@ -54,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')); @@ -278,6 +279,10 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI 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 { @@ -367,6 +372,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI resolution: settings.resolution, allowRedownload: settings.allowRedownload, subfolder: settings.subfolder, + audioFormat: settings.audioFormat, } : undefined; @@ -982,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 367b605d..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,6 +83,8 @@ function ChannelVideosDialogs({ missingVideoCount={missingVideoCount} defaultResolution={defaultResolution} defaultResolutionSource={defaultResolutionSource} + defaultAudioFormat={defaultAudioFormat} + defaultAudioFormatSource={defaultAudioFormatSource} mode="manual" token={token} /> diff --git a/client/src/components/ChannelPage/VideoCard.tsx b/client/src/components/ChannelPage/VideoCard.tsx index 910a4237..34698421 100644 --- a/client/src/components/ChannelPage/VideoCard.tsx +++ b/client/src/components/ChannelPage/VideoCard.tsx @@ -11,16 +11,16 @@ import { Tooltip, } from '@mui/material'; import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; -import StorageIcon from '@mui/icons-material/Storage'; import DeleteIcon from '@mui/icons-material/Delete'; import BlockIcon from '@mui/icons-material/Block'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import { useTheme } from '@mui/material/styles'; import { formatDuration } from '../../utils'; import { ChannelVideo } from '../../types/ChannelVideo'; -import { formatFileSize, decodeHtml } from '../../utils/formatters'; +import { decodeHtml } from '../../utils/formatters'; import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo } from '../../utils/videoStatus'; import StillLiveDot from './StillLiveDot'; +import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; interface VideoCardProps { video: ChannelVideo; @@ -275,11 +275,13 @@ function VideoCard({ } )} - {video.fileSize && ( - - - {formatFileSize(video.fileSize)} - + {video.added && !video.removed && ( + )} {mediaTypeInfo && ( )} - {video.fileSize && ( - - - {formatFileSize(video.fileSize)} - + {video.added && !video.removed && ( + )} {mediaTypeInfo && ( - {video.fileSize ? formatFileSize(video.fileSize) : '-'} + {(video.filePath || video.audioFilePath) ? ( + + ) : '-'} diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx index e83291fd..e95a49ca 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx @@ -35,6 +35,7 @@ describe('ChannelSettingsDialog', () => { min_duration: null, max_duration: null, title_filter_regex: null, + audio_format: null, }; const mockSubfolders = ['__Sports', '__Music', '__Tech']; diff --git a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx index 27735863..78d01300 100644 --- a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx @@ -150,17 +150,24 @@ describe('VideoCard Component', () => { }); describe('File Size Display', () => { - test('renders file size when available', () => { - const videoWithSize = { ...mockVideo, fileSize: 1024 * 1024 * 50 }; - renderWithProviders(); + test('renders file size chip when video file exists', () => { + const videoWithFile = { + ...mockVideo, + added: true, + removed: false, + filePath: '/path/to/video.mp4', + fileSize: 1024 * 1024 * 50 + }; + renderWithProviders(); + // File size shown in format indicator chip expect(screen.getByText(/50/)).toBeInTheDocument(); - expect(screen.getByTestId('StorageIcon')).toBeInTheDocument(); + expect(screen.getByTestId('MovieOutlinedIcon')).toBeInTheDocument(); }); - test('does not render file size when not available', () => { + test('does not render format indicator when no file path exists', () => { renderWithProviders(); - const storageIcons = screen.queryAllByTestId('StorageIcon'); - expect(storageIcons.length).toBe(0); + const movieIcons = screen.queryAllByTestId('MovieOutlinedIcon'); + expect(movieIcons.length).toBe(0); }); }); @@ -638,11 +645,17 @@ describe('VideoCard Component', () => { }); test('handles video with very large file size', () => { - const largeVideo = { ...mockVideo, fileSize: 1024 * 1024 * 1024 * 5.5 }; + const largeVideo = { + ...mockVideo, + added: true, + removed: false, + filePath: '/path/to/video.mp4', + fileSize: 1024 * 1024 * 1024 * 5.5 + }; renderWithProviders(); - // Check for file size presence - look for GB text + // Check for file size presence in format indicator chip expect(screen.getByText(/GB/)).toBeInTheDocument(); - expect(screen.getByTestId('StorageIcon')).toBeInTheDocument(); + expect(screen.getByTestId('MovieOutlinedIcon')).toBeInTheDocument(); }); test('handles video in both selectedForDeletion and checkedBoxes', () => { diff --git a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx index 2aa7101e..341a479a 100644 --- a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx @@ -131,17 +131,24 @@ describe('VideoListItem Component', () => { }); describe('File Size Display', () => { - test('renders file size when available', () => { - const videoWithSize = { ...mockVideo, fileSize: 1024 * 1024 * 50 }; - renderWithProviders(); - expect(screen.getByText('50MB')).toBeInTheDocument(); - expect(screen.getByTestId('StorageIcon')).toBeInTheDocument(); + test('renders file size chip when video file exists', () => { + const videoWithFile = { + ...mockVideo, + added: true, + removed: false, + filePath: '/path/to/video.mp4', + fileSize: 1024 * 1024 * 50 + }; + renderWithProviders(); + // File size shown in format indicator chip + expect(screen.getByText(/50/)).toBeInTheDocument(); + expect(screen.getByTestId('MovieOutlinedIcon')).toBeInTheDocument(); }); - test('does not render file size when not available', () => { + test('does not render format indicator when no file path exists', () => { renderWithProviders(); - const storageIcons = screen.queryAllByTestId('StorageIcon'); - expect(storageIcons.length).toBe(0); + const movieIcons = screen.queryAllByTestId('MovieOutlinedIcon'); + expect(movieIcons.length).toBe(0); }); }); @@ -608,10 +615,17 @@ describe('VideoListItem Component', () => { }); test('handles video with very large file size', () => { - const largeVideo = { ...mockVideo, fileSize: 1024 * 1024 * 1024 * 5.5 }; + const largeVideo = { + ...mockVideo, + added: true, + removed: false, + filePath: '/path/to/video.mp4', + fileSize: 1024 * 1024 * 1024 * 5.5 + }; renderWithProviders(); - expect(screen.getByText('5.5GB')).toBeInTheDocument(); - expect(screen.getByTestId('StorageIcon')).toBeInTheDocument(); + // File size shown in format indicator chip + expect(screen.getByText(/GB/)).toBeInTheDocument(); + expect(screen.getByTestId('MovieOutlinedIcon')).toBeInTheDocument(); }); test('handles video in both selectedForDeletion and checkedBoxes', () => { diff --git a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx index 75bee0fc..dc774cd4 100644 --- a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx @@ -133,14 +133,17 @@ describe('VideoTableView Component', () => { expect(allNAs.length).toBeGreaterThanOrEqual(2); }); - test('renders file size when available', () => { - renderWithProviders(); + test('renders file size chip when video file exists', () => { + const videoWithFile = { ...mockVideo, filePath: '/path/to/video.mp4' }; + renderWithProviders(); + // File size shown in format indicator chip expect(screen.getByText(/50/)).toBeInTheDocument(); + expect(screen.getByTestId('MovieOutlinedIcon')).toBeInTheDocument(); }); - test('renders dash when file size is not available', () => { - const videoNoSize = { ...mockVideo, fileSize: undefined }; - renderWithProviders(); + test('renders dash when no file path exists', () => { + const videoNoFile = { ...mockVideo, filePath: undefined }; + renderWithProviders(); expect(screen.getByText('-')).toBeInTheDocument(); }); }); @@ -803,9 +806,15 @@ describe('VideoTableView Component', () => { }); test('handles video with very large file size', () => { - const largeVideo = { ...mockVideo, fileSize: 1024 * 1024 * 1024 * 5.5 }; + const largeVideo = { + ...mockVideo, + filePath: '/path/to/video.mp4', + fileSize: 1024 * 1024 * 1024 * 5.5 + }; renderWithProviders(); + // File size shown in format indicator chip expect(screen.getByText(/GB/)).toBeInTheDocument(); + expect(screen.getByTestId('MovieOutlinedIcon')).toBeInTheDocument(); }); test('handles video in both selectedForDeletion and checkedBoxes', () => { 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..170e88e0 --- /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.firstChild).toBeNull(); + }); + + test('returns null when file paths are null', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + 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/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 9c4eb8e6..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, 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 e4f6ffcb..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 () => { 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 2b941700..5108d57a 100644 --- a/server/modules/channelModule.js +++ b/server/modules/channelModule.js @@ -295,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, @@ -318,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, }; } @@ -1244,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 @@ -1257,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); } }); @@ -1276,6 +1280,7 @@ class ChannelModule { if (status) { status.removed = v.removed; status.fileSize = v.fileSize; + status.audioFileSize = v.audioFileSize; } }); @@ -1295,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) diff --git a/server/modules/channelSettingsModule.js b/server/modules/channelSettingsModule.js index e3061c44..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 @@ -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); From 7f8802faecd62a37a0ab11a9b777db36aa66fd23 Mon Sep 17 00:00:00 2001 From: dialmaster Date: Fri, 16 Jan 2026 13:34:10 -0800 Subject: [PATCH 2/2] chore(#309): lint fix --- .../shared/__tests__/DownloadFormatIndicator.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/shared/__tests__/DownloadFormatIndicator.test.tsx b/client/src/components/shared/__tests__/DownloadFormatIndicator.test.tsx index 170e88e0..d259a423 100644 --- a/client/src/components/shared/__tests__/DownloadFormatIndicator.test.tsx +++ b/client/src/components/shared/__tests__/DownloadFormatIndicator.test.tsx @@ -9,7 +9,7 @@ describe('DownloadFormatIndicator', () => { test('returns null when no file paths are provided', () => { const { container } = render(); - expect(container.firstChild).toBeNull(); + expect(container).toBeEmptyDOMElement(); }); test('returns null when file paths are null', () => { @@ -17,7 +17,7 @@ describe('DownloadFormatIndicator', () => { ); - expect(container.firstChild).toBeNull(); + expect(container).toBeEmptyDOMElement(); }); test('renders video chip when filePath is provided', () => {