Refactor CoreSettings to use JSONField for value storage and update related logic for proper type handling. Adjusted serializers and forms to accommodate new data structure, ensuring seamless integration across the application.

This commit is contained in:
SergeantPanda 2026-01-13 12:18:34 -06:00
parent 4bfdd15b37
commit 36967c10ce
32 changed files with 866 additions and 519 deletions

View file

@ -3,8 +3,28 @@ import React, { useEffect, useMemo, useState } from 'react';
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
import usePlaylistsStore from '../../store/playlists.jsx';
import useSettingsStore from '../../store/settings.jsx';
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Select, Stack, Text, Tooltip } from '@mantine/core';
import { Gauge, HardDriveDownload, HardDriveUpload, SquareX, Timer, Users, Video } from 'lucide-react';
import {
ActionIcon,
Badge,
Box,
Card,
Center,
Flex,
Group,
Select,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import {
Gauge,
HardDriveDownload,
HardDriveUpload,
SquareX,
Timer,
Users,
Video,
} from 'lucide-react';
import { toFriendlyDuration } from '../../utils/dateTimeUtils.js';
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
import { TableHelper } from '../../helpers/index.jsx';
@ -87,7 +107,10 @@ const StreamConnectionCard = ({
// If we have a channel URL, try to find the matching stream
if (channel.url && streamData.length > 0) {
// Try to find matching stream based on URL
const matchingStream = getMatchingStreamByUrl(streamData, channel.url);
const matchingStream = getMatchingStreamByUrl(
streamData,
channel.url
);
if (matchingStream) {
setActiveStreamId(matchingStream.id.toString());
@ -178,9 +201,9 @@ const StreamConnectionCard = ({
console.error('Error checking streams after switch:', error);
}
};
}
};
// Handle stream switching
// Handle stream switching
const handleStreamChange = async (streamId) => {
try {
console.log('Switching to stream ID:', streamId);
@ -333,7 +356,7 @@ const StreamConnectionCard = ({
});
// Get logo URL from the logos object if available
const logoUrl = getLogoUrl(channel.logo_id , logos, previewedStream);
const logoUrl = getLogoUrl(channel.logo_id, logos, previewedStream);
useEffect(() => {
let isMounted = true;
@ -388,11 +411,11 @@ const StreamConnectionCard = ({
style={{
backgroundColor: '#27272A',
}}
color='#fff'
color="#fff"
maw={700}
w={'100%'}
>
<Stack pos='relative' >
<Stack pos="relative">
<Group justify="space-between">
<Box
style={{
@ -401,7 +424,7 @@ const StreamConnectionCard = ({
}}
w={100}
h={50}
display='flex'
display="flex"
>
<img
src={logoUrl || logo}
@ -531,7 +554,7 @@ const StreamConnectionCard = ({
variant="light"
color={
parseFloat(channel.ffmpeg_speed) >=
getBufferingSpeedThreshold(settings['proxy-settings'])
getBufferingSpeedThreshold(settings['proxy_settings'])
? 'green'
: 'red'
}
@ -587,4 +610,4 @@ const StreamConnectionCard = ({
);
};
export default StreamConnectionCard;
export default StreamConnectionCard;

View file

@ -50,9 +50,9 @@ const DvrSettingsForm = React.memo(({ active }) => {
form.setValues(formValues);
if (formValues['dvr-comskip-custom-path']) {
if (formValues['comskip_custom_path']) {
setComskipConfig((prev) => ({
path: formValues['dvr-comskip-custom-path'],
path: formValues['comskip_custom_path'],
exists: prev.exists,
}));
}
@ -69,7 +69,7 @@ const DvrSettingsForm = React.memo(({ active }) => {
exists: Boolean(response.exists),
});
if (response.path) {
form.setFieldValue('dvr-comskip-custom-path', response.path);
form.setFieldValue('comskip_custom_path', response.path);
}
}
} catch (error) {
@ -94,10 +94,10 @@ const DvrSettingsForm = React.memo(({ active }) => {
autoClose: 3000,
color: 'green',
});
form.setFieldValue('dvr-comskip-custom-path', response.path);
form.setFieldValue('comskip_custom_path', response.path);
useSettingsStore.getState().updateSetting({
...(settings['dvr-comskip-custom-path'] || {
key: 'dvr-comskip-custom-path',
...(settings['comskip_custom_path'] || {
key: 'comskip_custom_path',
name: 'DVR Comskip Custom Path',
}),
value: response.path,
@ -137,24 +137,19 @@ const DvrSettingsForm = React.memo(({ active }) => {
)}
<Switch
label="Enable Comskip (remove commercials after recording)"
{...form.getInputProps('dvr-comskip-enabled', {
{...form.getInputProps('comskip_enabled', {
type: 'checkbox',
})}
id={settings['dvr-comskip-enabled']?.id || 'dvr-comskip-enabled'}
name={settings['dvr-comskip-enabled']?.key || 'dvr-comskip-enabled'}
id="comskip_enabled"
name="comskip_enabled"
/>
<TextInput
label="Custom comskip.ini path"
description="Leave blank to use the built-in defaults."
placeholder="/app/docker/comskip.ini"
{...form.getInputProps('dvr-comskip-custom-path')}
id={
settings['dvr-comskip-custom-path']?.id || 'dvr-comskip-custom-path'
}
name={
settings['dvr-comskip-custom-path']?.key ||
'dvr-comskip-custom-path'
}
{...form.getInputProps('comskip_custom_path')}
id="comskip_custom_path"
name="comskip_custom_path"
/>
<Group align="flex-end" gap="sm">
<FileInput
@ -184,71 +179,50 @@ const DvrSettingsForm = React.memo(({ active }) => {
description="Begin recording this many minutes before the scheduled start."
min={0}
step={1}
{...form.getInputProps('dvr-pre-offset-minutes')}
id={
settings['dvr-pre-offset-minutes']?.id || 'dvr-pre-offset-minutes'
}
name={
settings['dvr-pre-offset-minutes']?.key || 'dvr-pre-offset-minutes'
}
{...form.getInputProps('pre_offset_minutes')}
id="pre_offset_minutes"
name="pre_offset_minutes"
/>
<NumberInput
label="End late (minutes)"
description="Continue recording this many minutes after the scheduled end."
min={0}
step={1}
{...form.getInputProps('dvr-post-offset-minutes')}
id={
settings['dvr-post-offset-minutes']?.id || 'dvr-post-offset-minutes'
}
name={
settings['dvr-post-offset-minutes']?.key ||
'dvr-post-offset-minutes'
}
{...form.getInputProps('post_offset_minutes')}
id="post_offset_minutes"
name="post_offset_minutes"
/>
<TextInput
label="TV Path Template"
description="Supports {show}, {season}, {episode}, {sub_title}, {channel}, {year}, {start}, {end}. Use format specifiers like {season:02d}. Relative paths are under your library dir."
placeholder="TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
{...form.getInputProps('dvr-tv-template')}
id={settings['dvr-tv-template']?.id || 'dvr-tv-template'}
name={settings['dvr-tv-template']?.key || 'dvr-tv-template'}
{...form.getInputProps('tv_template')}
id="tv_template"
name="tv_template"
/>
<TextInput
label="TV Fallback Template"
description="Template used when an episode has no season/episode. Supports {show}, {start}, {end}, {channel}, {year}."
placeholder="TV_Shows/{show}/{start}.mkv"
{...form.getInputProps('dvr-tv-fallback-template')}
id={
settings['dvr-tv-fallback-template']?.id ||
'dvr-tv-fallback-template'
}
name={
settings['dvr-tv-fallback-template']?.key ||
'dvr-tv-fallback-template'
}
{...form.getInputProps('tv_fallback_template')}
id="tv_fallback_template"
name="tv_fallback_template"
/>
<TextInput
label="Movie Path Template"
description="Supports {title}, {year}, {channel}, {start}, {end}. Relative paths are under your library dir."
placeholder="Movies/{title} ({year}).mkv"
{...form.getInputProps('dvr-movie-template')}
id={settings['dvr-movie-template']?.id || 'dvr-movie-template'}
name={settings['dvr-movie-template']?.key || 'dvr-movie-template'}
{...form.getInputProps('movie_template')}
id="movie_template"
name="movie_template"
/>
<TextInput
label="Movie Fallback Template"
description="Template used when movie metadata is incomplete. Supports {start}, {end}, {channel}."
placeholder="Movies/{start}.mkv"
{...form.getInputProps('dvr-movie-fallback-template')}
id={
settings['dvr-movie-fallback-template']?.id ||
'dvr-movie-fallback-template'
}
name={
settings['dvr-movie-fallback-template']?.key ||
'dvr-movie-fallback-template'
}
{...form.getInputProps('movie_fallback_template')}
id="movie_fallback_template"
name="movie_fallback_template"
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" variant="default">
@ -260,4 +234,4 @@ const DvrSettingsForm = React.memo(({ active }) => {
);
});
export default DvrSettingsForm;
export default DvrSettingsForm;

View file

@ -36,9 +36,7 @@ const NetworkAccessForm = React.memo(({ active }) => {
}, [active]);
useEffect(() => {
const networkAccessSettings = JSON.parse(
settings['network-access'].value || '{}'
);
const networkAccessSettings = settings['network_access']?.value || {};
networkAccessForm.setValues(
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0';
@ -51,8 +49,8 @@ const NetworkAccessForm = React.memo(({ active }) => {
setSaved(false);
setNetworkAccessError(null);
const check = await checkSetting({
...settings['network-access'],
value: JSON.stringify(networkAccessForm.getValues()),
...settings['network_access'],
value: networkAccessForm.getValues(), // Send as object
});
if (check.error && check.message) {
@ -78,8 +76,8 @@ const NetworkAccessForm = React.memo(({ active }) => {
setSaving(true);
try {
await updateSetting({
...settings['network-access'],
value: JSON.stringify(networkAccessForm.getValues()),
...settings['network_access'],
value: networkAccessForm.getValues(), // Send as object
});
setSaved(true);
} catch (e) {

View file

@ -91,18 +91,13 @@ const ProxySettingsForm = React.memo(({ active }) => {
});
useEffect(() => {
if(!active) setSaved(false);
if (!active) setSaved(false);
}, [active]);
useEffect(() => {
if (settings) {
if (settings['proxy-settings']?.value) {
try {
const proxySettings = JSON.parse(settings['proxy-settings'].value);
proxySettingsForm.setValues(proxySettings);
} catch (error) {
console.error('Error parsing proxy settings:', error);
}
if (settings['proxy_settings']?.value) {
proxySettingsForm.setValues(settings['proxy_settings'].value);
}
}
}, [settings]);
@ -116,8 +111,8 @@ const ProxySettingsForm = React.memo(({ active }) => {
try {
const result = await updateSetting({
...settings['proxy-settings'],
value: JSON.stringify(proxySettingsForm.getValues()),
...settings['proxy_settings'],
value: proxySettingsForm.getValues(), // Send as object
});
// API functions return undefined on error
if (result) {
@ -163,4 +158,4 @@ const ProxySettingsForm = React.memo(({ active }) => {
);
});
export default ProxySettingsForm;
export default ProxySettingsForm;

View file

@ -129,8 +129,11 @@ const StreamSettingsForm = React.memo(({ active }) => {
const values = form.getValues();
const changedSettings = getChangedSettings(values, settings);
const m3uHashKeyChanged =
settings['m3u-hash-key']?.value !== values['m3u-hash-key'].join(',');
// Check if m3u_hash_key changed from the grouped stream_settings
const currentHashKey =
settings['stream_settings']?.value?.m3u_hash_key || '';
const newHashKey = values['m3u_hash_key']?.join(',') || '';
const m3uHashKeyChanged = currentHashKey !== newHashKey;
// If M3U hash key changed, show warning (unless suppressed)
if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
@ -161,10 +164,10 @@ const StreamSettingsForm = React.memo(({ active }) => {
)}
<Select
searchable
{...form.getInputProps('default-user-agent')}
id={settings['default-user-agent']?.id || 'default-user-agent'}
name={settings['default-user-agent']?.key || 'default-user-agent'}
label={settings['default-user-agent']?.name || 'Default User Agent'}
{...form.getInputProps('default_user_agent')}
id="default_user_agent"
name="default_user_agent"
label="Default User Agent"
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
@ -172,16 +175,10 @@ const StreamSettingsForm = React.memo(({ active }) => {
/>
<Select
searchable
{...form.getInputProps('default-stream-profile')}
id={
settings['default-stream-profile']?.id || 'default-stream-profile'
}
name={
settings['default-stream-profile']?.key || 'default-stream-profile'
}
label={
settings['default-stream-profile']?.name || 'Default Stream Profile'
}
{...form.getInputProps('default_stream_profile')}
id="default_stream_profile"
name="default_stream_profile"
label="Default Stream Profile"
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
@ -189,10 +186,10 @@ const StreamSettingsForm = React.memo(({ active }) => {
/>
<Select
searchable
{...form.getInputProps('preferred-region')}
id={settings['preferred-region']?.id || 'preferred-region'}
name={settings['preferred-region']?.key || 'preferred-region'}
label={settings['preferred-region']?.name || 'Preferred Region'}
{...form.getInputProps('preferred_region')}
id="preferred_region"
name="preferred_region"
label="Preferred Region"
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
@ -204,19 +201,16 @@ const StreamSettingsForm = React.memo(({ active }) => {
Auto-Import Mapped Files
</Text>
<Switch
{...form.getInputProps('auto-import-mapped-files', {
{...form.getInputProps('auto_import_mapped_files', {
type: 'checkbox',
})}
id={
settings['auto-import-mapped-files']?.id ||
'auto-import-mapped-files'
}
id="auto_import_mapped_files"
/>
</Group>
<MultiSelect
id="m3u-hash-key"
name="m3u-hash-key"
id="m3u_hash_key"
name="m3u_hash_key"
label="M3U Hash Key"
data={[
{
@ -240,7 +234,7 @@ const StreamSettingsForm = React.memo(({ active }) => {
label: 'Group',
},
]}
{...form.getInputProps('m3u-hash-key')}
{...form.getInputProps('m3u_hash_key')}
/>
{rehashSuccess && (
@ -303,4 +297,4 @@ Please ensure you have time to let this complete before proceeding.`}
);
});
export default StreamSettingsForm;
export default StreamSettingsForm;

View file

@ -60,9 +60,9 @@ const SystemSettingsForm = React.memo(({ active }) => {
<NumberInput
label="Maximum System Events"
description="Number of events to retain (minimum: 10, maximum: 1000)"
value={form.values['max-system-events'] || 100}
value={form.values['max_system_events'] || 100}
onChange={(value) => {
form.setFieldValue('max-system-events', value);
form.setFieldValue('max_system_events', value);
}}
min={10}
max={1000}
@ -81,4 +81,4 @@ const SystemSettingsForm = React.memo(({ active }) => {
);
});
export default SystemSettingsForm;
export default SystemSettingsForm;

View file

@ -45,12 +45,11 @@ const UiSettingsForm = React.memo(() => {
useEffect(() => {
if (settings) {
const tzSetting = settings['system-time-zone'];
if (tzSetting?.value) {
const systemSettings = settings['system_settings'];
const tzValue = systemSettings?.value?.time_zone;
if (tzValue) {
timeZoneSyncedRef.current = true;
setTimeZone((prev) =>
prev === tzSetting.value ? prev : tzSetting.value
);
setTimeZone((prev) => (prev === tzValue ? prev : tzValue));
} else if (!timeZoneSyncedRef.current && timeZone) {
timeZoneSyncedRef.current = true;
persistTimeZoneSetting(timeZone);
@ -141,4 +140,4 @@ const UiSettingsForm = React.memo(() => {
);
});
export default UiSettingsForm;
export default UiSettingsForm;

View file

@ -155,7 +155,7 @@ const StreamProfiles = () => {
};
const deleteStreamProfile = async (id) => {
if (id == settings['default-stream-profile'].value) {
if (id == settings.default_stream_profile) {
notifications.show({
title: 'Cannot delete default stream-profile',
color: 'red.5',

View file

@ -127,7 +127,7 @@ const UserAgentsTable = () => {
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
if (ids.includes(settings['default-user-agent'].value)) {
if (ids.includes(settings.default_user_agent)) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
@ -137,7 +137,7 @@ const UserAgentsTable = () => {
await API.deleteUserAgents(ids);
} else {
if (ids == settings['default-user-agent'].value) {
if (ids == settings.default_user_agent) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',

View file

@ -12,7 +12,11 @@ import {
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import { categorizeRecordings } from '../../utils/pages/DVRUtils.js';
import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../../utils/cards/RecordingCardUtils.js';
import {
getPosterUrl,
getRecordingUrl,
getShowVideoUrl,
} from '../../utils/cards/RecordingCardUtils.js';
vi.mock('../../store/channels');
vi.mock('../../store/settings');
@ -53,17 +57,28 @@ vi.mock('../../components/cards/RecordingCard', () => ({
<span>{recording.custom_properties?.Title || 'Recording'}</span>
<button onClick={() => onOpenDetails(recording)}>Open Details</button>
{recording.custom_properties?.rule && (
<button onClick={() => onOpenRecurring(recording)}>Open Recurring</button>
<button onClick={() => onOpenRecurring(recording)}>
Open Recurring
</button>
)}
</div>
),
}));
vi.mock('../../components/forms/RecordingDetailsModal', () => ({
default: ({ opened, onClose, recording, onEdit, onWatchLive, onWatchRecording }) =>
default: ({
opened,
onClose,
recording,
onEdit,
onWatchLive,
onWatchRecording,
}) =>
opened ? (
<div data-testid="details-modal">
<div data-testid="modal-title">{recording?.custom_properties?.Title}</div>
<div data-testid="modal-title">
{recording?.custom_properties?.Title}
</div>
<button onClick={onClose}>Close Modal</button>
<button onClick={onEdit}>Edit</button>
<button onClick={onWatchLive}>Watch Live</button>
@ -137,7 +152,7 @@ describe('DVRPage', () => {
const defaultSettingsState = {
settings: {
'system-time-zone': { value: 'America/New_York' },
system_settings: { value: { time_zone: 'America/New_York' } },
},
environment: {
env_mode: 'production',
@ -178,12 +193,10 @@ describe('DVRPage', () => {
getPosterUrl.mockImplementation((recording) =>
recording?.id ? `http://poster.url/${recording.id}` : null
);
getRecordingUrl.mockImplementation((custom_properties) =>
custom_properties?.recording_url
);
getShowVideoUrl.mockImplementation((channel) =>
channel?.stream_url
getRecordingUrl.mockImplementation(
(custom_properties) => custom_properties?.recording_url
);
getShowVideoUrl.mockImplementation((channel) => channel?.stream_url);
useChannelsStore.mockImplementation((selector) => {
return selector ? selector(defaultChannelsState) : defaultChannelsState;
@ -295,7 +308,9 @@ describe('DVRPage', () => {
const state = {
...defaultChannelsState,
recordings: [recording],
channels: { 1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' } },
channels: {
1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
},
};
return selector ? selector(state) : state;
});
@ -362,7 +377,7 @@ describe('DVRPage', () => {
end_time: now.add(1, 'hour').toISOString(),
custom_properties: {
Title: 'Recurring Show',
rule: { id: 100 }
rule: { id: 100 },
},
};
@ -538,4 +553,4 @@ describe('DVRPage', () => {
expect(mockShowVideo).not.toHaveBeenCalled();
});
});
});
});

View file

@ -256,7 +256,7 @@ describe('dateTimeUtils', () => {
const setTimeZone = vi.fn();
useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
useSettingsStore.mockReturnValue({
'system-time-zone': { value: 'America/Los_Angeles' }
'system_settings': { value: { time_zone: 'America/Los_Angeles' } }
});
renderHook(() => dateTimeUtils.useUserTimeZone());

View file

@ -7,15 +7,14 @@ import {
toFriendlyDuration,
} from '../dateTimeUtils.js';
// Parse proxy settings to get buffering_speed
// Get buffering_speed from proxy settings
export const getBufferingSpeedThreshold = (proxySetting) => {
try {
if (proxySetting?.value) {
const proxySettings = JSON.parse(proxySetting.value);
return parseFloat(proxySettings.buffering_speed) || 1.0;
return parseFloat(proxySetting.value.buffering_speed) || 1.0;
}
} catch (error) {
console.error('Error parsing proxy settings:', error);
console.error('Error getting buffering speed:', error);
}
return 1.0; // Default fallback
};

View file

@ -66,7 +66,7 @@ export const useUserTimeZone = () => {
);
useEffect(() => {
const tz = settings?.['system-time-zone']?.value;
const tz = settings?.['system_settings']?.value?.time_zone;
if (tz && tz !== timeZone) {
setTimeZone(tz);
}

View file

@ -10,13 +10,13 @@ export const uploadComskipIni = async (file) => {
export const getDvrSettingsFormInitialValues = () => {
return {
'dvr-tv-template': '',
'dvr-movie-template': '',
'dvr-tv-fallback-template': '',
'dvr-movie-fallback-template': '',
'dvr-comskip-enabled': false,
'dvr-comskip-custom-path': '',
'dvr-pre-offset-minutes': 0,
'dvr-post-offset-minutes': 0,
'tv_template': '',
'movie_template': '',
'tv_fallback_template': '',
'movie_fallback_template': '',
'comskip_enabled': false,
'comskip_custom_path': '',
'pre_offset_minutes': 0,
'post_offset_minutes': 0,
};
};

View file

@ -2,18 +2,18 @@ import { isNotEmpty } from '@mantine/form';
export const getStreamSettingsFormInitialValues = () => {
return {
'default-user-agent': '',
'default-stream-profile': '',
'preferred-region': '',
'auto-import-mapped-files': true,
'm3u-hash-key': [],
default_user_agent: '',
default_stream_profile: '',
preferred_region: '',
auto_import_mapped_files: true,
m3u_hash_key: [],
};
};
export const getStreamSettingsFormValidation = () => {
return {
'default-user-agent': isNotEmpty('Select a user agent'),
'default-stream-profile': isNotEmpty('Select a stream profile'),
'preferred-region': isNotEmpty('Select a region'),
default_user_agent: isNotEmpty('Select a user agent'),
default_stream_profile: isNotEmpty('Select a stream profile'),
preferred_region: isNotEmpty('Select a region'),
};
};

View file

@ -1,5 +1,5 @@
export const getSystemSettingsFormInitialValues = () => {
return {
'max-system-events': 100,
max_system_events: 100,
};
};

View file

@ -1,14 +1,17 @@
import { createSetting, updateSetting } from '../../pages/SettingsUtils.js';
export const saveTimeZoneSetting = async (tzValue, settings) => {
const existing = settings['system-time-zone'];
const existing = settings['system_settings'];
const currentValue = existing?.value || {};
const newValue = { ...currentValue, time_zone: tzValue };
if (existing?.id) {
await updateSetting({ ...existing, value: tzValue });
await updateSetting({ ...existing, value: newValue });
} else {
await createSetting({
key: 'system-time-zone',
name: 'System Time Zone',
value: tzValue,
key: 'system_settings',
name: 'System Settings',
value: newValue,
});
}
};

View file

@ -57,14 +57,14 @@ describe('DvrSettingsFormUtils', () => {
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
expect(result).toEqual({
'dvr-tv-template': '',
'dvr-movie-template': '',
'dvr-tv-fallback-template': '',
'dvr-movie-fallback-template': '',
'dvr-comskip-enabled': false,
'dvr-comskip-custom-path': '',
'dvr-pre-offset-minutes': 0,
'dvr-post-offset-minutes': 0,
'tv_template': '',
'movie_template': '',
'tv_fallback_template': '',
'movie_fallback_template': '',
'comskip_enabled': false,
'comskip_custom_path': '',
'pre_offset_minutes': 0,
'post_offset_minutes': 0,
});
});
@ -79,14 +79,14 @@ describe('DvrSettingsFormUtils', () => {
it('should have correct default types', () => {
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
expect(typeof result['dvr-tv-template']).toBe('string');
expect(typeof result['dvr-movie-template']).toBe('string');
expect(typeof result['dvr-tv-fallback-template']).toBe('string');
expect(typeof result['dvr-movie-fallback-template']).toBe('string');
expect(typeof result['dvr-comskip-enabled']).toBe('boolean');
expect(typeof result['dvr-comskip-custom-path']).toBe('string');
expect(typeof result['dvr-pre-offset-minutes']).toBe('number');
expect(typeof result['dvr-post-offset-minutes']).toBe('number');
expect(typeof result['tv_template']).toBe('string');
expect(typeof result['movie_template']).toBe('string');
expect(typeof result['tv_fallback_template']).toBe('string');
expect(typeof result['movie_fallback_template']).toBe('string');
expect(typeof result['comskip_enabled']).toBe('boolean');
expect(typeof result['comskip_custom_path']).toBe('string');
expect(typeof result['pre_offset_minutes']).toBe('number');
expect(typeof result['post_offset_minutes']).toBe('number');
});
});
});

View file

@ -16,26 +16,26 @@ describe('StreamSettingsFormUtils', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result).toEqual({
'default-user-agent': '',
'default-stream-profile': '',
'preferred-region': '',
'auto-import-mapped-files': true,
'm3u-hash-key': []
'default_user_agent': '',
'default_stream_profile': '',
'preferred_region': '',
'auto_import_mapped_files': true,
'm3u_hash_key': []
});
});
it('should return boolean true for auto-import-mapped-files', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result['auto-import-mapped-files']).toBe(true);
expect(typeof result['auto-import-mapped-files']).toBe('boolean');
expect(result['auto_import_mapped_files']).toBe(true);
expect(typeof result['auto_import_mapped_files']).toBe('boolean');
});
it('should return empty array for m3u-hash-key', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result['m3u-hash-key']).toEqual([]);
expect(Array.isArray(result['m3u-hash-key'])).toBe(true);
expect(result['m3u_hash_key']).toEqual([]);
expect(Array.isArray(result['m3u_hash_key'])).toBe(true);
});
it('should return a new object each time', () => {
@ -50,7 +50,7 @@ describe('StreamSettingsFormUtils', () => {
const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result1['m3u-hash-key']).not.toBe(result2['m3u-hash-key']);
expect(result1['m3u_hash_key']).not.toBe(result2['m3u_hash_key']);
});
});
@ -59,25 +59,25 @@ describe('StreamSettingsFormUtils', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(Object.keys(result)).toEqual([
'default-user-agent',
'default-stream-profile',
'preferred-region'
'default_user_agent',
'default_stream_profile',
'preferred_region'
]);
});
it('should use isNotEmpty validator for default-user-agent', () => {
it('should use isNotEmpty validator for default_user_agent', () => {
StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(isNotEmpty).toHaveBeenCalledWith('Select a user agent');
});
it('should use isNotEmpty validator for default-stream-profile', () => {
it('should use isNotEmpty validator for default_stream_profile', () => {
StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(isNotEmpty).toHaveBeenCalledWith('Select a stream profile');
});
it('should use isNotEmpty validator for preferred-region', () => {
it('should use isNotEmpty validator for preferred_region', () => {
StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(isNotEmpty).toHaveBeenCalledWith('Select a region');
@ -86,21 +86,21 @@ describe('StreamSettingsFormUtils', () => {
it('should not include validation for auto-import-mapped-files', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(result).not.toHaveProperty('auto-import-mapped-files');
expect(result).not.toHaveProperty('auto_import_mapped_files');
});
it('should not include validation for m3u-hash-key', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(result).not.toHaveProperty('m3u-hash-key');
expect(result).not.toHaveProperty('m3u_hash_key');
});
it('should return correct validation error messages', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(result['default-user-agent']).toBe('Select a user agent');
expect(result['default-stream-profile']).toBe('Select a stream profile');
expect(result['preferred-region']).toBe('Select a region');
expect(result['default_user_agent']).toBe('Select a user agent');
expect(result['default_stream_profile']).toBe('Select a stream profile');
expect(result['preferred_region']).toBe('Select a region');
});
});
});

View file

@ -7,15 +7,15 @@ describe('SystemSettingsFormUtils', () => {
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result).toEqual({
'max-system-events': 100
'max_system_events': 100
});
});
it('should return number value for max-system-events', () => {
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result['max-system-events']).toBe(100);
expect(typeof result['max-system-events']).toBe('number');
expect(result['max_system_events']).toBe(100);
expect(typeof result['max_system_events']).toBe('number');
});
it('should return a new object each time', () => {
@ -29,7 +29,7 @@ describe('SystemSettingsFormUtils', () => {
it('should have max-system-events property', () => {
const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result).toHaveProperty('max-system-events');
expect(result).toHaveProperty('max_system_events');
});
});
});

View file

@ -17,26 +17,95 @@ export const rehashStreams = async () => {
};
export const saveChangedSettings = async (settings, changedSettings) => {
for (const updatedKey in changedSettings) {
const existing = settings[updatedKey];
// Group changes by their setting group based on field name prefixes
const groupedChanges = {
stream_settings: {},
dvr_settings: {},
backup_settings: {},
system_settings: {},
};
// Map of field prefixes to their groups
const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files'];
const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template',
'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules'];
const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week',
'retention_count', 'schedule_cron_expression'];
const systemFields = ['time_zone', 'max_system_events'];
for (const formKey in changedSettings) {
let value = changedSettings[formKey];
// Handle special grouped settings (proxy_settings and network_access)
if (formKey === 'proxy_settings') {
const existing = settings['proxy_settings'];
if (existing?.id) {
await updateSetting({ ...existing, value });
} else {
await createSetting({ key: 'proxy_settings', name: 'Proxy Settings', value });
}
continue;
}
if (formKey === 'network_access') {
const existing = settings['network_access'];
if (existing?.id) {
await updateSetting({ ...existing, value });
} else {
await createSetting({ key: 'network_access', name: 'Network Access', value });
}
continue;
}
// Type conversions for proper storage
if (formKey === 'm3u_hash_key' && Array.isArray(value)) {
value = value.join(',');
}
if (['default_user_agent', 'default_stream_profile'].includes(formKey) && value != null) {
value = parseInt(value, 10);
}
const numericFields = ['pre_offset_minutes', 'post_offset_minutes', 'retention_count', 'schedule_day_of_week', 'max_system_events'];
if (numericFields.includes(formKey) && value != null) {
value = typeof value === 'number' ? value : parseInt(value, 10);
}
const booleanFields = ['comskip_enabled', 'schedule_enabled', 'auto_import_mapped_files'];
if (booleanFields.includes(formKey) && value != null) {
value = typeof value === 'boolean' ? value : Boolean(value);
}
// Route to appropriate group
if (streamFields.includes(formKey)) {
groupedChanges.stream_settings[formKey] = value;
} else if (dvrFields.includes(formKey)) {
groupedChanges.dvr_settings[formKey] = value;
} else if (backupFields.includes(formKey)) {
groupedChanges.backup_settings[formKey] = value;
} else if (systemFields.includes(formKey)) {
groupedChanges.system_settings[formKey] = value;
}
}
// Update each group that has changes
for (const [groupKey, changes] of Object.entries(groupedChanges)) {
if (Object.keys(changes).length === 0) continue;
const existing = settings[groupKey];
const currentValue = existing?.value || {};
const newValue = { ...currentValue, ...changes };
if (existing?.id) {
const result = await updateSetting({
...existing,
value: changedSettings[updatedKey],
});
// API functions return undefined on error
const result = await updateSetting({ ...existing, value: newValue });
if (!result) {
throw new Error('Failed to update setting');
throw new Error(`Failed to update ${groupKey}`);
}
} else {
const result = await createSetting({
key: updatedKey,
name: updatedKey.replace(/-/g, ' '),
value: changedSettings[updatedKey],
});
// API functions return undefined on error
const name = groupKey.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
const result = await createSetting({ key: groupKey, name: name, value: newValue });
if (!result) {
throw new Error('Failed to create setting');
throw new Error(`Failed to create ${groupKey}`);
}
}
}
@ -46,59 +115,104 @@ export const getChangedSettings = (values, settings) => {
const changedSettings = {};
for (const settingKey in values) {
// Skip grouped settings that are handled by their own dedicated forms
if (settingKey === 'proxy_settings' || settingKey === 'network_access') {
continue;
}
// Only compare against existing value if the setting exists
const existing = settings[settingKey];
// Convert array values (like m3u-hash-key) to comma-separated strings
const stringValue = Array.isArray(values[settingKey])
? values[settingKey].join(',')
: `${values[settingKey]}`;
// Convert array values (like m3u_hash_key) to comma-separated strings for comparison
let compareValue;
let actualValue = values[settingKey];
if (Array.isArray(actualValue)) {
actualValue = actualValue.join(',');
compareValue = actualValue;
} else {
compareValue = String(actualValue);
}
// Skip empty values to avoid validation errors
if (!stringValue) {
if (!compareValue) {
continue;
}
if (!existing) {
// Create new setting on save
changedSettings[settingKey] = stringValue;
} else if (stringValue !== String(existing.value)) {
// If the user changed the setting's value from what's in the DB:
changedSettings[settingKey] = stringValue;
// Create new setting on save - preserve original type
changedSettings[settingKey] = actualValue;
} else if (compareValue !== String(existing.value)) {
// If the user changed the setting's value from what's in the DB - preserve original type
changedSettings[settingKey] = actualValue;
}
}
return changedSettings;
};
export const parseSettings = (settings) => {
return Object.entries(settings).reduce((acc, [key, value]) => {
// Modify each value based on its own properties
switch (value.value) {
case 'true':
value.value = true;
break;
case 'false':
value.value = false;
break;
}
const parsed = {};
let val = null;
switch (key) {
case 'm3u-hash-key':
// Split comma-separated string, filter out empty strings
val = value.value ? value.value.split(',').filter((v) => v) : [];
break;
case 'dvr-pre-offset-minutes':
case 'dvr-post-offset-minutes':
val = Number.parseInt(value.value || '0', 10);
if (Number.isNaN(val)) val = 0;
break;
default:
val = value.value;
break;
}
// Stream settings - direct mapping with underscore keys
const streamSettings = settings['stream_settings']?.value;
if (streamSettings && typeof streamSettings === 'object') {
// IDs must be strings for Select components
parsed.default_user_agent = streamSettings.default_user_agent != null ? String(streamSettings.default_user_agent) : null;
parsed.default_stream_profile = streamSettings.default_stream_profile != null ? String(streamSettings.default_stream_profile) : null;
parsed.preferred_region = streamSettings.preferred_region;
parsed.auto_import_mapped_files = streamSettings.auto_import_mapped_files;
acc[key] = val;
return acc;
}, {});
// m3u_hash_key should be array
const hashKey = streamSettings.m3u_hash_key;
if (typeof hashKey === 'string') {
parsed.m3u_hash_key = hashKey ? hashKey.split(',').filter((v) => v) : [];
} else if (Array.isArray(hashKey)) {
parsed.m3u_hash_key = hashKey;
} else {
parsed.m3u_hash_key = [];
}
}
// DVR settings - direct mapping with underscore keys
const dvrSettings = settings['dvr_settings']?.value;
if (dvrSettings && typeof dvrSettings === 'object') {
parsed.tv_template = dvrSettings.tv_template;
parsed.movie_template = dvrSettings.movie_template;
parsed.tv_fallback_dir = dvrSettings.tv_fallback_dir;
parsed.tv_fallback_template = dvrSettings.tv_fallback_template;
parsed.movie_fallback_template = dvrSettings.movie_fallback_template;
parsed.comskip_enabled = typeof dvrSettings.comskip_enabled === 'boolean' ? dvrSettings.comskip_enabled : Boolean(dvrSettings.comskip_enabled);
parsed.comskip_custom_path = dvrSettings.comskip_custom_path;
parsed.pre_offset_minutes = typeof dvrSettings.pre_offset_minutes === 'number' ? dvrSettings.pre_offset_minutes : parseInt(dvrSettings.pre_offset_minutes, 10) || 0;
parsed.post_offset_minutes = typeof dvrSettings.post_offset_minutes === 'number' ? dvrSettings.post_offset_minutes : parseInt(dvrSettings.post_offset_minutes, 10) || 0;
parsed.series_rules = dvrSettings.series_rules;
}
// Backup settings - direct mapping with underscore keys
const backupSettings = settings['backup_settings']?.value;
if (backupSettings && typeof backupSettings === 'object') {
parsed.schedule_enabled = typeof backupSettings.schedule_enabled === 'boolean' ? backupSettings.schedule_enabled : Boolean(backupSettings.schedule_enabled);
parsed.schedule_frequency = String(backupSettings.schedule_frequency || '');
parsed.schedule_time = String(backupSettings.schedule_time || '');
parsed.schedule_day_of_week = typeof backupSettings.schedule_day_of_week === 'number' ? backupSettings.schedule_day_of_week : parseInt(backupSettings.schedule_day_of_week, 10) || 0;
parsed.retention_count = typeof backupSettings.retention_count === 'number' ? backupSettings.retention_count : parseInt(backupSettings.retention_count, 10) || 0;
parsed.schedule_cron_expression = String(backupSettings.schedule_cron_expression || '');
}
// System settings - direct mapping with underscore keys
const systemSettings = settings['system_settings']?.value;
if (systemSettings && typeof systemSettings === 'object') {
parsed.time_zone = String(systemSettings.time_zone || '');
parsed.max_system_events = typeof systemSettings.max_system_events === 'number' ? systemSettings.max_system_events : parseInt(systemSettings.max_system_events, 10) || 100;
}
// Proxy and network access are already grouped objects
if (settings['proxy_settings']?.value) {
parsed.proxy_settings = settings['proxy_settings'].value;
}
if (settings['network_access']?.value) {
parsed.network_access = settings['network_access'].value;
}
return parsed;
};