Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions client/src/components/ChannelPage/ChannelSettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
DialogActions,
Button,
FormControl,
FormControlLabel,
InputLabel,
Select,
MenuItem,
Switch,
TextField,
CircularProgress,
Alert,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ChannelSettings>({
sub_folder: null,
Expand All @@ -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<string[]>([]);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
})
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -461,6 +474,24 @@ function ChannelSettingsDialog({
</Typography>
)}

<FormControlLabel
control={
<Switch
checked={!!settings.skip_video_folder}
onChange={(e) => setSettings({
...settings,
skip_video_folder: e.target.checked ? true : null
})}
color="primary"
/>
}
label="Flat file structure (no video subfolders)"
sx={{ mt: 1 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: -1, mb: 1 }}>
When enabled, video files are saved directly in the channel folder instead of individual video subfolders. Only affects new downloads.
</Typography>

<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', mb: 1 }}>
Subfolder Organization
Expand Down
1 change: 1 addition & 0 deletions client/src/components/ChannelPage/ChannelVideos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ function ChannelVideos({ token, channelAutoDownloadTabs, channelId: propChannelI
subfolder: settings.subfolder,
audioFormat: settings.audioFormat,
rating: settings.rating,
skipVideoFolder: settings.skipVideoFolder,
}
: undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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(<ChannelSettingsDialog {...defaultProps} />);

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(<ChannelSettingsDialog {...defaultProps} />);

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(<ChannelSettingsDialog {...defaultProps} />);

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(<ChannelSettingsDialog {...defaultProps} />);

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({
const [hasUserInteracted, setHasUserInteracted] = useState(false);
const [subfolderOverride, setSubfolderOverride] = useState<string | null>(null);
const [audioFormat, setAudioFormat] = useState<string | null>(defaultAudioFormat);
const [skipVideoFolder, setSkipVideoFolder] = useState(false);

// Fetch available subfolders
const { subfolders, loading: subfoldersLoading } = useSubfolders(token);
Expand Down Expand Up @@ -123,19 +124,22 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({
setSubfolderOverride(null);
setAudioFormat(defaultAudioFormat);
setRating(defaultRating ?? null);
setSkipVideoFolder(false);
}
}, [open, defaultAudioFormat]);

const handleUseCustomToggle = (event: React.ChangeEvent<HTMLInputElement>) => {
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);
}
}
}
};
Expand Down Expand Up @@ -207,7 +211,8 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({
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
Expand Down Expand Up @@ -507,6 +512,24 @@ const DownloadSettingsDialog: React.FC<DownloadSettingsDialogProps> = ({
<MenuItem value="TV-MA"><RatingBadge rating="TV-MA" size="small" sx={{ mr: 1 }} /> TV-MA</MenuItem>
</Select>
</FormControl>

<FormControlLabel
control={
<Switch
checked={skipVideoFolder}
onChange={(e) => {
setSkipVideoFolder(e.target.checked);
setHasUserInteracted(true);
}}
color="primary"
/>
}
label="Flat file structure (no video subfolders)"
sx={{ mb: 1 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
Save files directly in the channel folder instead of individual video subfolders.
</Typography>
</>
)}
</Box>
Expand Down
Loading