diff --git a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx index 3e8191e..4dd562e 100644 --- a/client/src/components/ChannelPage/ChannelSettingsDialog.tsx +++ b/client/src/components/ChannelPage/ChannelSettingsDialog.tsx @@ -6,9 +6,11 @@ import { DialogActions, Button, FormControl, + FormControlLabel, InputLabel, Select, MenuItem, + Switch, TextField, CircularProgress, Alert, @@ -39,6 +41,7 @@ interface ChannelSettings { title_filter_regex: string | null; audio_format: string | null; default_rating: string | null; + skip_video_folder: boolean | null; } interface FilterPreviewVideo { @@ -96,7 +99,8 @@ function ChannelSettingsDialog({ max_duration: null, title_filter_regex: null, audio_format: null, - default_rating: null + default_rating: null, + skip_video_folder: null }); const [originalSettings, setOriginalSettings] = useState({ sub_folder: null, @@ -105,7 +109,8 @@ function ChannelSettingsDialog({ max_duration: null, title_filter_regex: null, audio_format: null, - default_rating: null + default_rating: null, + skip_video_folder: null }); const [subfolders, setSubfolders] = useState([]); const [loading, setLoading] = useState(true); @@ -186,6 +191,9 @@ function ChannelSettingsDialog({ default_rating: Object.prototype.hasOwnProperty.call(settingsData, 'default_rating') ? settingsData.default_rating : null, + skip_video_folder: Object.prototype.hasOwnProperty.call(settingsData, 'skip_video_folder') + ? settingsData.skip_video_folder + : null, }; setSettings(loadedSettings); setOriginalSettings(loadedSettings); @@ -242,7 +250,8 @@ function ChannelSettingsDialog({ max_duration: settings.max_duration, title_filter_regex: settings.title_filter_regex || null, audio_format: settings.audio_format || null, - default_rating: settings.default_rating || null + default_rating: settings.default_rating || null, + skip_video_folder: settings.skip_video_folder }) }); @@ -274,6 +283,9 @@ function ChannelSettingsDialog({ default_rating: result?.settings && Object.prototype.hasOwnProperty.call(result.settings, 'default_rating') ? result.settings.default_rating : settings.default_rating ?? null, + skip_video_folder: result?.settings && Object.prototype.hasOwnProperty.call(result.settings, 'skip_video_folder') + ? result.settings.skip_video_folder + : settings.skip_video_folder ?? null, }; setSettings(updatedSettings); @@ -313,7 +325,8 @@ function ChannelSettingsDialog({ settings.max_duration !== originalSettings.max_duration || settings.title_filter_regex !== originalSettings.title_filter_regex || settings.audio_format !== originalSettings.audio_format || - settings.default_rating !== originalSettings.default_rating; + settings.default_rating !== originalSettings.default_rating || + settings.skip_video_folder !== originalSettings.skip_video_folder; }; const handlePreviewFilter = async () => { @@ -461,6 +474,24 @@ function ChannelSettingsDialog({ )} + setSettings({ + ...settings, + skip_video_folder: e.target.checked ? true : null + })} + color="primary" + /> + } + label="Flat file structure (no video subfolders)" + sx={{ mt: 1 }} + /> + + When enabled, video files are saved directly in the channel folder instead of individual video subfolders. Only affects new downloads. + + Subfolder Organization diff --git a/client/src/components/ChannelPage/ChannelVideos.tsx b/client/src/components/ChannelPage/ChannelVideos.tsx index 9c2053b..68f5bf1 100644 --- a/client/src/components/ChannelPage/ChannelVideos.tsx +++ b/client/src/components/ChannelPage/ChannelVideos.tsx @@ -390,6 +390,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI subfolder: settings.subfolder, audioFormat: settings.audioFormat, rating: settings.rating, + skipVideoFolder: settings.skipVideoFolder, } : undefined; diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx index 33fb92c..3aca8e1 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx @@ -37,6 +37,7 @@ describe('ChannelSettingsDialog', () => { title_filter_regex: null, audio_format: null, default_rating: null, + skip_video_folder: null, }; const mockSubfolders = ['__Sports', '__Music', '__Tech']; @@ -1327,6 +1328,127 @@ describe('ChannelSettingsDialog', () => { }); }); + describe('Skip Video Folder Toggle', () => { + test('renders the flat file structure toggle', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockChannelSettings), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockSubfolders), + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + expect(screen.getByLabelText('Flat file structure (no video subfolders)')).toBeInTheDocument(); + }); + + test('toggles skip_video_folder when switch is clicked', async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockChannelSettings), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockSubfolders), + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + const toggle = screen.getByRole('checkbox', { name: /Flat file structure/i }); + expect(toggle).not.toBeChecked(); + + await user.click(toggle); + expect(toggle).toBeChecked(); + + // Save button should now be enabled since there's a change + const saveButton = screen.getByRole('button', { name: 'Save' }); + expect(saveButton).not.toBeDisabled(); + }); + + test('sends skip_video_folder in the API save call', async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockChannelSettings), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockSubfolders), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + settings: { ...mockChannelSettings, skip_video_folder: true }, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // Toggle the flat file structure switch + const toggle = screen.getByRole('checkbox', { name: /Flat file structure/i }); + await user.click(toggle); + + // Click save + const saveButton = screen.getByRole('button', { name: 'Save' }); + await user.click(saveButton); + + // Verify the PUT call includes skip_video_folder: true + let putCall: any[]; + await waitFor(() => { + putCall = mockFetch.mock.calls.find( + (call: any[]) => call[0] === '/api/channels/channel123/settings' && call[1]?.method === 'PUT' + ); + expect(putCall).toBeDefined(); + }); + const body = JSON.parse(putCall![1].body); + expect(body.skip_video_folder).toBe(true); + }); + + test('loads skip_video_folder true from server and shows toggle checked', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + ...mockChannelSettings, + skip_video_folder: true, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockSubfolders), + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + const toggle = screen.getByRole('checkbox', { name: /Flat file structure/i }); + expect(toggle).toBeChecked(); + }); + }); + describe('Edge Cases', () => { test('handles missing onSettingsSaved callback', async () => { const user = userEvent.setup(); diff --git a/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx b/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx index c8490ac..f4787f6 100644 --- a/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx +++ b/client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx @@ -80,6 +80,7 @@ const DownloadSettingsDialog: React.FC = ({ const [hasUserInteracted, setHasUserInteracted] = useState(false); const [subfolderOverride, setSubfolderOverride] = useState(null); const [audioFormat, setAudioFormat] = useState(defaultAudioFormat); + const [skipVideoFolder, setSkipVideoFolder] = useState(false); // Fetch available subfolders const { subfolders, loading: subfoldersLoading } = useSubfolders(token); @@ -123,6 +124,7 @@ const DownloadSettingsDialog: React.FC = ({ setSubfolderOverride(null); setAudioFormat(defaultAudioFormat); setRating(defaultRating ?? null); + setSkipVideoFolder(false); } }, [open, defaultAudioFormat]); @@ -130,12 +132,14 @@ const DownloadSettingsDialog: React.FC = ({ const checked = event.target.checked; setUseCustomSettings(checked); setHasUserInteracted(true); - // When enabling custom settings, if rating is not set, initialize to channel/defaultRating - if (checked && (rating === null || rating === undefined)) { - // defaultRating prop may be undefined in some usages - // prefer to leave null if no defaultRating available - if (typeof defaultRating !== 'undefined' && defaultRating !== null) { - setRating(defaultRating); + if (checked) { + // When enabling custom settings, if rating is not set, initialize to channel/defaultRating + if (rating === null || rating === undefined) { + // defaultRating prop may be undefined in some usages + // prefer to leave null if no defaultRating available + if (typeof defaultRating !== 'undefined' && defaultRating !== null) { + setRating(defaultRating); + } } } }; @@ -207,7 +211,8 @@ const DownloadSettingsDialog: React.FC = ({ audioFormat: mode === 'manual' ? audioFormat : undefined, // Include rating only if custom settings are enabled (user explicitly selected it) // Use an explicit sentinel 'NR' when the user selected "No Rating" (null) - rating: useCustomSettings ? (rating === null ? 'NR' : (rating ?? undefined)) : undefined + rating: useCustomSettings ? (rating === null ? 'NR' : (rating ?? undefined)) : undefined, + skipVideoFolder: mode === 'manual' ? (useCustomSettings ? skipVideoFolder : false) : undefined }); } else { onConfirm(null); // Use defaults - post-processor will apply channel default rating @@ -507,6 +512,24 @@ const DownloadSettingsDialog: React.FC = ({ TV-MA + + { + setSkipVideoFolder(e.target.checked); + setHasUserInteracted(true); + }} + color="primary" + /> + } + label="Flat file structure (no video subfolders)" + sx={{ mb: 1 }} + /> + + Save files directly in the channel folder instead of individual video subfolders. + )} diff --git a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx index 00d2cbd..412e74e 100644 --- a/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx +++ b/client/src/components/DownloadManager/ManualDownload/__tests__/DownloadSettingsDialog.test.tsx @@ -487,6 +487,7 @@ describe('DownloadSettingsDialog', () => { allowRedownload: false, subfolder: null, audioFormat: null, + skipVideoFolder: false, rating: 'NR', }); }); @@ -615,6 +616,7 @@ describe('DownloadSettingsDialog', () => { allowRedownload: true, subfolder: null, audioFormat: null, + skipVideoFolder: false, rating: 'NR', }); }); @@ -639,6 +641,7 @@ describe('DownloadSettingsDialog', () => { allowRedownload: true, subfolder: null, audioFormat: null, + skipVideoFolder: false, rating: 'NR', }); }); @@ -780,6 +783,106 @@ describe('DownloadSettingsDialog', () => { }); }); + describe('Skip Video Folder Toggle', () => { + test('renders skipVideoFolder toggle when custom settings enabled in manual mode', () => { + render(); + + const toggle = screen.getByRole('checkbox', { name: /Use custom settings/i }); + fireEvent.click(toggle); + + expect(screen.getByLabelText('Flat file structure (no video subfolders)')).toBeInTheDocument(); + }); + + test('skipVideoFolder defaults to false', () => { + render(); + + const toggle = screen.getByRole('checkbox', { name: /Use custom settings/i }); + fireEvent.click(toggle); + + const skipToggle = screen.getByRole('checkbox', { name: /Flat file structure/i }); + expect(skipToggle).not.toBeChecked(); + }); + + test('calls onConfirm with skipVideoFolder true when toggled on', () => { + render(); + + // Enable custom settings + const customToggle = screen.getByRole('checkbox', { name: /Use custom settings/i }); + fireEvent.click(customToggle); + + // Toggle skipVideoFolder + const skipToggle = screen.getByRole('checkbox', { name: /Flat file structure/i }); + fireEvent.click(skipToggle); + expect(skipToggle).toBeChecked(); + + // Confirm + const confirmButton = screen.getByRole('button', { name: /Start Download/i }); + fireEvent.click(confirmButton); + + expect(mockOnConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + skipVideoFolder: true, + }) + ); + }); + + test('sends skipVideoFolder false when custom settings are disabled', () => { + render(); + + // Enable custom settings to access toggle + const customToggle = screen.getByRole('checkbox', { name: /Use custom settings/i }); + fireEvent.click(customToggle); + + // Toggle skipVideoFolder on + const skipToggle = screen.getByRole('checkbox', { name: /Flat file structure/i }); + fireEvent.click(skipToggle); + expect(skipToggle).toBeChecked(); + + // Also enable re-download so hasOverride is true even with custom off + const redownloadToggle = screen.getByRole('checkbox', { name: /Allow re-downloading/i }); + fireEvent.click(redownloadToggle); + + // Disable custom settings + fireEvent.click(customToggle); + + const confirmButton = screen.getByRole('button', { name: /Start Download/i }); + fireEvent.click(confirmButton); + + // Payload should have skipVideoFolder: false when custom settings are off + expect(mockOnConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + skipVideoFolder: false, + }) + ); + }); + + test('resets skipVideoFolder when dialog is closed and reopened', () => { + const { rerender } = render(); + + // Enable custom settings and toggle skipVideoFolder + const customToggle = screen.getByRole('checkbox', { name: /Use custom settings/i }); + fireEvent.click(customToggle); + + const skipToggle = screen.getByRole('checkbox', { name: /Flat file structure/i }); + fireEvent.click(skipToggle); + expect(skipToggle).toBeChecked(); + + // Close dialog + rerender(); + + // Reopen dialog + rerender(); + + // Enable custom settings again + const newCustomToggle = screen.getByRole('checkbox', { name: /Use custom settings/i }); + fireEvent.click(newCustomToggle); + + // skipVideoFolder should be reset to false + const newSkipToggle = screen.getByRole('checkbox', { name: /Flat file structure/i }); + expect(newSkipToggle).not.toBeChecked(); + }); + }); + describe('Edge Cases', () => { test('handles undefined videoCount in manual mode', () => { render(); diff --git a/client/src/components/DownloadManager/ManualDownload/types.ts b/client/src/components/DownloadManager/ManualDownload/types.ts index 02d6acd..baae435 100644 --- a/client/src/components/DownloadManager/ManualDownload/types.ts +++ b/client/src/components/DownloadManager/ManualDownload/types.ts @@ -18,6 +18,7 @@ export interface DownloadSettings { subfolder?: string | null; audioFormat?: string | null; rating?: string | null; + skipVideoFolder?: boolean; } export interface ValidationResponse { diff --git a/client/src/hooks/useTriggerDownloads.ts b/client/src/hooks/useTriggerDownloads.ts index aa6fc74..06a13ff 100644 --- a/client/src/hooks/useTriggerDownloads.ts +++ b/client/src/hooks/useTriggerDownloads.ts @@ -6,6 +6,7 @@ interface DownloadOverrideSettings { subfolder?: string | null; audioFormat?: string | null; rating?: string | null; + skipVideoFolder?: boolean; } interface TriggerDownloadsParams { @@ -43,7 +44,8 @@ export function useTriggerDownloads(token: string | null): UseTriggerDownloadsRe allowRedownload: overrideSettings.allowRedownload, subfolder: overrideSettings.subfolder, audioFormat: overrideSettings.audioFormat, - rating: overrideSettings.rating + rating: overrideSettings.rating, + skipVideoFolder: overrideSettings.skipVideoFolder }; } diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index e899a07..21c9e64 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -81,7 +81,8 @@ Add a single YouTube video to the download queue. { "url": "https://www.youtube.com/watch?v=VIDEO_ID", "resolution": "1080", - "subfolder": "Movies" + "subfolder": "Movies", + "skipVideoFolder": true } ``` @@ -90,6 +91,7 @@ Add a single YouTube video to the download queue. | `url` | Yes | string | YouTube video URL | | `resolution` | No | string | Override resolution (360, 480, 720, 1080, 1440, 2160) | | `subfolder` | No | string | Override download subfolder | +| `skipVideoFolder` | No | boolean | When `true`, download files directly into the channel folder without creating a video subfolder | **Success Response (200):** ```json diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 9790f56..842d65d 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -27,9 +27,9 @@ Download specific YouTube videos manually without subscribing to channels. - Repeat for each video you want to queue - Every URL is validated and previewed with video metadata before it is added -3. **Customize resolution settings** (optional) - - Choose a specific resolution for this download - - Or leave it at the default to use your global quality setting +3. **Customize download settings** (optional) + - Choose a specific resolution for this download, or leave it at the default to use your global quality setting + - Enable **Flat file structure (no video subfolders)** to download files directly into the channel folder without creating individual video subfolders 4. **Click "Start Download"** - The download will begin immediately @@ -61,6 +61,7 @@ Subscribe to YouTube channels to automatically download new videos as they're pu - Click the settings icon (gear) to access channel settings: - **Custom subfolder**: Organize channels into separate media libraries (e.g., `__kids`, `__music`) - **Quality override**: Set a channel-specific resolution preference that overrides the global setting + - **Flat file structure**: Download videos directly into the channel folder without individual video subfolders (see [Folder Structure](YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md)) - **Auto-download controls**: Enable/disable automatic downloads separately for: - `Videos` - `Shorts` diff --git a/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md b/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md index c89fe48..0d046ed 100644 --- a/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md +++ b/docs/YOUTARR_DOWNLOADS_FOLDER_STRUCTURE.md @@ -2,7 +2,7 @@ Youtarr downloads videos into folders named for the channel they came from. Channels can be configured in the web UI to place the channel folder into a subfolder, which will be prefixed with `__` to allow grouping channels together. This allows you to setup different libraries in your media server of choice for different channel groups. -In each channel folder videos will be placed into their own subfolders with associated metadata files. +By default, videos in each channel folder are placed into their own subfolders with associated metadata files. This can be changed to a flat file structure on a per-channel basis (see below). ### Expected Default Layout @@ -39,3 +39,36 @@ YouTube Downloads/ └── Regular Channel/ # Channel with no subfolder setting └── [videos] ``` + +## Layout For Channels with Flat File Structure (No Video Subfolders) + +Channels can be configured to use a flat file structure, where video files are placed directly in the channel folder instead of individual video subfolders. This is a per-channel setting configured in the channel settings dialog ("Flat file structure (no video subfolders)") and only affects new downloads. + +The same option is available as a one-time override in the manual download settings dialog. + +``` +/ +├── Channel Name/ +│ ├── poster.jpg # Channel poster +│ ├── Channel - Video [id].mp4 # Video file +│ ├── Channel - Video [id].nfo # Video metadata +│ ├── Channel - Video [id].[lang].srt # Subtitle file(s) +│ ├── Channel - Video [id].jpg # Video thumbnail +│ ├── Channel - Another Video [id].mp4 +│ ├── Channel - Another Video [id].nfo +│ ├── Channel - Another Video [id].[lang].srt +│ └── Channel - Another Video [id].jpg +``` + +This also works in combination with subfolder settings: + +``` +YouTube Downloads/ +├── __Kids/ +│ └── Channel Name/ # Flat structure + subfolder +│ ├── poster.jpg +│ ├── Channel - Video [id].mp4 +│ ├── Channel - Video [id].nfo +│ ├── Channel - Video [id].[lang].srt +│ └── Channel - Video [id].jpg +``` diff --git a/migrations/20260222034147-add-skip-video-folder-to-channels.js b/migrations/20260222034147-add-skip-video-folder-to-channels.js new file mode 100644 index 0000000..a94d95b --- /dev/null +++ b/migrations/20260222034147-add-skip-video-folder-to-channels.js @@ -0,0 +1,17 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('channels', 'skip_video_folder', { + type: Sequelize.BOOLEAN, + allowNull: true, + defaultValue: null, + comment: 'When true, videos are stored directly in the channel folder without per-video subfolders', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('channels', 'skip_video_folder'); + } +}; diff --git a/server/models/channel.js b/server/models/channel.js index 4604237..06a7061 100644 --- a/server/models/channel.js +++ b/server/models/channel.js @@ -92,6 +92,12 @@ Channel.init( defaultValue: null, comment: 'Default rating to apply to unrated videos in this channel', }, + skip_video_folder: { + type: DataTypes.BOOLEAN, + allowNull: true, + defaultValue: null, + comment: 'When true, videos are stored directly in the channel folder without per-video subfolders', + }, }, { sequelize, diff --git a/server/modules/__tests__/channelDownloadGrouper.test.js b/server/modules/__tests__/channelDownloadGrouper.test.js index 63fba84..a02f2f0 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","audio":null}'); + expect(key).toBe('{"min":300,"max":3600,"regex":"test.*regex","audio":null,"skipVF":false}'); }); 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,"audio":null}'); + expect(key).toBe('{"min":null,"max":null,"regex":null,"audio":null,"skipVF":false}'); }); it('should build different keys for different filters', () => { @@ -86,35 +86,35 @@ describe('ChannelDownloadGrouper', () => { }); }); - describe('hasFilters', () => { + describe('hasGroupingCriteria', () => { it('should return true when minDuration is set', () => { const filterConfig = new ChannelFilterConfig(300, null, null); - expect(filterConfig.hasFilters()).toBe(true); + expect(filterConfig.hasGroupingCriteria()).toBe(true); }); it('should return true when maxDuration is set', () => { const filterConfig = new ChannelFilterConfig(null, 3600, null); - expect(filterConfig.hasFilters()).toBe(true); + expect(filterConfig.hasGroupingCriteria()).toBe(true); }); it('should return true when titleFilterRegex is set', () => { const filterConfig = new ChannelFilterConfig(null, null, 'test'); - expect(filterConfig.hasFilters()).toBe(true); + expect(filterConfig.hasGroupingCriteria()).toBe(true); }); it('should return true when all filters are set', () => { const filterConfig = new ChannelFilterConfig(300, 3600, 'test'); - expect(filterConfig.hasFilters()).toBe(true); + expect(filterConfig.hasGroupingCriteria()).toBe(true); }); it('should return false when no filters are set', () => { const filterConfig = new ChannelFilterConfig(null, null, null); - expect(filterConfig.hasFilters()).toBe(false); + expect(filterConfig.hasGroupingCriteria()).toBe(false); }); it('should return false for default constructor', () => { const filterConfig = new ChannelFilterConfig(); - expect(filterConfig.hasFilters()).toBe(false); + expect(filterConfig.hasGroupingCriteria()).toBe(false); }); }); @@ -194,7 +194,8 @@ describe('ChannelDownloadGrouper', () => { 'min_duration', 'max_duration', 'title_filter_regex', - 'audio_format' + 'audio_format', + 'skip_video_folder' ] }); expect(result).toEqual(mockChannels); diff --git a/server/modules/__tests__/downloadModule.test.js b/server/modules/__tests__/downloadModule.test.js index dce46e7..6a096dd 100644 --- a/server/modules/__tests__/downloadModule.test.js +++ b/server/modules/__tests__/downloadModule.test.js @@ -360,7 +360,7 @@ describe('DownloadModule', () => { minDuration: 300, maxDuration: 3600, titleFilterRegex: null, - hasFilters: jest.fn().mockReturnValue(true) + hasGroupingCriteria: jest.fn().mockReturnValue(true) }, channels: [{ channel_id: 'UC1' }] } @@ -370,7 +370,7 @@ describe('DownloadModule', () => { await downloadModule.doChannelDownloads(); - expect(groups[0].filterConfig.hasFilters).toHaveBeenCalled(); + expect(groups[0].filterConfig.hasGroupingCriteria).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith({}, groups, false); }); @@ -421,7 +421,8 @@ describe('DownloadModule', () => { expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('720', true); expect(mockDownloadExecutor.doDownload).toHaveBeenCalled(); - const args = mockDownloadExecutor.doDownload.mock.calls[0][0]; + const doDownloadCall = mockDownloadExecutor.doDownload.mock.calls[0]; + const args = doDownloadCall[0]; expect(args).toContain('--playlist-end'); expect(args).toContain('5'); }); @@ -753,7 +754,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, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('480', false, null, undefined, null, false); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--playlist-end', '5' @@ -763,7 +764,10 @@ describe('DownloadModule', () => { 0, null, false, - true + true, + null, + undefined, + false ); }); @@ -783,7 +787,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, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('1080', true, null, undefined, null, false); }); it('should pass filterConfig to YtdlpCommandBuilder when group has filters', async () => { @@ -791,7 +795,7 @@ describe('DownloadModule', () => { minDuration: 300, maxDuration: 3600, titleFilterRegex: 'test.*', - hasFilters: jest.fn().mockReturnValue(true) + hasGroupingCriteria: jest.fn().mockReturnValue(true) }; const group = { quality: '1080', @@ -808,7 +812,7 @@ describe('DownloadModule', () => { await downloadModule.executeGroupDownload(group, mockJobId, 'Channel Downloads - Group 1/1 (1080p)', {}, true); // Verify filterConfig is passed as the 4th parameter, audioFormat is null when not in filterConfig - expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('1080', false, null, mockFilterConfig, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgs).toHaveBeenCalledWith('1080', false, null, mockFilterConfig, null, false); }); it('should pass skipJobTransition flag to doDownload', async () => { @@ -827,7 +831,10 @@ describe('DownloadModule', () => { 0, null, false, - true // skipJobTransition + true, // skipJobTransition + null, + undefined, + false // skipVideoFolder ); }); @@ -885,7 +892,7 @@ describe('DownloadModule', () => { }), false ); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false, null, false); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--format', 'best[height<=1080]', @@ -901,7 +908,8 @@ describe('DownloadModule', () => { false, false, null, - undefined + undefined, + false ); }); @@ -933,7 +941,8 @@ describe('DownloadModule', () => { false, false, null, - undefined + undefined, + false ); }); @@ -960,7 +969,8 @@ describe('DownloadModule', () => { false, false, null, - undefined + undefined, + false ); }); @@ -977,7 +987,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('480', false, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('480', false, null, false); }); it('should respect channel-level quality override when present', async () => { @@ -995,9 +1005,9 @@ describe('DownloadModule', () => { expect(ChannelModelMock.findOne).toHaveBeenCalledWith({ where: { channel_id: 'UC123456' }, - attributes: ['video_quality', 'audio_format'] + attributes: ['video_quality', 'audio_format', 'skip_video_folder'] }); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, null, false); }); it('should respect channel-level audio_format when no override provided', async () => { @@ -1013,7 +1023,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, 'mp3_only'); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, 'mp3_only', false); }); it('should prioritize override audioFormat over channel audio_format', async () => { @@ -1032,7 +1042,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, 'video_mp3'); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, 'video_mp3', false); }); it('should allow null audioFormat override to bypass channel mp3_only setting', async () => { @@ -1051,7 +1061,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', false, null, false); }); it('should handle allowRedownload override setting', async () => { @@ -1068,7 +1078,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', true, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('720', true, null, false); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--format', 'best[height<=720]', @@ -1083,7 +1093,8 @@ describe('DownloadModule', () => { true, false, null, - undefined + undefined, + false ); // Verify that --download-archive is NOT in the arguments when allowRedownload is true const callArgs = mockDownloadExecutor.doDownload.mock.calls[0][0]; @@ -1104,7 +1115,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('480', false, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('480', false, null, false); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--format', 'best[height<=480]', @@ -1119,7 +1130,8 @@ describe('DownloadModule', () => { false, false, null, - undefined + undefined, + false ); }); @@ -1136,7 +1148,7 @@ describe('DownloadModule', () => { await downloadModule.doSpecificDownloads(request); - expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false, null); + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false, null, false); expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( expect.arrayContaining([ '--download-archive', './config/complete.list', @@ -1149,7 +1161,8 @@ describe('DownloadModule', () => { false, false, null, - undefined + undefined, + false ); }); @@ -1178,7 +1191,8 @@ describe('DownloadModule', () => { false, false, 'Movies', - undefined + undefined, + false ); }); @@ -1205,7 +1219,8 @@ describe('DownloadModule', () => { false, false, null, - undefined + undefined, + false ); }); @@ -1231,7 +1246,95 @@ describe('DownloadModule', () => { false, false, '', - undefined + undefined, + false + ); + }); + + it('should apply skipVideoFolder from override settings', async () => { + jobModuleMock.getJob.mockReturnValue({ status: 'In Progress' }); + const request = { + body: { + urls: ['https://youtube.com/watch?v=test'], + overrideSettings: { + skipVideoFolder: true + } + } + }; + + await downloadModule.doSpecificDownloads(request); + + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false, null, true); + expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( + expect.any(Array), + mockJobId, + 'Manually Added Urls', + 1, + ['https://youtube.com/watch?v=test'], + false, + false, + null, + undefined, + true + ); + }); + + it('should use channel skip_video_folder when no override provided', async () => { + jobModuleMock.getJob.mockReturnValue({ status: 'In Progress' }); + ChannelModelMock.findOne.mockResolvedValue({ video_quality: null, audio_format: null, skip_video_folder: true }); + + const request = { + body: { + urls: ['https://youtube.com/watch?v=test'], + channelId: 'UC123456' + } + }; + + await downloadModule.doSpecificDownloads(request); + + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false, null, true); + expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( + expect.any(Array), + mockJobId, + 'Manually Added Urls', + 1, + ['https://youtube.com/watch?v=test'], + false, + false, + null, + undefined, + true + ); + }); + + it('should prioritize override skipVideoFolder over channel setting', async () => { + jobModuleMock.getJob.mockReturnValue({ status: 'In Progress' }); + ChannelModelMock.findOne.mockResolvedValue({ video_quality: null, audio_format: null, skip_video_folder: true }); + + const request = { + body: { + urls: ['https://youtube.com/watch?v=test'], + channelId: 'UC123456', + overrideSettings: { + skipVideoFolder: false + } + } + }; + + await downloadModule.doSpecificDownloads(request); + + expect(YtdlpCommandBuilderMock.getBaseCommandArgsForManualDownload).toHaveBeenCalledWith('1080', false, null, false); + expect(mockDownloadExecutor.doDownload).toHaveBeenCalledWith( + expect.any(Array), + mockJobId, + 'Manually Added Urls', + 1, + ['https://youtube.com/watch?v=test'], + false, + false, + null, + undefined, + false ); }); @@ -1290,7 +1393,8 @@ describe('DownloadModule', () => { false, false, null, - undefined + undefined, + false ); }); }); diff --git a/server/modules/__tests__/videoDeletionModule.test.js b/server/modules/__tests__/videoDeletionModule.test.js index 70a89d2..42856ed 100644 --- a/server/modules/__tests__/videoDeletionModule.test.js +++ b/server/modules/__tests__/videoDeletionModule.test.js @@ -24,7 +24,9 @@ describe('VideoDeletionModule', () => { // Mock fs.promises mockFs = { - rm: jest.fn() + rm: jest.fn(), + readdir: jest.fn().mockResolvedValue([]), + unlink: jest.fn() }; // Mock the models @@ -37,6 +39,12 @@ describe('VideoDeletionModule', () => { promises: mockFs })); + // Mock the filesystem module (isVideoDirectory) + // Default to true (nested structure) for backwards compatibility with existing tests + jest.doMock('../filesystem', () => ({ + isVideoDirectory: jest.fn(() => true) + })); + // Require the module after mocks are in place VideoDeletionModule = require('../videoDeletionModule'); }); @@ -147,7 +155,7 @@ describe('VideoDeletionModule', () => { expect(result).toEqual({ success: false, videoId: 1, - error: 'Safety check failed: invalid directory path' + error: 'Safety check failed: invalid file path' }); expect(mockFs.rm).not.toHaveBeenCalled(); }); @@ -171,7 +179,7 @@ describe('VideoDeletionModule', () => { expect(mockLogger.info).toHaveBeenCalledWith( expect.objectContaining({ videoId: 1 }), - 'Directory already removed' + 'Files already removed' ); expect(mockVideoRecord.update).toHaveBeenCalledWith({ removed: true }); expect(result).toEqual({ @@ -199,7 +207,7 @@ describe('VideoDeletionModule', () => { expect(mockLogger.error).toHaveBeenCalledWith( expect.objectContaining({ videoId: 1, err: permissionError }), - 'Failed to delete directory' + 'Failed to delete video files' ); expect(result).toEqual({ success: false, @@ -402,7 +410,7 @@ describe('VideoDeletionModule', () => { expect(result.failed[0]).toEqual({ videoId: 1, - error: 'Safety check failed: invalid directory path' + error: 'Safety check failed: invalid file path' }); }); }); @@ -537,7 +545,7 @@ describe('VideoDeletionModule', () => { failed: [ { youtubeId: 'abc123', - error: 'Safety check failed: invalid directory path' + error: 'Safety check failed: invalid file path' } ] }); diff --git a/server/modules/channelDownloadGrouper.js b/server/modules/channelDownloadGrouper.js index 7ff6616..df54a32 100644 --- a/server/modules/channelDownloadGrouper.js +++ b/server/modules/channelDownloadGrouper.js @@ -7,11 +7,12 @@ const { buildOutputTemplate, buildThumbnailTemplate } = require('./filesystem'); * Encapsulates channel filter settings for download filtering */ class ChannelFilterConfig { - constructor(minDuration = null, maxDuration = null, titleFilterRegex = null, audioFormat = null) { + constructor(minDuration = null, maxDuration = null, titleFilterRegex = null, audioFormat = null, skipVideoFolder = false) { this.minDuration = minDuration; this.maxDuration = maxDuration; this.titleFilterRegex = titleFilterRegex; this.audioFormat = audioFormat; + this.skipVideoFolder = !!skipVideoFolder; } /** @@ -25,19 +26,25 @@ class ChannelFilterConfig { min: this.minDuration, max: this.maxDuration, regex: this.titleFilterRegex, - audio: this.audioFormat + audio: this.audioFormat, + skipVF: this.skipVideoFolder }); } /** - * Check if any filters are set - * @returns {boolean} - True if at least one filter is configured + * Check if any grouping criteria (filters or structural settings) are set. + * Includes duration/title filters and structural options like skipVideoFolder + * that affect how downloads are organized on disk. + * @returns {boolean} - True if at least one criterion is configured */ - hasFilters() { + hasGroupingCriteria() { return this.minDuration !== null || this.maxDuration !== null || this.titleFilterRegex !== null || - this.audioFormat !== null; + this.audioFormat !== null || + // skipVideoFolder affects download path structure, so channels with + // different settings must be in separate download groups + this.skipVideoFolder; } /** @@ -50,7 +57,8 @@ class ChannelFilterConfig { channel.min_duration, channel.max_duration, channel.title_filter_regex, - channel.audio_format + channel.audio_format, + channel.skip_video_folder ); } } @@ -76,7 +84,8 @@ class ChannelDownloadGrouper { 'min_duration', 'max_duration', 'title_filter_regex', - 'audio_format' + 'audio_format', + 'skip_video_folder' ] }); diff --git a/server/modules/channelSettingsModule.js b/server/modules/channelSettingsModule.js index 3280bf9..b0d08a2 100644 --- a/server/modules/channelSettingsModule.js +++ b/server/modules/channelSettingsModule.js @@ -273,6 +273,18 @@ class ChannelSettingsModule { return { valid: true }; } + /** + * Validate skip_video_folder setting + * @param {boolean|null} value - Skip video folder setting to validate + * @returns {Object} - { valid: boolean, error?: string } + */ + validateSkipVideoFolder(value) { + if (value === null || value === undefined || value === true || value === false) { + return { valid: true }; + } + return { valid: false, error: 'skip_video_folder must be true, false, or null' }; + } + /** * Get the full directory path for a channel, including subfolder if set * @param {Object} channel - Channel database record @@ -497,6 +509,7 @@ class ChannelSettingsModule { title_filter_regex: channel.title_filter_regex, audio_format: channel.audio_format, default_rating: channel.default_rating, + skip_video_folder: channel.skip_video_folder, }; } @@ -585,6 +598,14 @@ class ChannelSettingsModule { } } + // Validate skip_video_folder if provided + if (settings.skip_video_folder !== undefined) { + const validation = this.validateSkipVideoFolder(settings.skip_video_folder); + 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 ? @@ -621,6 +642,9 @@ class ChannelSettingsModule { if (settings.audio_format !== undefined) { updateData.audio_format = settings.audio_format; } + if (settings.skip_video_folder !== undefined) { + updateData.skip_video_folder = settings.skip_video_folder; + } // Update database FIRST to ensure changes are persisted before slow file operations // This prevents issues where HTTP requests timeout during file operations @@ -667,6 +691,7 @@ class ChannelSettingsModule { title_filter_regex: updatedChannel.title_filter_regex, audio_format: updatedChannel.audio_format, default_rating: updatedChannel.default_rating, + skip_video_folder: updatedChannel.skip_video_folder, }, folderMoved: subFolderChanged, moveResult diff --git a/server/modules/download/__tests__/downloadExecutor.test.js b/server/modules/download/__tests__/downloadExecutor.test.js index 6a46991..94baef9 100644 --- a/server/modules/download/__tests__/downloadExecutor.test.js +++ b/server/modules/download/__tests__/downloadExecutor.test.js @@ -299,7 +299,7 @@ describe('DownloadExecutor', () => { expect(mockVideoDownload.destroy).toHaveBeenCalled(); }); - it('should skip non-video directories', async () => { + it('should clean up individual files in flat mode (non-video directories)', async () => { const mockVideoDownload = { youtube_id: 'abc123XYZ_d', file_path: '/output/Channel', @@ -308,15 +308,32 @@ describe('DownloadExecutor', () => { JobVideoDownload.findAll.mockResolvedValue([mockVideoDownload]); mockFsPromises.access.mockResolvedValue(); // Directory exists - // Mock filesystem.isVideoDirectory to return false for this path + // Mock filesystem.isVideoDirectory to return false (flat mode) filesystem.isVideoDirectory.mockReturnValue(false); + // Mock directory contents with matching files + mockFsPromises.readdir.mockResolvedValue([ + 'Channel - Title [abc123XYZ_d].mp4', + 'Channel - Title [abc123XYZ_d].jpg', + 'other-video.mp4' + ]); + mockFsPromises.stat.mockResolvedValue({ isFile: () => true, isDirectory: () => false }); + mockFsPromises.unlink.mockResolvedValue(); await executor.cleanupInProgressVideos('job-123'); - expect(logger.info).toHaveBeenCalledWith({ dirPath: '/output/Channel' }, 'Skipping non-video directory'); + expect(logger.info).toHaveBeenCalledWith( + { youtubeId: 'abc123XYZ_d', dirPath: '/output/Channel' }, + 'Flat structure detected, cleaning up individual files' + ); + // Should NOT remove the directory itself expect(mockFsPromises.rmdir).not.toHaveBeenCalled(); - // Should still destroy the entry since cleanup was attempted - expect(mockVideoDownload.destroy).not.toHaveBeenCalled(); + // Should delete files matching the youtube ID + expect(mockFsPromises.unlink).toHaveBeenCalledWith('/output/Channel/Channel - Title [abc123XYZ_d].mp4'); + expect(mockFsPromises.unlink).toHaveBeenCalledWith('/output/Channel/Channel - Title [abc123XYZ_d].jpg'); + // Should NOT delete unrelated files + expect(mockFsPromises.unlink).not.toHaveBeenCalledWith('/output/Channel/other-video.mp4'); + // Should destroy the tracking entry + expect(mockVideoDownload.destroy).toHaveBeenCalled(); }); it('should check temp location when file path is final path', async () => { diff --git a/server/modules/download/__tests__/ytdlpCommandBuilder.test.js b/server/modules/download/__tests__/ytdlpCommandBuilder.test.js index a39d914..a9a525e 100644 --- a/server/modules/download/__tests__/ytdlpCommandBuilder.test.js +++ b/server/modules/download/__tests__/ytdlpCommandBuilder.test.js @@ -383,21 +383,21 @@ describe('YtdlpCommandBuilder', () => { expect(result).toBe('availability!=subscriber_only & !is_live & live_status!=is_upcoming'); }); - it('should return base filters when filterConfig.hasFilters is false', () => { - const filterConfig = { hasFilters: false }; + it('should return base filters when filterConfig.hasGroupingCriteria is false', () => { + const filterConfig = { hasGroupingCriteria: false }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); expect(result).toBe('availability!=subscriber_only & !is_live & live_status!=is_upcoming'); }); - it('should return base filters when filterConfig.hasFilters() returns false', () => { - const filterConfig = { hasFilters: () => false }; + it('should return base filters when filterConfig.hasGroupingCriteria() returns false', () => { + const filterConfig = { hasGroupingCriteria: () => false }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); expect(result).toBe('availability!=subscriber_only & !is_live & live_status!=is_upcoming'); }); it('should add minimum duration filter when specified', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, minDuration: 300 // 5 minutes }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); @@ -406,7 +406,7 @@ describe('YtdlpCommandBuilder', () => { it('should add maximum duration filter when specified', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, maxDuration: 600 // 10 minutes }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); @@ -416,7 +416,7 @@ describe('YtdlpCommandBuilder', () => { it('should add both min and max duration filters when specified', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, minDuration: 60, // 1 minute maxDuration: 1800 // 30 minutes }; @@ -426,7 +426,7 @@ describe('YtdlpCommandBuilder', () => { it('should add title regex filter when specified', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, titleFilterRegex: 'tutorial' }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); @@ -435,7 +435,7 @@ describe('YtdlpCommandBuilder', () => { it('should escape backslashes in title regex', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, titleFilterRegex: '\\d+' // Match one or more digits }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); @@ -444,7 +444,7 @@ describe('YtdlpCommandBuilder', () => { it('should escape single quotes in title regex', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, titleFilterRegex: 'Let\'s Go' }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); @@ -453,7 +453,7 @@ describe('YtdlpCommandBuilder', () => { it('should escape both backslashes and quotes in complex regex', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, titleFilterRegex: 'Part \\d+: It\'s Here' }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); @@ -462,7 +462,7 @@ describe('YtdlpCommandBuilder', () => { it('should combine all filters when specified', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, minDuration: 120, maxDuration: 900, titleFilterRegex: 'review' @@ -473,7 +473,7 @@ describe('YtdlpCommandBuilder', () => { it('should handle zero as minimum duration', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, minDuration: 0 }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); @@ -482,7 +482,7 @@ describe('YtdlpCommandBuilder', () => { it('should handle zero as maximum duration', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, maxDuration: 0 }; const result = YtdlpCommandBuilder.buildMatchFilters(filterConfig); @@ -491,7 +491,7 @@ describe('YtdlpCommandBuilder', () => { it('should ignore null minimum duration', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, minDuration: null, maxDuration: 600 }; @@ -501,7 +501,7 @@ describe('YtdlpCommandBuilder', () => { it('should ignore undefined maximum duration', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, minDuration: 180, maxDuration: undefined }; @@ -511,7 +511,7 @@ describe('YtdlpCommandBuilder', () => { it('should handle empty string title regex as no filter', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, titleFilterRegex: '', minDuration: 60 }; @@ -712,7 +712,7 @@ describe('YtdlpCommandBuilder', () => { it('should apply filterConfig with minimum duration', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, minDuration: 300 }; const result = YtdlpCommandBuilder.getBaseCommandArgs('1080', false, null, filterConfig); @@ -722,7 +722,7 @@ describe('YtdlpCommandBuilder', () => { it('should apply filterConfig with maximum duration', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, maxDuration: 900 }; const result = YtdlpCommandBuilder.getBaseCommandArgs('1080', false, null, filterConfig); @@ -732,7 +732,7 @@ describe('YtdlpCommandBuilder', () => { it('should apply filterConfig with title regex', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, titleFilterRegex: 'gameplay' }; const result = YtdlpCommandBuilder.getBaseCommandArgs('1080', false, null, filterConfig); @@ -742,7 +742,7 @@ describe('YtdlpCommandBuilder', () => { it('should apply filterConfig with all filters combined', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, minDuration: 60, maxDuration: 600, titleFilterRegex: 'tutorial' @@ -754,7 +754,7 @@ describe('YtdlpCommandBuilder', () => { it('should work with subfolder and filterConfig together', () => { const filterConfig = { - hasFilters: true, + hasGroupingCriteria: true, minDuration: 120 }; const result = YtdlpCommandBuilder.getBaseCommandArgs('1080', false, 'MyChannel', filterConfig); diff --git a/server/modules/download/downloadExecutor.js b/server/modules/download/downloadExecutor.js index ef84497..8ca0dc4 100644 --- a/server/modules/download/downloadExecutor.js +++ b/server/modules/download/downloadExecutor.js @@ -149,7 +149,28 @@ class DownloadExecutor { foundExistingPath = true; if (!filesystem.isVideoDirectory(dirPath)) { - logger.info({ dirPath }, 'Skipping non-video directory'); + // Flat mode (no video subfolder) - only delete files matching the youtube ID + const youtubeId = videoDownload.youtube_id; + logger.info({ youtubeId, dirPath }, 'Flat structure detected, cleaning up individual files'); + + const dirFiles = await fsPromises.readdir(dirPath); + for (const fileName of dirFiles) { + // Match files by YouTube ID: bracketed form [ID] is the yt-dlp default; + // dash form " - ID" is a fallback for non-standard naming patterns + if (fileName.includes(`[${youtubeId}]`) || fileName.includes(` - ${youtubeId}`)) { + const fullPath = path.join(dirPath, fileName); + try { + const stats = await fsPromises.stat(fullPath); + if (stats.isFile()) { + await fsPromises.unlink(fullPath); + logger.info({ fileName }, 'Removed file (flat mode)'); + } + } catch (fileError) { + logger.error({ err: fileError, fileName }, 'Error removing file (flat mode)'); + } + } + } + cleanedAny = true; continue; } @@ -431,7 +452,7 @@ class DownloadExecutor { this.pendingProgressMessage = null; } - async doDownload(args, jobId, jobType, urlCount = 0, originalUrls = null, allowRedownload = false, skipJobTransition = false, subfolderOverride = null, ratingOverride = undefined) { + async doDownload(args, jobId, jobType, urlCount = 0, originalUrls = null, allowRedownload = false, skipJobTransition = false, subfolderOverride = null, ratingOverride = undefined, skipVideoFolder = false) { const initialCount = this.getCountOfDownloadedVideos(); const config = configModule.getConfig(); const monitor = new DownloadProgressMonitor(jobId, jobType); @@ -501,6 +522,11 @@ class DownloadExecutor { procEnv.YOUTARR_SUBFOLDER_OVERRIDE = subfolderOverride; } + // Pass skip video folder flag to post-processor + if (skipVideoFolder) { + procEnv.YOUTARR_SKIP_VIDEO_FOLDER = 'true'; + } + // Pass explicit rating override if provided // Accept null as an explicit "clear rating" sentinel and map it to 'NR' if (ratingOverride !== undefined) { diff --git a/server/modules/download/ytdlpCommandBuilder.js b/server/modules/download/ytdlpCommandBuilder.js index 8185995..ba18b1d 100644 --- a/server/modules/download/ytdlpCommandBuilder.js +++ b/server/modules/download/ytdlpCommandBuilder.js @@ -12,37 +12,43 @@ class YtdlpCommandBuilder { * Build output path with optional subfolder support * Downloads always go to temp path first, then get moved to final location * @param {string|null} subFolder - Optional subfolder name + * @param {boolean} skipVideoFolder - If true, skip the video subfolder level (flat structure) * @returns {string} - Full output path template */ - static buildOutputPath(subFolder = null) { + static buildOutputPath(subFolder = null, skipVideoFolder = false) { // Always use temp path - downloads are staged before moving to final location const baseOutputPath = tempPathManager.getTempBasePath(); - if (subFolder) { - return path.join(baseOutputPath, subFolder, CHANNEL_TEMPLATE, VIDEO_FOLDER_TEMPLATE, VIDEO_FILE_TEMPLATE); - } else { - return path.join(baseOutputPath, CHANNEL_TEMPLATE, VIDEO_FOLDER_TEMPLATE, VIDEO_FILE_TEMPLATE); - } + const segments = [baseOutputPath]; + if (subFolder) segments.push(subFolder); + segments.push(CHANNEL_TEMPLATE); + if (!skipVideoFolder) segments.push(VIDEO_FOLDER_TEMPLATE); + segments.push(VIDEO_FILE_TEMPLATE); + + return path.join(...segments); } /** * Build thumbnail output path with optional subfolder support * Thumbnails are staged in temp path alongside videos * @param {string|null} subFolder - Optional subfolder name + * @param {boolean} skipVideoFolder - If true, skip the video subfolder level (flat structure) * @returns {string} - Thumbnail path template */ - static buildThumbnailPath(subFolder = null) { + static buildThumbnailPath(subFolder = null, skipVideoFolder = false) { // Always use temp path - thumbnails are staged with videos const baseOutputPath = tempPathManager.getTempBasePath(); // Use same filename as video file (without extension - yt-dlp adds .jpg) const thumbnailFilename = `${CHANNEL_TEMPLATE} - %(title).76B [%(id)s]`; - if (subFolder) { - return path.join(baseOutputPath, subFolder, CHANNEL_TEMPLATE, VIDEO_FOLDER_TEMPLATE, thumbnailFilename); - } else { - return path.join(baseOutputPath, CHANNEL_TEMPLATE, VIDEO_FOLDER_TEMPLATE, thumbnailFilename); - } + const segments = [baseOutputPath]; + if (subFolder) segments.push(subFolder); + segments.push(CHANNEL_TEMPLATE); + if (!skipVideoFolder) segments.push(VIDEO_FOLDER_TEMPLATE); + segments.push(thumbnailFilename); + + return path.join(...segments); } /** * Build format string based on resolution, codec preference, and audio format @@ -322,9 +328,9 @@ class YtdlpCommandBuilder { // If no filter config provided or no filters set, return base filters only if ( !filterConfig || - !filterConfig.hasFilters || - (typeof filterConfig.hasFilters === 'function' && - !filterConfig.hasFilters()) + !filterConfig.hasGroupingCriteria || + (typeof filterConfig.hasGroupingCriteria === 'function' && + !filterConfig.hasGroupingCriteria()) ) { return baseFilters.join(' & '); } @@ -367,6 +373,7 @@ class YtdlpCommandBuilder { * @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) + * @param {boolean} skipVideoFolder - If true, skip the video subfolder level (flat structure) * @returns {string[]} - Array of yt-dlp command arguments */ static getBaseCommandArgs( @@ -374,14 +381,15 @@ class YtdlpCommandBuilder { allowRedownload = false, subFolder = null, filterConfig = null, - audioFormat = null + audioFormat = null, + skipVideoFolder = false ) { const config = configModule.getConfig(); const res = resolution || config.preferredResolution || '1080'; const videoCodec = config.videoCodec || 'default'; - const outputPath = this.buildOutputPath(subFolder); - const thumbnailPath = this.buildThumbnailPath(subFolder); + const outputPath = this.buildOutputPath(subFolder, skipVideoFolder); + const thumbnailPath = this.buildThumbnailPath(subFolder, skipVideoFolder); // Start with common args (includes -4, proxy, sleep-requests, cookies) const args = [ @@ -449,15 +457,16 @@ class YtdlpCommandBuilder { * @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) + * @param {boolean} skipVideoFolder - If true, skip the video subfolder level (flat structure) * @returns {string[]} - Array of yt-dlp command arguments */ - static getBaseCommandArgsForManualDownload(resolution, allowRedownload = false, audioFormat = null) { + static getBaseCommandArgsForManualDownload(resolution, allowRedownload = false, audioFormat = null, skipVideoFolder = false) { const config = configModule.getConfig(); const res = resolution || config.preferredResolution || '1080'; const videoCodec = config.videoCodec || 'default'; - const outputPath = this.buildOutputPath(null); - const thumbnailPath = this.buildThumbnailPath(null); + const outputPath = this.buildOutputPath(null, skipVideoFolder); + const thumbnailPath = this.buildThumbnailPath(null, skipVideoFolder); // Start with common args (includes -4, proxy, sleep-requests, cookies) const args = [ diff --git a/server/modules/downloadModule.js b/server/modules/downloadModule.js index 869c411..57b899f 100644 --- a/server/modules/downloadModule.js +++ b/server/modules/downloadModule.js @@ -103,7 +103,7 @@ class DownloadModule { groups.length > 1 || groups.some((g) => g.subFolder !== null) || groups.some((g) => g.quality !== effectiveGlobalQuality) || - groups.some((g) => g.filterConfig && g.filterConfig.hasFilters && g.filterConfig.hasFilters()); + groups.some((g) => g.filterConfig && g.filterConfig.hasGroupingCriteria && g.filterConfig.hasGroupingCriteria()); if (needsGrouping) { console.log(`Using grouped downloads: ${groups.length} group(s) with resolved settings`); @@ -390,7 +390,8 @@ class DownloadModule { // Pass filter config for channel-specific duration and title filtering // Pass audioFormat from filterConfig for MP3 downloads const audioFormat = group.filterConfig?.audioFormat || null; - const args = YtdlpCommandBuilder.getBaseCommandArgs(group.quality, allowRedownload, null, group.filterConfig, audioFormat); + const skipVideoFolder = group.filterConfig?.skipVideoFolder || false; + const args = YtdlpCommandBuilder.getBaseCommandArgs(group.quality, allowRedownload, null, group.filterConfig, audioFormat, skipVideoFolder); args.push('-a', tempChannelsFile); args.push('--playlist-end', String(videoCount)); @@ -398,7 +399,7 @@ class DownloadModule { this.downloadExecutor.tempChannelsFile = tempChannelsFile; // Execute download with skipJobTransition flag - await this.downloadExecutor.doDownload(args, jobId, jobType, 0, null, allowRedownload, skipJobTransition); + await this.downloadExecutor.doDownload(args, jobId, jobType, 0, null, allowRedownload, skipJobTransition, null, undefined, skipVideoFolder); } catch (err) { logger.error({ err, jobType }, 'Error executing group download'); if (tempChannelsFile) { @@ -455,7 +456,7 @@ class DownloadModule { const Channel = require('../models/channel'); channelRecord = await Channel.findOne({ where: { channel_id: channelId }, - attributes: ['video_quality', 'audio_format'], + attributes: ['video_quality', 'audio_format', 'skip_video_folder'], }); if (!effectiveQuality && channelRecord && channelRecord.video_quality) { @@ -477,10 +478,18 @@ class DownloadModule { // Persist resolved quality for any subsequent retries of this job this.setJobDataValue(jobData, 'effectiveQuality', resolution); + // Determine skipVideoFolder from override settings or channel setting + let skipVideoFolder = false; + if (overrideSettings.skipVideoFolder !== undefined) { + skipVideoFolder = !!overrideSettings.skipVideoFolder; + } else if (channelRecord && channelRecord.skip_video_folder) { + skipVideoFolder = true; + } + // For manual downloads, we don't apply duration filters but still exclude members-only // Subfolder override is passed to post-processor via environment variable // Pass audioFormat for MP3 downloads - const args = YtdlpCommandBuilder.getBaseCommandArgsForManualDownload(resolution, allowRedownload, audioFormat); + const args = YtdlpCommandBuilder.getBaseCommandArgsForManualDownload(resolution, allowRedownload, audioFormat, skipVideoFolder); // 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 @@ -538,7 +547,8 @@ class DownloadModule { false, subfolderOverride, // Pass rating override from overrideSettings if present - overrideSettings.rating !== undefined ? overrideSettings.rating : undefined + overrideSettings.rating !== undefined ? overrideSettings.rating : undefined, + skipVideoFolder ); } } diff --git a/server/modules/videoDeletionModule.js b/server/modules/videoDeletionModule.js index 8c40f30..5ff7c26 100644 --- a/server/modules/videoDeletionModule.js +++ b/server/modules/videoDeletionModule.js @@ -2,10 +2,25 @@ const { Video } = require('../models'); const fs = require('fs').promises; const path = require('path'); const logger = require('../logger'); +const { isVideoDirectory } = require('./filesystem'); class VideoDeletionModule { constructor() {} + /** + * Determine if a video's file path indicates flat structure (no video subfolder) + * In nested mode, the parent directory name ends with " - " + * In flat mode, the video file sits directly in the channel folder + * @param {string} filePath - Full path to the video file + * @returns {boolean} - True if flat structure + */ + isFlat(filePath) { + const parentDir = path.dirname(filePath); + // If the parent directory looks like a video directory (ends with " - youtubeId"), + // then this is nested mode. Otherwise, it's flat mode. + return !isVideoDirectory(parentDir); + } + /** * Prepare minimal video metadata for dry-run responses * @param {object} video @@ -62,31 +77,55 @@ class VideoDeletionModule { } // Get the video directory path - // filePath format: /path/to/channel/channel - title - id/video.mp4 - // We need to delete the parent directory (channel - title - id) + // Nested: filePath = /path/to/channel/channel - title - id/video.mp4 + // Flat: filePath = /path/to/channel/video.mp4 const videoDirectory = path.dirname(video.filePath); + const flat = this.isFlat(video.filePath); - // Safety check: ensure the directory path contains the youtube ID - // This prevents accidentally deleting the wrong directory - if (!videoDirectory.includes(video.youtubeId)) { - logger.error({ videoId, videoDirectory, youtubeId: video.youtubeId }, 'Safety check failed: directory path doesn\'t contain youtube ID'); + // Safety check: ensure the path contains the youtube ID + // This prevents accidentally deleting the wrong files + if (!video.filePath.includes(video.youtubeId)) { + logger.error({ videoId, filePath: video.filePath, youtubeId: video.youtubeId }, 'Safety check failed: file path doesn\'t contain youtube ID'); return { success: false, videoId, - error: 'Safety check failed: invalid directory path' + error: 'Safety check failed: invalid file path' }; } - // Delete the video directory + // Delete the video files try { - await fs.rm(videoDirectory, { recursive: true, force: true }); - logger.info({ videoId, videoDirectory }, 'Deleted video directory'); + if (flat) { + // Flat structure: delete only files matching this video's youtube ID + // NEVER delete the directory itself (it's the channel folder containing other videos) + logger.info({ videoId, videoDirectory, youtubeId: video.youtubeId }, 'Flat structure detected, deleting individual files'); + const files = await fs.readdir(videoDirectory); + for (const file of files) { + // Match files by YouTube ID: bracketed form [ID] is the yt-dlp default; + // dash form " - ID" is a fallback for non-standard naming patterns + if (file.includes(`[${video.youtubeId}]`) || file.includes(` - ${video.youtubeId}`)) { + const fullPath = path.join(videoDirectory, file); + try { + await fs.unlink(fullPath); + logger.info({ videoId, file }, 'Deleted video file (flat mode)'); + } catch (unlinkErr) { + if (unlinkErr.code !== 'ENOENT') { + logger.error({ videoId, file, err: unlinkErr }, 'Failed to delete file (flat mode)'); + } + } + } + } + } else { + // Nested structure: delete the entire video directory + await fs.rm(videoDirectory, { recursive: true, force: true }); + logger.info({ videoId, videoDirectory }, 'Deleted video directory'); + } } catch (fsError) { if (fsError.code === 'ENOENT') { - // Directory already gone; treat as success but still mark removed in DB - logger.info({ videoId, videoDirectory, error: fsError.message }, 'Directory already removed'); + // Directory/files already gone; treat as success but still mark removed in DB + logger.info({ videoId, videoDirectory, error: fsError.message }, 'Files already removed'); } else { - logger.error({ videoId, videoDirectory, err: fsError }, 'Failed to delete directory'); + logger.error({ videoId, videoDirectory, err: fsError }, 'Failed to delete video files'); return { success: false, videoId, diff --git a/server/modules/videoDownloadPostProcessFiles.js b/server/modules/videoDownloadPostProcessFiles.js index d923ea9..51b6596 100644 --- a/server/modules/videoDownloadPostProcessFiles.js +++ b/server/modules/videoDownloadPostProcessFiles.js @@ -13,6 +13,9 @@ const { buildChannelPath, cleanupEmptyParents, moveWithRetries, ensureDirWithRet const activeJobId = process.env.YOUTARR_JOB_ID; +// Flat mode: skip video subfolder, files go directly in channel folder +const isFlatMode = process.env.YOUTARR_SKIP_VIDEO_FOLDER === 'true'; + const videoPath = process.argv[2]; // get the media file path (video or audio) const parsedPath = path.parse(videoPath); // Note that MP4 videos contain embedded metadata for Plex @@ -31,7 +34,10 @@ const imagePath = path.join(videoDirectory, parsedPath.name + '.jpg'); // Extract the actual channel folder name that yt-dlp created (already sanitized) // This is more reliable than using jsonData.uploader which may contain special characters // that yt-dlp sanitizes differently (e.g., #, :, <, >, etc.) -const actualChannelFolderName = path.basename(path.dirname(videoDirectory)); +// In flat mode, videoDirectory IS the channel folder; in nested mode, parent is channel folder +const actualChannelFolderName = isFlatMode + ? path.basename(videoDirectory) + : path.basename(path.dirname(videoDirectory)); function shouldWriteChannelPosters() { const config = configModule.getConfig() || {}; @@ -279,9 +285,13 @@ async function copyChannelPosterIfNeeded(channelId, channelFolderPath) { if (targetChannelFolder) { // Channel has subfolder - calculate path with subfolder included - const videoDirectoryName = path.basename(videoDirectory); const videoFileName = path.basename(videoPath); - finalVideoPathForJson = path.join(targetChannelFolder, videoDirectoryName, videoFileName); + if (isFlatMode) { + finalVideoPathForJson = path.join(targetChannelFolder, videoFileName); + } else { + const videoDirectoryName = path.basename(videoDirectory); + finalVideoPathForJson = path.join(targetChannelFolder, videoDirectoryName, videoFileName); + } } else { // No subfolder - use standard temp-to-final conversion finalVideoPathForJson = tempPathManager.convertTempToFinal(videoPath); @@ -468,7 +478,7 @@ async function copyChannelPosterIfNeeded(channelId, channelFolderPath) { let finalVideoPath = videoPath; if (tempPathManager.isTempPath(videoPath)) { - logger.info('[Post-Process] Moving files from temp to final location'); + logger.info({ isFlatMode }, '[Post-Process] Moving files from temp to final location'); // Calculate target video directory based on subfolder setting const videoDirectoryName = path.basename(videoDirectory); @@ -477,19 +487,30 @@ async function copyChannelPosterIfNeeded(channelId, channelFolderPath) { let targetChannelFolderForMove; if (targetChannelFolder) { - // Channel has subfolder - move directly to subfolder location (atomic move) - targetVideoDirectory = path.join(targetChannelFolder, videoDirectoryName); + if (isFlatMode) { + // Flat mode with subfolder - files go directly into channel folder + targetVideoDirectory = targetChannelFolder; + } else { + // Nested mode with subfolder - move video directory into subfolder location (atomic move) + targetVideoDirectory = path.join(targetChannelFolder, videoDirectoryName); + } targetChannelFolderForMove = targetChannelFolder; console.log(`[Post-Process] Moving to subfolder location: ${channelSubFolder}`); } else { // No subfolder - move to standard location const standardFinalPath = tempPathManager.convertTempToFinal(videoPath); - const standardChannelFolder = path.dirname(path.dirname(standardFinalPath)); - targetVideoDirectory = path.join(standardChannelFolder, videoDirectoryName); - targetChannelFolderForMove = standardChannelFolder; + if (isFlatMode) { + // Flat mode without subfolder - channel folder is parent of final file + targetChannelFolderForMove = path.dirname(standardFinalPath); + targetVideoDirectory = targetChannelFolderForMove; + } else { + const standardChannelFolder = path.dirname(path.dirname(standardFinalPath)); + targetVideoDirectory = path.join(standardChannelFolder, videoDirectoryName); + targetChannelFolderForMove = standardChannelFolder; + } } - logger.info({ from: videoDirectory, to: targetVideoDirectory }, '[Post-Process] Moving video directory'); + logger.info({ from: videoDirectory, to: targetVideoDirectory, isFlatMode }, '[Post-Process] Moving video directory'); try { // Ensure parent channel directory exists (with retries for NFS/cross-filesystem transient errors) @@ -520,25 +541,53 @@ async function copyChannelPosterIfNeeded(channelId, channelFolderPath) { } } - // Check if target video directory already exists (rare, but handle gracefully) - const targetExists = await fs.pathExists(targetVideoDirectory); + if (isFlatMode) { + // Flat mode: move individual files from temp channel folder to final channel folder + // Filter by video ID to avoid moving files belonging to other downloads + const allFilesInDir = await fs.readdir(videoDirectory); + // Bracketed form [ID] is the yt-dlp default; dash form " - ID" is a fallback + const updatedFilesInDir = allFilesInDir.filter( + file => file.includes(`[${id}]`) || file.includes(` - ${id}`) + ); + for (const file of updatedFilesInDir) { + const srcPath = path.join(videoDirectory, file); + const destPath = path.join(targetVideoDirectory, file); + // Overwrite if the file already exists at destination + if (await fs.pathExists(destPath)) { + logger.warn({ destPath }, '[Post-Process] Target file already exists, overwriting'); + await fs.remove(destPath); + } + await moveWithRetries(srcPath, destPath, { retries: 5, delayMs: 500 }); + logger.info({ file }, '[Post-Process] Moved file (flat mode)'); + } + + // Update paths to reflect final locations + finalVideoPath = path.join(targetVideoDirectory, videoFileName); - if (targetExists) { - logger.warn({ targetVideoDirectory }, '[Post-Process] Target directory already exists, removing before move'); - await fs.remove(targetVideoDirectory); - } + logger.info({ targetVideoDirectory }, '[Post-Process] Successfully moved files to final location (flat mode)'); + } else { + // Nested mode: move the entire video directory atomically - // Move the entire video directory from temp to final location (with retries for NFS/cross-filesystem transient errors) - await moveWithRetries(videoDirectory, targetVideoDirectory, { retries: 5, delayMs: 500 }); + // Check if target video directory already exists (rare, but handle gracefully) + const targetExists = await fs.pathExists(targetVideoDirectory); - // Update paths to reflect final locations - finalVideoPath = path.join(targetVideoDirectory, videoFileName); + if (targetExists) { + logger.warn({ targetVideoDirectory }, '[Post-Process] Target directory already exists, removing before move'); + await fs.remove(targetVideoDirectory); + } + + // Move the entire video directory from temp to final location (with retries for NFS/cross-filesystem transient errors) + await moveWithRetries(videoDirectory, targetVideoDirectory, { retries: 5, delayMs: 500 }); - logger.info({ targetVideoDirectory }, '[Post-Process] Successfully moved to final location'); + // Update paths to reflect final locations + finalVideoPath = path.join(targetVideoDirectory, videoFileName); + + logger.info({ targetVideoDirectory }, '[Post-Process] Successfully moved to final location'); + } // Clean up empty parent directories in the temp path (e.g., empty channel folder) const tempBasePath = tempPathManager.getTempBasePath(); - const parentDir = path.dirname(videoDirectory); // This was the channel folder in temp + const parentDir = isFlatMode ? videoDirectory : path.dirname(videoDirectory); await cleanupEmptyParents(parentDir, tempBasePath); // Verify the final file exists @@ -619,7 +668,10 @@ async function copyChannelPosterIfNeeded(channelId, channelFolderPath) { // Copy channel thumbnail as poster.jpg to channel folder (must be done AFTER all moves) // Calculate the final channel folder path based on the final video path - const finalChannelFolderPath = path.dirname(path.dirname(finalVideoPath)); + // In flat mode, the file is directly in the channel folder + const finalChannelFolderPath = isFlatMode + ? path.dirname(finalVideoPath) + : path.dirname(path.dirname(finalVideoPath)); if (jsonData.channel_id) { await copyChannelPosterIfNeeded(jsonData.channel_id, finalChannelFolderPath); } diff --git a/server/routes/videos.js b/server/routes/videos.js index 81d1f33..37ed18a 100644 --- a/server/routes/videos.js +++ b/server/routes/videos.js @@ -663,6 +663,13 @@ module.exports = function createVideoRoutes({ verifyToken, videosModule, downloa }); } } + + // Validate skipVideoFolder if provided + if (overrideSettings.skipVideoFolder !== undefined && typeof overrideSettings.skipVideoFolder !== 'boolean') { + return res.status(400).json({ + error: 'skipVideoFolder must be a boolean' + }); + } } downloadModule.doSpecificDownloads(req);