Skip to content

Commit a4696ba

Browse files
authored
Merge pull request #442 from DialmasterOrg/258-fr-ability-to-download-single-videos-in-subdirectories-without-channel-grouping
feat: channel flat file structure option (#258)
2 parents 682ccc2 + c2408fc commit a4696ba

25 files changed

+811
-162
lines changed

client/src/components/ChannelPage/ChannelSettingsDialog.tsx

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import {
66
DialogActions,
77
Button,
88
FormControl,
9+
FormControlLabel,
910
InputLabel,
1011
Select,
1112
MenuItem,
13+
Switch,
1214
TextField,
1315
CircularProgress,
1416
Alert,
@@ -39,6 +41,7 @@ interface ChannelSettings {
3941
title_filter_regex: string | null;
4042
audio_format: string | null;
4143
default_rating: string | null;
44+
skip_video_folder: boolean | null;
4245
}
4346

4447
interface FilterPreviewVideo {
@@ -96,7 +99,8 @@ function ChannelSettingsDialog({
9699
max_duration: null,
97100
title_filter_regex: null,
98101
audio_format: null,
99-
default_rating: null
102+
default_rating: null,
103+
skip_video_folder: null
100104
});
101105
const [originalSettings, setOriginalSettings] = useState<ChannelSettings>({
102106
sub_folder: null,
@@ -105,7 +109,8 @@ function ChannelSettingsDialog({
105109
max_duration: null,
106110
title_filter_regex: null,
107111
audio_format: null,
108-
default_rating: null
112+
default_rating: null,
113+
skip_video_folder: null
109114
});
110115
const [subfolders, setSubfolders] = useState<string[]>([]);
111116
const [loading, setLoading] = useState(true);
@@ -186,6 +191,9 @@ function ChannelSettingsDialog({
186191
default_rating: Object.prototype.hasOwnProperty.call(settingsData, 'default_rating')
187192
? settingsData.default_rating
188193
: null,
194+
skip_video_folder: Object.prototype.hasOwnProperty.call(settingsData, 'skip_video_folder')
195+
? settingsData.skip_video_folder
196+
: null,
189197
};
190198
setSettings(loadedSettings);
191199
setOriginalSettings(loadedSettings);
@@ -242,7 +250,8 @@ function ChannelSettingsDialog({
242250
max_duration: settings.max_duration,
243251
title_filter_regex: settings.title_filter_regex || null,
244252
audio_format: settings.audio_format || null,
245-
default_rating: settings.default_rating || null
253+
default_rating: settings.default_rating || null,
254+
skip_video_folder: settings.skip_video_folder
246255
})
247256
});
248257

@@ -274,6 +283,9 @@ function ChannelSettingsDialog({
274283
default_rating: result?.settings && Object.prototype.hasOwnProperty.call(result.settings, 'default_rating')
275284
? result.settings.default_rating
276285
: settings.default_rating ?? null,
286+
skip_video_folder: result?.settings && Object.prototype.hasOwnProperty.call(result.settings, 'skip_video_folder')
287+
? result.settings.skip_video_folder
288+
: settings.skip_video_folder ?? null,
277289
};
278290

279291
setSettings(updatedSettings);
@@ -313,7 +325,8 @@ function ChannelSettingsDialog({
313325
settings.max_duration !== originalSettings.max_duration ||
314326
settings.title_filter_regex !== originalSettings.title_filter_regex ||
315327
settings.audio_format !== originalSettings.audio_format ||
316-
settings.default_rating !== originalSettings.default_rating;
328+
settings.default_rating !== originalSettings.default_rating ||
329+
settings.skip_video_folder !== originalSettings.skip_video_folder;
317330
};
318331

319332
const handlePreviewFilter = async () => {
@@ -461,6 +474,24 @@ function ChannelSettingsDialog({
461474
</Typography>
462475
)}
463476

477+
<FormControlLabel
478+
control={
479+
<Switch
480+
checked={!!settings.skip_video_folder}
481+
onChange={(e) => setSettings({
482+
...settings,
483+
skip_video_folder: e.target.checked ? true : null
484+
})}
485+
color="primary"
486+
/>
487+
}
488+
label="Flat file structure (no video subfolders)"
489+
sx={{ mt: 1 }}
490+
/>
491+
<Typography variant="caption" color="text.secondary" sx={{ mt: -1, mb: 1 }}>
492+
When enabled, video files are saved directly in the channel folder instead of individual video subfolders. Only affects new downloads.
493+
</Typography>
494+
464495
<Alert severity="info" sx={{ mb: 2 }}>
465496
<Typography variant="body2" sx={{ fontWeight: 'bold', mb: 1 }}>
466497
Subfolder Organization

client/src/components/ChannelPage/ChannelVideos.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI
390390
subfolder: settings.subfolder,
391391
audioFormat: settings.audioFormat,
392392
rating: settings.rating,
393+
skipVideoFolder: settings.skipVideoFolder,
393394
}
394395
: undefined;
395396

client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('ChannelSettingsDialog', () => {
3737
title_filter_regex: null,
3838
audio_format: null,
3939
default_rating: null,
40+
skip_video_folder: null,
4041
};
4142

4243
const mockSubfolders = ['__Sports', '__Music', '__Tech'];
@@ -1327,6 +1328,127 @@ describe('ChannelSettingsDialog', () => {
13271328
});
13281329
});
13291330

1331+
describe('Skip Video Folder Toggle', () => {
1332+
test('renders the flat file structure toggle', async () => {
1333+
mockFetch
1334+
.mockResolvedValueOnce({
1335+
ok: true,
1336+
json: jest.fn().mockResolvedValueOnce(mockChannelSettings),
1337+
})
1338+
.mockResolvedValueOnce({
1339+
ok: true,
1340+
json: jest.fn().mockResolvedValueOnce(mockSubfolders),
1341+
});
1342+
1343+
render(<ChannelSettingsDialog {...defaultProps} />);
1344+
1345+
await waitFor(() => {
1346+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
1347+
});
1348+
1349+
expect(screen.getByLabelText('Flat file structure (no video subfolders)')).toBeInTheDocument();
1350+
});
1351+
1352+
test('toggles skip_video_folder when switch is clicked', async () => {
1353+
const user = userEvent.setup();
1354+
1355+
mockFetch
1356+
.mockResolvedValueOnce({
1357+
ok: true,
1358+
json: jest.fn().mockResolvedValueOnce(mockChannelSettings),
1359+
})
1360+
.mockResolvedValueOnce({
1361+
ok: true,
1362+
json: jest.fn().mockResolvedValueOnce(mockSubfolders),
1363+
});
1364+
1365+
render(<ChannelSettingsDialog {...defaultProps} />);
1366+
1367+
await waitFor(() => {
1368+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
1369+
});
1370+
1371+
const toggle = screen.getByRole('checkbox', { name: /Flat file structure/i });
1372+
expect(toggle).not.toBeChecked();
1373+
1374+
await user.click(toggle);
1375+
expect(toggle).toBeChecked();
1376+
1377+
// Save button should now be enabled since there's a change
1378+
const saveButton = screen.getByRole('button', { name: 'Save' });
1379+
expect(saveButton).not.toBeDisabled();
1380+
});
1381+
1382+
test('sends skip_video_folder in the API save call', async () => {
1383+
const user = userEvent.setup();
1384+
1385+
mockFetch
1386+
.mockResolvedValueOnce({
1387+
ok: true,
1388+
json: jest.fn().mockResolvedValueOnce(mockChannelSettings),
1389+
})
1390+
.mockResolvedValueOnce({
1391+
ok: true,
1392+
json: jest.fn().mockResolvedValueOnce(mockSubfolders),
1393+
})
1394+
.mockResolvedValueOnce({
1395+
ok: true,
1396+
json: jest.fn().mockResolvedValueOnce({
1397+
settings: { ...mockChannelSettings, skip_video_folder: true },
1398+
}),
1399+
});
1400+
1401+
render(<ChannelSettingsDialog {...defaultProps} />);
1402+
1403+
await waitFor(() => {
1404+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
1405+
});
1406+
1407+
// Toggle the flat file structure switch
1408+
const toggle = screen.getByRole('checkbox', { name: /Flat file structure/i });
1409+
await user.click(toggle);
1410+
1411+
// Click save
1412+
const saveButton = screen.getByRole('button', { name: 'Save' });
1413+
await user.click(saveButton);
1414+
1415+
// Verify the PUT call includes skip_video_folder: true
1416+
let putCall: any[];
1417+
await waitFor(() => {
1418+
putCall = mockFetch.mock.calls.find(
1419+
(call: any[]) => call[0] === '/api/channels/channel123/settings' && call[1]?.method === 'PUT'
1420+
);
1421+
expect(putCall).toBeDefined();
1422+
});
1423+
const body = JSON.parse(putCall![1].body);
1424+
expect(body.skip_video_folder).toBe(true);
1425+
});
1426+
1427+
test('loads skip_video_folder true from server and shows toggle checked', async () => {
1428+
mockFetch
1429+
.mockResolvedValueOnce({
1430+
ok: true,
1431+
json: jest.fn().mockResolvedValueOnce({
1432+
...mockChannelSettings,
1433+
skip_video_folder: true,
1434+
}),
1435+
})
1436+
.mockResolvedValueOnce({
1437+
ok: true,
1438+
json: jest.fn().mockResolvedValueOnce(mockSubfolders),
1439+
});
1440+
1441+
render(<ChannelSettingsDialog {...defaultProps} />);
1442+
1443+
await waitFor(() => {
1444+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
1445+
});
1446+
1447+
const toggle = screen.getByRole('checkbox', { name: /Flat file structure/i });
1448+
expect(toggle).toBeChecked();
1449+
});
1450+
});
1451+
13301452
describe('Edge Cases', () => {
13311453
test('handles missing onSettingsSaved callback', async () => {
13321454
const user = userEvent.setup();

client/src/components/DownloadManager/ManualDownload/DownloadSettingsDialog.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({
8080
const [hasUserInteracted, setHasUserInteracted] = useState(false);
8181
const [subfolderOverride, setSubfolderOverride] = useState<string | null>(null);
8282
const [audioFormat, setAudioFormat] = useState<string | null>(defaultAudioFormat);
83+
const [skipVideoFolder, setSkipVideoFolder] = useState(false);
8384

8485
// Fetch available subfolders
8586
const { subfolders, loading: subfoldersLoading } = useSubfolders(token);
@@ -123,19 +124,22 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({
123124
setSubfolderOverride(null);
124125
setAudioFormat(defaultAudioFormat);
125126
setRating(defaultRating ?? null);
127+
setSkipVideoFolder(false);
126128
}
127129
}, [open, defaultAudioFormat]);
128130

129131
const handleUseCustomToggle = (event: React.ChangeEvent<HTMLInputElement>) => {
130132
const checked = event.target.checked;
131133
setUseCustomSettings(checked);
132134
setHasUserInteracted(true);
133-
// When enabling custom settings, if rating is not set, initialize to channel/defaultRating
134-
if (checked && (rating === null || rating === undefined)) {
135-
// defaultRating prop may be undefined in some usages
136-
// prefer to leave null if no defaultRating available
137-
if (typeof defaultRating !== 'undefined' && defaultRating !== null) {
138-
setRating(defaultRating);
135+
if (checked) {
136+
// When enabling custom settings, if rating is not set, initialize to channel/defaultRating
137+
if (rating === null || rating === undefined) {
138+
// defaultRating prop may be undefined in some usages
139+
// prefer to leave null if no defaultRating available
140+
if (typeof defaultRating !== 'undefined' && defaultRating !== null) {
141+
setRating(defaultRating);
142+
}
139143
}
140144
}
141145
};
@@ -207,7 +211,8 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({
207211
audioFormat: mode === 'manual' ? audioFormat : undefined,
208212
// Include rating only if custom settings are enabled (user explicitly selected it)
209213
// Use an explicit sentinel 'NR' when the user selected "No Rating" (null)
210-
rating: useCustomSettings ? (rating === null ? 'NR' : (rating ?? undefined)) : undefined
214+
rating: useCustomSettings ? (rating === null ? 'NR' : (rating ?? undefined)) : undefined,
215+
skipVideoFolder: mode === 'manual' ? (useCustomSettings ? skipVideoFolder : false) : undefined
211216
});
212217
} else {
213218
onConfirm(null); // Use defaults - post-processor will apply channel default rating
@@ -507,6 +512,24 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({
507512
<MenuItem value="TV-MA"><RatingBadge rating="TV-MA" size="small" sx={{ mr: 1 }} /> TV-MA</MenuItem>
508513
</Select>
509514
</FormControl>
515+
516+
<FormControlLabel
517+
control={
518+
<Switch
519+
checked={skipVideoFolder}
520+
onChange={(e) => {
521+
setSkipVideoFolder(e.target.checked);
522+
setHasUserInteracted(true);
523+
}}
524+
color="primary"
525+
/>
526+
}
527+
label="Flat file structure (no video subfolders)"
528+
sx={{ mb: 1 }}
529+
/>
530+
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
531+
Save files directly in the channel folder instead of individual video subfolders.
532+
</Typography>
510533
</>
511534
)}
512535
</Box>

0 commit comments

Comments
 (0)