diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00a87240..02c852d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,9 +32,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed unused Dashboard and Home pages
- Logo loading optimization: Logos now load only after both Channels and Streams tables complete loading to prevent blocking initial page render, with rendering gated by table readiness to ensure data loads before visual elements
- M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs
+- Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810) (PR #795)
+ - Extracted individual settings forms (DVR, Network Access, Proxy, Stream, System, UI) into separate components with dedicated utility files
+ - Moved larger nested components into their own files
+ - Moved business logic into corresponding utils/ files
+ - Extracted larger in-line component logic into its own function
+ - Each panel in Settings now uses its own form state with the parent component handling active state management
### Fixed
+- VOD category filtering now correctly handles category names containing pipe "|" characters (e.g., "PL | BAJKI", "EN | MOVIES") by using `rsplit()` to split from the right instead of the left, ensuring the category type is correctly extracted as the last segment - Thanks [@Vitekant](https://github.com/Vitekant)
- M3U and EPG URLs now correctly preserve non-standard HTTPS ports (e.g., `:8443`) when accessed behind reverse proxies that forward the port in headers — `get_host_and_port()` now properly checks `X-Forwarded-Port` header before falling back to other detection methods (Fixes #704)
- M3U and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation)
- Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect
diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py
index 8cc55a11..3bd984e6 100644
--- a/apps/vod/api_views.py
+++ b/apps/vod/api_views.py
@@ -62,7 +62,7 @@ class MovieFilter(django_filters.FilterSet):
# Handle the format 'category_name|category_type'
if '|' in value:
- category_name, category_type = value.split('|', 1)
+ category_name, category_type = value.rsplit('|', 1)
return queryset.filter(
m3u_relations__category__name=category_name,
m3u_relations__category__category_type=category_type
@@ -219,7 +219,7 @@ class SeriesFilter(django_filters.FilterSet):
# Handle the format 'category_name|category_type'
if '|' in value:
- category_name, category_type = value.split('|', 1)
+ category_name, category_type = value.rsplit('|', 1)
return queryset.filter(
m3u_relations__category__name=category_name,
m3u_relations__category__category_type=category_type
@@ -588,7 +588,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
if category:
if '|' in category:
- cat_name, cat_type = category.split('|', 1)
+ cat_name, cat_type = category.rsplit('|', 1)
if cat_type == 'movie':
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] = "1=0" # Exclude series
diff --git a/frontend/src/components/forms/settings/DvrSettingsForm.jsx b/frontend/src/components/forms/settings/DvrSettingsForm.jsx
new file mode 100644
index 00000000..f03bdf66
--- /dev/null
+++ b/frontend/src/components/forms/settings/DvrSettingsForm.jsx
@@ -0,0 +1,263 @@
+import useSettingsStore from '../../../store/settings.jsx';
+import React, { useEffect, useState } from 'react';
+import {
+ getChangedSettings,
+ parseSettings,
+ saveChangedSettings,
+} from '../../../utils/pages/SettingsUtils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import {
+ Alert,
+ Button,
+ FileInput,
+ Flex,
+ Group,
+ NumberInput,
+ Stack,
+ Switch,
+ Text,
+ TextInput,
+} from '@mantine/core';
+import {
+ getComskipConfig,
+ getDvrSettingsFormInitialValues,
+ uploadComskipIni,
+} from '../../../utils/forms/settings/DvrSettingsFormUtils.js';
+import { useForm } from '@mantine/form';
+
+const DvrSettingsForm = React.memo(({ active }) => {
+ const settings = useSettingsStore((s) => s.settings);
+ const [saved, setSaved] = useState(false);
+ const [comskipFile, setComskipFile] = useState(null);
+ const [comskipUploadLoading, setComskipUploadLoading] = useState(false);
+ const [comskipConfig, setComskipConfig] = useState({
+ path: '',
+ exists: false,
+ });
+
+ const form = useForm({
+ mode: 'controlled',
+ initialValues: getDvrSettingsFormInitialValues(),
+ });
+
+ useEffect(() => {
+ if (!active) setSaved(false);
+ }, [active]);
+
+ useEffect(() => {
+ if (settings) {
+ const formValues = parseSettings(settings);
+
+ form.setValues(formValues);
+
+ if (formValues['dvr-comskip-custom-path']) {
+ setComskipConfig((prev) => ({
+ path: formValues['dvr-comskip-custom-path'],
+ exists: prev.exists,
+ }));
+ }
+ }
+ }, [settings]);
+
+ useEffect(() => {
+ const loadComskipConfig = async () => {
+ try {
+ const response = await getComskipConfig();
+ if (response) {
+ setComskipConfig({
+ path: response.path || '',
+ exists: Boolean(response.exists),
+ });
+ if (response.path) {
+ form.setFieldValue('dvr-comskip-custom-path', response.path);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load comskip config', error);
+ }
+ };
+ loadComskipConfig();
+ }, []);
+
+ const onComskipUpload = async () => {
+ if (!comskipFile) {
+ return;
+ }
+
+ setComskipUploadLoading(true);
+ try {
+ const response = await uploadComskipIni(comskipFile);
+ if (response?.path) {
+ showNotification({
+ title: 'comskip.ini uploaded',
+ message: response.path,
+ autoClose: 3000,
+ color: 'green',
+ });
+ form.setFieldValue('dvr-comskip-custom-path', response.path);
+ useSettingsStore.getState().updateSetting({
+ ...(settings['dvr-comskip-custom-path'] || {
+ key: 'dvr-comskip-custom-path',
+ name: 'DVR Comskip Custom Path',
+ }),
+ value: response.path,
+ });
+ setComskipConfig({ path: response.path, exists: true });
+ }
+ } catch (error) {
+ console.error('Failed to upload comskip.ini', error);
+ } finally {
+ setComskipUploadLoading(false);
+ setComskipFile(null);
+ }
+ };
+
+ const onSubmit = async () => {
+ setSaved(false);
+
+ const changedSettings = getChangedSettings(form.getValues(), settings);
+
+ // Update each changed setting in the backend (create if missing)
+ try {
+ await saveChangedSettings(settings, changedSettings);
+
+ setSaved(true);
+ } catch (error) {
+ // Error notifications are already shown by API functions
+ // Just don't show the success message
+ console.error('Error saving settings:', error);
+ }
+ };
+
+ return (
+
+ );
+});
+
+export default DvrSettingsForm;
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/NetworkAccessForm.jsx b/frontend/src/components/forms/settings/NetworkAccessForm.jsx
new file mode 100644
index 00000000..1d2c42e7
--- /dev/null
+++ b/frontend/src/components/forms/settings/NetworkAccessForm.jsx
@@ -0,0 +1,161 @@
+import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
+import useSettingsStore from '../../../store/settings.jsx';
+import React, { useEffect, useState } from 'react';
+import { useForm } from '@mantine/form';
+import {
+ checkSetting,
+ updateSetting,
+} from '../../../utils/pages/SettingsUtils.js';
+import { Alert, Button, Flex, Stack, Text, TextInput } from '@mantine/core';
+import ConfirmationDialog from '../../ConfirmationDialog.jsx';
+import {
+ getNetworkAccessFormInitialValues,
+ getNetworkAccessFormValidation,
+} from '../../../utils/forms/settings/NetworkAccessFormUtils.js';
+
+const NetworkAccessForm = React.memo(({ active }) => {
+ const settings = useSettingsStore((s) => s.settings);
+
+ const [networkAccessError, setNetworkAccessError] = useState(null);
+ const [saved, setSaved] = useState(false);
+ const [networkAccessConfirmOpen, setNetworkAccessConfirmOpen] =
+ useState(false);
+ const [netNetworkAccessConfirmCIDRs, setNetNetworkAccessConfirmCIDRs] =
+ useState([]);
+ const [clientIpAddress, setClientIpAddress] = useState(null);
+
+ const networkAccessForm = useForm({
+ mode: 'controlled',
+ initialValues: getNetworkAccessFormInitialValues(),
+ validate: getNetworkAccessFormValidation(),
+ });
+
+ useEffect(() => {
+ if(!active) setSaved(false);
+ }, [active]);
+
+ useEffect(() => {
+ const networkAccessSettings = JSON.parse(
+ settings['network-access'].value || '{}'
+ );
+ networkAccessForm.setValues(
+ Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
+ acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0';
+ return acc;
+ }, {})
+ );
+ }, [settings]);
+
+ const onNetworkAccessSubmit = async () => {
+ setSaved(false);
+ setNetworkAccessError(null);
+ const check = await checkSetting({
+ ...settings['network-access'],
+ value: JSON.stringify(networkAccessForm.getValues()),
+ });
+
+ if (check.error && check.message) {
+ setNetworkAccessError(`${check.message}: ${check.data}`);
+ return;
+ }
+
+ // Store the client IP
+ setClientIpAddress(check.client_ip);
+
+ // For now, only warn if we're blocking the UI
+ const blockedAccess = check.UI;
+ if (blockedAccess.length === 0) {
+ return saveNetworkAccess();
+ }
+
+ setNetNetworkAccessConfirmCIDRs(blockedAccess);
+ setNetworkAccessConfirmOpen(true);
+ };
+
+ const saveNetworkAccess = async () => {
+ setSaved(false);
+ try {
+ await updateSetting({
+ ...settings['network-access'],
+ value: JSON.stringify(networkAccessForm.getValues()),
+ });
+ setSaved(true);
+ setNetworkAccessConfirmOpen(false);
+ } catch (e) {
+ const errors = {};
+ for (const key in e.body.value) {
+ errors[key] = `Invalid CIDR(s): ${e.body.value[key]}`;
+ }
+ networkAccessForm.setErrors(errors);
+ }
+ };
+
+ return (
+ <>
+
+
+ setNetworkAccessConfirmOpen(false)}
+ onConfirm={saveNetworkAccess}
+ title={`Confirm Network Access Blocks`}
+ message={
+ <>
+
+ Your client {clientIpAddress && `(${clientIpAddress}) `}is not
+ included in the allowed networks for the web UI. Are you sure you
+ want to proceed?
+
+
+
+ {netNetworkAccessConfirmCIDRs.map((cidr) => (
+ {cidr}
+ ))}
+
+ >
+ }
+ confirmLabel="Save"
+ cancelLabel="Cancel"
+ size="md"
+ />
+ >
+ );
+});
+
+export default NetworkAccessForm;
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx
new file mode 100644
index 00000000..7fc2d0cb
--- /dev/null
+++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx
@@ -0,0 +1,166 @@
+import useSettingsStore from '../../../store/settings.jsx';
+import React, { useEffect, useState } from 'react';
+import { useForm } from '@mantine/form';
+import { updateSetting } from '../../../utils/pages/SettingsUtils.js';
+import {
+ Alert,
+ Button,
+ Flex,
+ NumberInput,
+ Stack,
+ TextInput,
+} from '@mantine/core';
+import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js';
+import {
+ getProxySettingDefaults,
+ getProxySettingsFormInitialValues,
+} from '../../../utils/forms/settings/ProxySettingsFormUtils.js';
+
+const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => {
+ const isNumericField = (key) => {
+ // Determine if this field should be a NumberInput
+ return [
+ 'buffering_timeout',
+ 'redis_chunk_ttl',
+ 'channel_shutdown_delay',
+ 'channel_init_grace_period',
+ ].includes(key);
+ };
+ const isFloatField = (key) => {
+ return key === 'buffering_speed';
+ };
+ const getNumericFieldMax = (key) => {
+ return key === 'buffering_timeout'
+ ? 300
+ : key === 'redis_chunk_ttl'
+ ? 3600
+ : key === 'channel_shutdown_delay'
+ ? 300
+ : 60;
+ };
+ return (
+ <>
+ {Object.entries(PROXY_SETTINGS_OPTIONS).map(([key, config]) => {
+ if (isNumericField(key)) {
+ return (
+
+ );
+ } else if (isFloatField(key)) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ })}
+ >
+ );
+});
+
+const ProxySettingsForm = React.memo(({ active }) => {
+ const settings = useSettingsStore((s) => s.settings);
+
+ const [saved, setSaved] = useState(false);
+
+ const proxySettingsForm = useForm({
+ mode: 'controlled',
+ initialValues: getProxySettingsFormInitialValues(),
+ });
+
+ useEffect(() => {
+ 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);
+ }
+ }
+ }
+ }, [settings]);
+
+ const resetProxySettingsToDefaults = () => {
+ proxySettingsForm.setValues(getProxySettingDefaults());
+ };
+
+ const onProxySettingsSubmit = async () => {
+ setSaved(false);
+
+ try {
+ const result = await updateSetting({
+ ...settings['proxy-settings'],
+ value: JSON.stringify(proxySettingsForm.getValues()),
+ });
+ // API functions return undefined on error
+ if (result) {
+ setSaved(true);
+ }
+ } catch (error) {
+ // Error notifications are already shown by API functions
+ console.error('Error saving proxy settings:', error);
+ }
+ };
+
+ return (
+
+ );
+});
+
+export default ProxySettingsForm;
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/StreamSettingsForm.jsx b/frontend/src/components/forms/settings/StreamSettingsForm.jsx
new file mode 100644
index 00000000..1b6b466d
--- /dev/null
+++ b/frontend/src/components/forms/settings/StreamSettingsForm.jsx
@@ -0,0 +1,306 @@
+import useSettingsStore from '../../../store/settings.jsx';
+import useWarningsStore from '../../../store/warnings.jsx';
+import useUserAgentsStore from '../../../store/userAgents.jsx';
+import useStreamProfilesStore from '../../../store/streamProfiles.jsx';
+import { REGION_CHOICES } from '../../../constants.js';
+import React, { useEffect, useState } from 'react';
+import {
+ getChangedSettings,
+ parseSettings,
+ rehashStreams,
+ saveChangedSettings,
+} from '../../../utils/pages/SettingsUtils.js';
+import {
+ Alert,
+ Button,
+ Flex,
+ Group,
+ MultiSelect,
+ Select,
+ Switch,
+ Text,
+} from '@mantine/core';
+import ConfirmationDialog from '../../ConfirmationDialog.jsx';
+import { useForm } from '@mantine/form';
+import {
+ getStreamSettingsFormInitialValues,
+ getStreamSettingsFormValidation,
+} from '../../../utils/forms/settings/StreamSettingsFormUtils.js';
+
+const StreamSettingsForm = React.memo(({ active }) => {
+ const settings = useSettingsStore((s) => s.settings);
+ const suppressWarning = useWarningsStore((s) => s.suppressWarning);
+ const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
+ const userAgents = useUserAgentsStore((s) => s.userAgents);
+ const streamProfiles = useStreamProfilesStore((s) => s.profiles);
+ const regionChoices = REGION_CHOICES;
+
+ // Store pending changed settings when showing the dialog
+ const [pendingChangedSettings, setPendingChangedSettings] = useState(null);
+
+ const [saved, setSaved] = useState(false);
+ const [rehashingStreams, setRehashingStreams] = useState(false);
+ const [rehashSuccess, setRehashSuccess] = useState(false);
+ const [rehashConfirmOpen, setRehashConfirmOpen] = useState(false);
+
+ // Add a new state to track the dialog type
+ const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash'
+
+ const form = useForm({
+ mode: 'controlled',
+ initialValues: getStreamSettingsFormInitialValues(),
+ validate: getStreamSettingsFormValidation(),
+ });
+
+ useEffect(() => {
+ if (!active) {
+ setSaved(false);
+ setRehashSuccess(false);
+ }
+ }, [active]);
+
+ useEffect(() => {
+ if (settings) {
+ const formValues = parseSettings(settings);
+
+ form.setValues(formValues);
+ }
+ }, [settings]);
+
+ const executeSettingsSaveAndRehash = async () => {
+ setRehashConfirmOpen(false);
+ setSaved(false);
+
+ // Use the stored pending values that were captured before the dialog was shown
+ const changedSettings = pendingChangedSettings || {};
+
+ // Update each changed setting in the backend (create if missing)
+ try {
+ await saveChangedSettings(settings, changedSettings);
+
+ // Clear the pending values
+ setPendingChangedSettings(null);
+ setSaved(true);
+ } catch (error) {
+ // Error notifications are already shown by API functions
+ // Just don't show the success message
+ console.error('Error saving settings:', error);
+ setPendingChangedSettings(null);
+ }
+ };
+
+ const executeRehashStreamsOnly = async () => {
+ setRehashingStreams(true);
+ setRehashSuccess(false);
+ setRehashConfirmOpen(false);
+
+ try {
+ await rehashStreams();
+ setRehashSuccess(true);
+ setTimeout(() => setRehashSuccess(false), 5000);
+ } catch (error) {
+ console.error('Error rehashing streams:', error);
+ } finally {
+ setRehashingStreams(false);
+ }
+ };
+
+ const onRehashStreams = async () => {
+ // Skip warning if it's been suppressed
+ if (isWarningSuppressed('rehash-streams')) {
+ return executeRehashStreamsOnly();
+ }
+
+ setRehashDialogType('rehash'); // Set dialog type to rehash
+ setRehashConfirmOpen(true);
+ };
+
+ const handleRehashConfirm = () => {
+ if (rehashDialogType === 'save') {
+ executeSettingsSaveAndRehash();
+ } else {
+ executeRehashStreamsOnly();
+ }
+ };
+
+ const onSubmit = async () => {
+ setSaved(false);
+
+ const values = form.getValues();
+ const changedSettings = getChangedSettings(values, settings);
+
+ const m3uHashKeyChanged =
+ settings['m3u-hash-key']?.value !== values['m3u-hash-key'].join(',');
+
+ // If M3U hash key changed, show warning (unless suppressed)
+ if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
+ // Store the changed settings before showing dialog
+ setPendingChangedSettings(changedSettings);
+ setRehashDialogType('save'); // Set dialog type to save
+ setRehashConfirmOpen(true);
+ return;
+ }
+
+ // Update each changed setting in the backend (create if missing)
+ try {
+ await saveChangedSettings(settings, changedSettings);
+
+ setSaved(true);
+ } catch (error) {
+ // Error notifications are already shown by API functions
+ // Just don't show the success message
+ console.error('Error saving settings:', error);
+ }
+ };
+
+ return (
+ <>
+
+
+ {
+ setRehashConfirmOpen(false);
+ setRehashDialogType(null);
+ // Clear pending values when dialog is cancelled
+ setPendingChangedSettings(null);
+ }}
+ onConfirm={handleRehashConfirm}
+ title={
+ rehashDialogType === 'save'
+ ? 'Save Settings and Rehash Streams'
+ : 'Confirm Stream Rehash'
+ }
+ message={
+
+ {`Are you sure you want to rehash all streams?
+
+This process may take a while depending on the number of streams.
+Do not shut down Dispatcharr until the rehashing is complete.
+M3U refreshes will be blocked until this process finishes.
+
+Please ensure you have time to let this complete before proceeding.`}
+
+ }
+ confirmLabel={
+ rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'
+ }
+ cancelLabel="Cancel"
+ actionKey="rehash-streams"
+ onSuppressChange={suppressWarning}
+ size="md"
+ />
+ >
+ );
+});
+
+export default StreamSettingsForm;
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/SystemSettingsForm.jsx b/frontend/src/components/forms/settings/SystemSettingsForm.jsx
new file mode 100644
index 00000000..3f05c564
--- /dev/null
+++ b/frontend/src/components/forms/settings/SystemSettingsForm.jsx
@@ -0,0 +1,84 @@
+import useSettingsStore from '../../../store/settings.jsx';
+import React, { useEffect, useState } from 'react';
+import {
+ getChangedSettings,
+ parseSettings,
+ saveChangedSettings,
+} from '../../../utils/pages/SettingsUtils.js';
+import { Alert, Button, Flex, NumberInput, Stack, Text } from '@mantine/core';
+import { useForm } from '@mantine/form';
+import { getSystemSettingsFormInitialValues } from '../../../utils/forms/settings/SystemSettingsFormUtils.js';
+
+const SystemSettingsForm = React.memo(({ active }) => {
+ const settings = useSettingsStore((s) => s.settings);
+
+ const [saved, setSaved] = useState(false);
+
+ const form = useForm({
+ mode: 'controlled',
+ initialValues: getSystemSettingsFormInitialValues(),
+ });
+
+ useEffect(() => {
+ if (!active) setSaved(false);
+ }, [active]);
+
+ useEffect(() => {
+ if (settings) {
+ const formValues = parseSettings(settings);
+
+ form.setValues(formValues);
+ }
+ }, [settings]);
+
+ const onSubmit = async () => {
+ setSaved(false);
+
+ const changedSettings = getChangedSettings(form.getValues(), settings);
+
+ // Update each changed setting in the backend (create if missing)
+ try {
+ await saveChangedSettings(settings, changedSettings);
+
+ setSaved(true);
+ } catch (error) {
+ // Error notifications are already shown by API functions
+ // Just don't show the success message
+ console.error('Error saving settings:', error);
+ }
+ };
+
+ return (
+
+ {saved && (
+
+ )}
+
+ Configure how many system events (channel start/stop, buffering, etc.)
+ to keep in the database. Events are displayed on the Stats page.
+
+ {
+ form.setFieldValue('max-system-events', value);
+ }}
+ min={10}
+ max={1000}
+ step={10}
+ />
+
+
+ Save
+
+
+
+ );
+});
+
+export default SystemSettingsForm;
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/UiSettingsForm.jsx b/frontend/src/components/forms/settings/UiSettingsForm.jsx
new file mode 100644
index 00000000..c0f7b354
--- /dev/null
+++ b/frontend/src/components/forms/settings/UiSettingsForm.jsx
@@ -0,0 +1,144 @@
+import useSettingsStore from '../../../store/settings.jsx';
+import useLocalStorage from '../../../hooks/useLocalStorage.jsx';
+import {
+ buildTimeZoneOptions,
+ getDefaultTimeZone,
+} from '../../../utils/dateTimeUtils.js';
+import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import { Select } from '@mantine/core';
+import { saveTimeZoneSetting } from '../../../utils/forms/settings/UiSettingsFormUtils.js';
+
+const UiSettingsForm = React.memo(() => {
+ const settings = useSettingsStore((s) => s.settings);
+
+ const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
+ const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
+ const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy');
+ const [timeZone, setTimeZone] = useLocalStorage(
+ 'time-zone',
+ getDefaultTimeZone()
+ );
+
+ const timeZoneOptions = useMemo(
+ () => buildTimeZoneOptions(timeZone),
+ [timeZone]
+ );
+
+ const timeZoneSyncedRef = useRef(false);
+
+ const persistTimeZoneSetting = useCallback(
+ async (tzValue) => {
+ try {
+ await saveTimeZoneSetting(tzValue, settings);
+ } catch (error) {
+ console.error('Failed to persist time zone setting', error);
+ showNotification({
+ title: 'Failed to update time zone',
+ message: 'Could not save the selected time zone. Please try again.',
+ color: 'red',
+ });
+ }
+ },
+ [settings]
+ );
+
+ useEffect(() => {
+ if (settings) {
+ const tzSetting = settings['system-time-zone'];
+ if (tzSetting?.value) {
+ timeZoneSyncedRef.current = true;
+ setTimeZone((prev) =>
+ prev === tzSetting.value ? prev : tzSetting.value
+ );
+ } else if (!timeZoneSyncedRef.current && timeZone) {
+ timeZoneSyncedRef.current = true;
+ persistTimeZoneSetting(timeZone);
+ }
+ }
+ }, [settings, timeZone, setTimeZone, persistTimeZoneSetting]);
+
+ const onUISettingsChange = (name, value) => {
+ switch (name) {
+ case 'table-size':
+ if (value) setTableSize(value);
+ break;
+ case 'time-format':
+ if (value) setTimeFormat(value);
+ break;
+ case 'date-format':
+ if (value) setDateFormat(value);
+ break;
+ case 'time-zone':
+ if (value) {
+ setTimeZone(value);
+ persistTimeZoneSetting(value);
+ }
+ break;
+ }
+ };
+
+ return (
+ <>
+ onUISettingsChange('table-size', val)}
+ data={[
+ {
+ value: 'default',
+ label: 'Default',
+ },
+ {
+ value: 'compact',
+ label: 'Compact',
+ },
+ {
+ value: 'large',
+ label: 'Large',
+ },
+ ]}
+ />
+ onUISettingsChange('time-format', val)}
+ data={[
+ {
+ value: '12h',
+ label: '12 hour time',
+ },
+ {
+ value: '24h',
+ label: '24 hour time',
+ },
+ ]}
+ />
+ onUISettingsChange('date-format', val)}
+ data={[
+ {
+ value: 'mdy',
+ label: 'MM/DD/YYYY',
+ },
+ {
+ value: 'dmy',
+ label: 'DD/MM/YYYY',
+ },
+ ]}
+ />
+ onUISettingsChange('time-zone', val)}
+ data={timeZoneOptions}
+ />
+ >
+ );
+});
+
+export default UiSettingsForm;
\ No newline at end of file
diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx
index 0fe4f7a7..b7b87b17 100644
--- a/frontend/src/pages/Channels.jsx
+++ b/frontend/src/pages/Channels.jsx
@@ -65,6 +65,7 @@ const PageContent = () => {
if (!authUser.id) return <>>;
if (authUser.user_level <= USER_LEVELS.STANDARD) {
+ handleStreamsReady();
return (
diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx
index 889e32c9..f95212d6 100644
--- a/frontend/src/pages/Logos.jsx
+++ b/frontend/src/pages/Logos.jsx
@@ -1,34 +1,34 @@
import React, { useEffect, useCallback, useState } from 'react';
-import { Box, Tabs, Flex, Text } from '@mantine/core';
-import { notifications } from '@mantine/notifications';
+import { Box, Tabs, Flex, Text, TabsList, TabsTab } from '@mantine/core';
import useLogosStore from '../store/logos';
import useVODLogosStore from '../store/vodLogos';
import LogosTable from '../components/tables/LogosTable';
import VODLogosTable from '../components/tables/VODLogosTable';
+import { showNotification } from '../utils/notificationUtils.js';
const LogosPage = () => {
- const { fetchAllLogos, needsAllLogos, logos } = useLogosStore();
- const { totalCount } = useVODLogosStore();
+ const logos = useLogosStore(s => s.logos);
+ const totalCount = useVODLogosStore(s => s.totalCount);
const [activeTab, setActiveTab] = useState('channel');
-
- const channelLogosCount = Object.keys(logos).length;
- const vodLogosCount = totalCount;
+ const logoCount = activeTab === 'channel'
+ ? Object.keys(logos).length
+ : totalCount;
const loadChannelLogos = useCallback(async () => {
try {
// Only fetch all logos if we haven't loaded them yet
- if (needsAllLogos()) {
- await fetchAllLogos();
+ if (useLogosStore.getState().needsAllLogos()) {
+ await useLogosStore.getState().fetchAllLogos();
}
} catch (err) {
- notifications.show({
+ showNotification({
title: 'Error',
message: 'Failed to load channel logos',
color: 'red',
});
console.error('Failed to load channel logos:', err);
}
- }, [fetchAllLogos, needsAllLogos]);
+ }, []);
useEffect(() => {
// Always load channel logos on mount
@@ -39,51 +39,41 @@ const LogosPage = () => {
{/* Header with title and tabs */}
Logos
- ({activeTab === 'channel' ? channelLogosCount : vodLogosCount}{' '}
- logo
- {(activeTab === 'channel' ? channelLogosCount : vodLogosCount) !==
- 1
- ? 's'
- : ''}
- )
+ ({logoCount} {logoCount !== 1 ? 'logos' : 'logo'})
-
- Channel Logos
- VOD Logos
-
+
+ Channel Logos
+ VOD Logos
+
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index d523869e..4ce519a3 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1,1390 +1,166 @@
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import API from '../api';
-import useSettingsStore from '../store/settings';
-import useUserAgentsStore from '../store/userAgents';
-import useStreamProfilesStore from '../store/streamProfiles';
+import React, { Suspense, useState } from 'react';
import {
Accordion,
- Alert,
+ AccordionControl,
+ AccordionItem,
+ AccordionPanel,
Box,
- Button,
Center,
- Flex,
- Group,
- FileInput,
- MultiSelect,
- Select,
- Stack,
- Switch,
Text,
- TextInput,
- NumberInput,
+ Loader
} from '@mantine/core';
-import { isNotEmpty, useForm } from '@mantine/form';
-import { notifications } from '@mantine/notifications';
-import UserAgentsTable from '../components/tables/UserAgentsTable';
-import StreamProfilesTable from '../components/tables/StreamProfilesTable';
-import BackupManager from '../components/backups/BackupManager';
-import useLocalStorage from '../hooks/useLocalStorage';
+const UserAgentsTable = React.lazy(() =>
+ import('../components/tables/UserAgentsTable.jsx'));
+const StreamProfilesTable = React.lazy(() =>
+ import('../components/tables/StreamProfilesTable.jsx'));
+const BackupManager = React.lazy(() =>
+ import('../components/backups/BackupManager.jsx'));
import useAuthStore from '../store/auth';
-import {
- USER_LEVELS,
- NETWORK_ACCESS_OPTIONS,
- PROXY_SETTINGS_OPTIONS,
- REGION_CHOICES,
-} from '../constants';
-import ConfirmationDialog from '../components/ConfirmationDialog';
-import useWarningsStore from '../store/warnings';
-
-const TIMEZONE_FALLBACKS = [
- 'UTC',
- 'America/New_York',
- 'America/Chicago',
- 'America/Denver',
- 'America/Los_Angeles',
- 'America/Phoenix',
- 'America/Anchorage',
- 'Pacific/Honolulu',
- 'Europe/London',
- 'Europe/Paris',
- 'Europe/Berlin',
- 'Europe/Madrid',
- 'Europe/Warsaw',
- 'Europe/Moscow',
- 'Asia/Dubai',
- 'Asia/Kolkata',
- 'Asia/Shanghai',
- 'Asia/Tokyo',
- 'Asia/Seoul',
- 'Australia/Sydney',
-];
-
-const getSupportedTimeZones = () => {
- try {
- if (typeof Intl.supportedValuesOf === 'function') {
- return Intl.supportedValuesOf('timeZone');
- }
- } catch (error) {
- console.warn('Unable to enumerate supported time zones:', error);
- }
- return TIMEZONE_FALLBACKS;
-};
-
-const getTimeZoneOffsetMinutes = (date, timeZone) => {
- try {
- const dtf = new Intl.DateTimeFormat('en-US', {
- timeZone,
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hourCycle: 'h23',
- });
- const parts = dtf.formatToParts(date).reduce((acc, part) => {
- if (part.type !== 'literal') acc[part.type] = part.value;
- return acc;
- }, {});
- const asUTC = Date.UTC(
- Number(parts.year),
- Number(parts.month) - 1,
- Number(parts.day),
- Number(parts.hour),
- Number(parts.minute),
- Number(parts.second)
- );
- return (asUTC - date.getTime()) / 60000;
- } catch (error) {
- console.warn(`Failed to compute offset for ${timeZone}:`, error);
- return 0;
- }
-};
-
-const formatOffset = (minutes) => {
- const rounded = Math.round(minutes);
- const sign = rounded < 0 ? '-' : '+';
- const absolute = Math.abs(rounded);
- const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
- const mins = String(absolute % 60).padStart(2, '0');
- return `UTC${sign}${hours}:${mins}`;
-};
-
-const buildTimeZoneOptions = (preferredZone) => {
- const zones = getSupportedTimeZones();
- const referenceYear = new Date().getUTCFullYear();
- const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0));
- const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0));
-
- const options = zones
- .map((zone) => {
- const janOffset = getTimeZoneOffsetMinutes(janDate, zone);
- const julOffset = getTimeZoneOffsetMinutes(julDate, zone);
- const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone);
- const minOffset = Math.min(janOffset, julOffset);
- const maxOffset = Math.max(janOffset, julOffset);
- const usesDst = minOffset !== maxOffset;
- const labelParts = [`now ${formatOffset(currentOffset)}`];
- if (usesDst) {
- labelParts.push(
- `DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}`
- );
- }
- return {
- value: zone,
- label: `${zone} (${labelParts.join(' | ')})`,
- numericOffset: minOffset,
- };
- })
- .sort((a, b) => {
- if (a.numericOffset !== b.numericOffset) {
- return a.numericOffset - b.numericOffset;
- }
- return a.value.localeCompare(b.value);
- });
- if (
- preferredZone &&
- !options.some((option) => option.value === preferredZone)
- ) {
- const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone);
- options.push({
- value: preferredZone,
- label: `${preferredZone} (now ${formatOffset(currentOffset)})`,
- numericOffset: currentOffset,
- });
- options.sort((a, b) => {
- if (a.numericOffset !== b.numericOffset) {
- return a.numericOffset - b.numericOffset;
- }
- return a.value.localeCompare(b.value);
- });
- }
- return options;
-};
-
-const getDefaultTimeZone = () => {
- try {
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
- } catch (error) {
- return 'UTC';
- }
-};
+import { USER_LEVELS } from '../constants';
+import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+const NetworkAccessForm = React.lazy(() =>
+ import('../components/forms/settings/NetworkAccessForm.jsx'));
+const ProxySettingsForm = React.lazy(() =>
+ import('../components/forms/settings/ProxySettingsForm.jsx'));
+const StreamSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/StreamSettingsForm.jsx'));
+const DvrSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/DvrSettingsForm.jsx'));
+const SystemSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/SystemSettingsForm.jsx'));
const SettingsPage = () => {
- const settings = useSettingsStore((s) => s.settings);
- const userAgents = useUserAgentsStore((s) => s.userAgents);
- const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const authUser = useAuthStore((s) => s.user);
- const suppressWarning = useWarningsStore((s) => s.suppressWarning);
- const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const [accordianValue, setAccordianValue] = useState(null);
- const [networkAccessSaved, setNetworkAccessSaved] = useState(false);
- const [networkAccessError, setNetworkAccessError] = useState(null);
- const [networkAccessConfirmOpen, setNetworkAccessConfirmOpen] =
- useState(false);
- const [netNetworkAccessConfirmCIDRs, setNetNetworkAccessConfirmCIDRs] =
- useState([]);
-
- const [clientIpAddress, setClientIpAddress] = useState(null);
-
- const [proxySettingsSaved, setProxySettingsSaved] = useState(false);
- const [generalSettingsSaved, setGeneralSettingsSaved] = useState(false);
- const [rehashingStreams, setRehashingStreams] = useState(false);
- const [rehashSuccess, setRehashSuccess] = useState(false);
- const [rehashConfirmOpen, setRehashConfirmOpen] = useState(false);
-
- // Add a new state to track the dialog type
- const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash'
-
- // Store pending changed settings when showing the dialog
- const [pendingChangedSettings, setPendingChangedSettings] = useState(null);
- const [comskipFile, setComskipFile] = useState(null);
- const [comskipUploadLoading, setComskipUploadLoading] = useState(false);
- const [comskipConfig, setComskipConfig] = useState({
- path: '',
- exists: false,
- });
-
- // UI / local storage settings
- const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
- const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
- const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy');
- const [timeZone, setTimeZone] = useLocalStorage(
- 'time-zone',
- getDefaultTimeZone()
- );
- const timeZoneOptions = useMemo(
- () => buildTimeZoneOptions(timeZone),
- [timeZone]
- );
- const timeZoneSyncedRef = useRef(false);
-
- const persistTimeZoneSetting = useCallback(
- async (tzValue) => {
- try {
- const existing = settings['system-time-zone'];
- if (existing && existing.id) {
- await API.updateSetting({ ...existing, value: tzValue });
- } else {
- await API.createSetting({
- key: 'system-time-zone',
- name: 'System Time Zone',
- value: tzValue,
- });
- }
- } catch (error) {
- console.error('Failed to persist time zone setting', error);
- notifications.show({
- title: 'Failed to update time zone',
- message: 'Could not save the selected time zone. Please try again.',
- color: 'red',
- });
- }
- },
- [settings]
- );
-
- const regionChoices = REGION_CHOICES;
-
- const form = useForm({
- mode: 'controlled',
- initialValues: {
- 'default-user-agent': '',
- 'default-stream-profile': '',
- 'preferred-region': '',
- 'auto-import-mapped-files': true,
- 'm3u-hash-key': [],
- '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,
- },
-
- validate: {
- 'default-user-agent': isNotEmpty('Select a user agent'),
- 'default-stream-profile': isNotEmpty('Select a stream profile'),
- 'preferred-region': isNotEmpty('Select a region'),
- },
- });
-
- const networkAccessForm = useForm({
- mode: 'controlled',
- initialValues: Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = '0.0.0.0/0,::/0';
- return acc;
- }, {}),
- validate: Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = (value) => {
- const cidrs = value.split(',');
- const ipv4CidrRegex = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/;
- const ipv6CidrRegex =
- /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/;
- for (const cidr of cidrs) {
- if (cidr.match(ipv4CidrRegex) || cidr.match(ipv6CidrRegex)) {
- continue;
- }
-
- return 'Invalid CIDR range';
- }
-
- return null;
- };
- return acc;
- }, {}),
- });
-
- const proxySettingsForm = useForm({
- mode: 'controlled',
- initialValues: Object.keys(PROXY_SETTINGS_OPTIONS).reduce((acc, key) => {
- acc[key] = '';
- return acc;
- }, {}),
- });
-
- useEffect(() => {
- if (settings) {
- const formValues = 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;
- }
-
- 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;
- }
-
- acc[key] = val;
- return acc;
- },
- {}
- );
-
- form.setValues(formValues);
- if (formValues['dvr-comskip-custom-path']) {
- setComskipConfig((prev) => ({
- path: formValues['dvr-comskip-custom-path'],
- exists: prev.exists,
- }));
- }
-
- const networkAccessSettings = JSON.parse(
- settings['network-access'].value || '{}'
- );
- networkAccessForm.setValues(
- Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0';
- return acc;
- }, {})
- );
-
- 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);
- }
- }
-
- const tzSetting = settings['system-time-zone'];
- if (tzSetting?.value) {
- timeZoneSyncedRef.current = true;
- setTimeZone((prev) =>
- prev === tzSetting.value ? prev : tzSetting.value
- );
- } else if (!timeZoneSyncedRef.current && timeZone) {
- timeZoneSyncedRef.current = true;
- persistTimeZoneSetting(timeZone);
- }
- }
- }, [settings, timeZone, setTimeZone, persistTimeZoneSetting]);
-
- useEffect(() => {
- const loadComskipConfig = async () => {
- try {
- const response = await API.getComskipConfig();
- if (response) {
- setComskipConfig({
- path: response.path || '',
- exists: Boolean(response.exists),
- });
- if (response.path) {
- form.setFieldValue('dvr-comskip-custom-path', response.path);
- }
- }
- } catch (error) {
- console.error('Failed to load comskip config', error);
- }
- };
- loadComskipConfig();
- }, []);
-
- // Clear success states when switching accordion panels
- useEffect(() => {
- setGeneralSettingsSaved(false);
- setProxySettingsSaved(false);
- setNetworkAccessSaved(false);
- setRehashSuccess(false);
- }, [accordianValue]);
-
- const onSubmit = async () => {
- setGeneralSettingsSaved(false);
-
- const values = form.getValues();
- const changedSettings = {};
- let m3uHashKeyChanged = false;
-
- for (const settingKey in values) {
- // Only compare against existing value if the setting exists
- const existing = settings[settingKey];
-
- // Convert array values (like m3u-hash-key) to comma-separated strings
- let stringValue;
- if (Array.isArray(values[settingKey])) {
- stringValue = values[settingKey].join(',');
- } else {
- stringValue = `${values[settingKey]}`;
- }
-
- // Skip empty values to avoid validation errors
- if (!stringValue) {
- 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;
-
- // Check if M3U hash key was changed
- if (settingKey === 'm3u-hash-key') {
- m3uHashKeyChanged = true;
- }
- }
- }
-
- // If M3U hash key changed, show warning (unless suppressed)
- if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
- // Store the changed settings before showing dialog
- setPendingChangedSettings(changedSettings);
- setRehashDialogType('save'); // Set dialog type to save
- setRehashConfirmOpen(true);
- return;
- }
-
- // Update each changed setting in the backend (create if missing)
- try {
- for (const updatedKey in changedSettings) {
- const existing = settings[updatedKey];
- if (existing && existing.id) {
- const result = await API.updateSetting({
- ...existing,
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to update setting');
- }
- } else {
- const result = await API.createSetting({
- key: updatedKey,
- name: updatedKey.replace(/-/g, ' '),
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to create setting');
- }
- }
- }
-
- setGeneralSettingsSaved(true);
- } catch (error) {
- // Error notifications are already shown by API functions
- // Just don't show the success message
- console.error('Error saving settings:', error);
- }
- };
-
- const onNetworkAccessSubmit = async () => {
- setNetworkAccessSaved(false);
- setNetworkAccessError(null);
- const check = await API.checkSetting({
- ...settings['network-access'],
- value: JSON.stringify(networkAccessForm.getValues()),
- });
-
- if (check.error && check.message) {
- setNetworkAccessError(`${check.message}: ${check.data}`);
- return;
- }
-
- // Store the client IP
- setClientIpAddress(check.client_ip);
-
- // For now, only warn if we're blocking the UI
- const blockedAccess = check.UI;
- if (blockedAccess.length == 0) {
- return saveNetworkAccess();
- }
-
- setNetNetworkAccessConfirmCIDRs(blockedAccess);
- setNetworkAccessConfirmOpen(true);
- };
-
- const onProxySettingsSubmit = async () => {
- setProxySettingsSaved(false);
-
- try {
- const result = await API.updateSetting({
- ...settings['proxy-settings'],
- value: JSON.stringify(proxySettingsForm.getValues()),
- });
- // API functions return undefined on error
- if (result) {
- setProxySettingsSaved(true);
- }
- } catch (error) {
- // Error notifications are already shown by API functions
- console.error('Error saving proxy settings:', error);
- }
- };
-
- const onComskipUpload = async () => {
- if (!comskipFile) {
- return;
- }
-
- setComskipUploadLoading(true);
- try {
- const response = await API.uploadComskipIni(comskipFile);
- if (response?.path) {
- notifications.show({
- title: 'comskip.ini uploaded',
- message: response.path,
- autoClose: 3000,
- color: 'green',
- });
- form.setFieldValue('dvr-comskip-custom-path', response.path);
- useSettingsStore.getState().updateSetting({
- ...(settings['dvr-comskip-custom-path'] || {
- key: 'dvr-comskip-custom-path',
- name: 'DVR Comskip Custom Path',
- }),
- value: response.path,
- });
- setComskipConfig({ path: response.path, exists: true });
- }
- } catch (error) {
- console.error('Failed to upload comskip.ini', error);
- } finally {
- setComskipUploadLoading(false);
- setComskipFile(null);
- }
- };
-
- const resetProxySettingsToDefaults = () => {
- const defaultValues = {
- buffering_timeout: 15,
- buffering_speed: 1.0,
- redis_chunk_ttl: 60,
- channel_shutdown_delay: 0,
- channel_init_grace_period: 5,
- };
-
- proxySettingsForm.setValues(defaultValues);
- };
-
- const saveNetworkAccess = async () => {
- setNetworkAccessSaved(false);
- try {
- await API.updateSetting({
- ...settings['network-access'],
- value: JSON.stringify(networkAccessForm.getValues()),
- });
- setNetworkAccessSaved(true);
- setNetworkAccessConfirmOpen(false);
- } catch (e) {
- const errors = {};
- for (const key in e.body.value) {
- errors[key] = `Invalid CIDR(s): ${e.body.value[key]}`;
- }
- networkAccessForm.setErrors(errors);
- }
- };
-
- const onUISettingsChange = (name, value) => {
- switch (name) {
- case 'table-size':
- if (value) setTableSize(value);
- break;
- case 'time-format':
- if (value) setTimeFormat(value);
- break;
- case 'date-format':
- if (value) setDateFormat(value);
- break;
- case 'time-zone':
- if (value) {
- setTimeZone(value);
- persistTimeZoneSetting(value);
- }
- break;
- }
- };
-
- const executeSettingsSaveAndRehash = async () => {
- setRehashConfirmOpen(false);
- setGeneralSettingsSaved(false);
-
- // Use the stored pending values that were captured before the dialog was shown
- const changedSettings = pendingChangedSettings || {};
-
- // Update each changed setting in the backend (create if missing)
- try {
- for (const updatedKey in changedSettings) {
- const existing = settings[updatedKey];
- if (existing && existing.id) {
- const result = await API.updateSetting({
- ...existing,
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to update setting');
- }
- } else {
- const result = await API.createSetting({
- key: updatedKey,
- name: updatedKey.replace(/-/g, ' '),
- value: changedSettings[updatedKey],
- });
- // API functions return undefined on error
- if (!result) {
- throw new Error('Failed to create setting');
- }
- }
- }
-
- // Clear the pending values
- setPendingChangedSettings(null);
- setGeneralSettingsSaved(true);
- } catch (error) {
- // Error notifications are already shown by API functions
- // Just don't show the success message
- console.error('Error saving settings:', error);
- setPendingChangedSettings(null);
- }
- };
-
- const executeRehashStreamsOnly = async () => {
- setRehashingStreams(true);
- setRehashSuccess(false);
- setRehashConfirmOpen(false);
-
- try {
- await API.rehashStreams();
- setRehashSuccess(true);
- setTimeout(() => setRehashSuccess(false), 5000);
- } catch (error) {
- console.error('Error rehashing streams:', error);
- } finally {
- setRehashingStreams(false);
- }
- };
-
- const onRehashStreams = async () => {
- // Skip warning if it's been suppressed
- if (isWarningSuppressed('rehash-streams')) {
- return executeRehashStreamsOnly();
- }
-
- setRehashDialogType('rehash'); // Set dialog type to rehash
- setRehashConfirmOpen(true);
- };
-
- // Create a function to handle the confirmation based on dialog type
- const handleRehashConfirm = () => {
- if (rehashDialogType === 'save') {
- executeSettingsSaveAndRehash();
- } else {
- executeRehashStreamsOnly();
- }
- };
return (
-
-
+
+
-
- UI Settings
-
- onUISettingsChange('table-size', val)}
- data={[
- {
- value: 'default',
- label: 'Default',
- },
- {
- value: 'compact',
- label: 'Compact',
- },
- {
- value: 'large',
- label: 'Large',
- },
- ]}
- />
- onUISettingsChange('time-format', val)}
- data={[
- {
- value: '12h',
- label: '12 hour time',
- },
- {
- value: '24h',
- label: '24 hour time',
- },
- ]}
- />
- onUISettingsChange('date-format', val)}
- data={[
- {
- value: 'mdy',
- label: 'MM/DD/YYYY',
- },
- {
- value: 'dmy',
- label: 'DD/MM/YYYY',
- },
- ]}
- />
- onUISettingsChange('time-zone', val)}
- data={timeZoneOptions}
- />
-
-
+
+ UI Settings
+
+
+
+
{authUser.user_level == USER_LEVELS.ADMIN && (
<>
-
- DVR
-
-
-
-
-
- Stream Settings
-
-
-
-
+
+ Stream Profiles
+
+
+ }>
+
+
+
+
+
-
- System Settings
-
-
- {generalSettingsSaved && (
-
- )}
-
- Configure how many system events (channel start/stop,
- buffering, etc.) to keep in the database. Events are
- displayed on the Stats page.
-
- {
- form.setFieldValue('max-system-events', value);
- }}
- min={10}
- max={1000}
- step={10}
- />
-
-
- Save
-
-
-
-
-
-
-
- User-Agents
-
-
-
-
-
-
- Stream Profiles
-
-
-
-
-
-
-
+
+
Network Access
- {accordianValue == 'network-access' && (
+ {accordianValue === 'network-access' && (
Comma-Delimited CIDR ranges
)}
-
-
-
-
-
-
-
-
+
+
Proxy Settings
-
-
-
-
-
-
-
- Backup & Restore
-
-
-
-
+
+ Backup & Restore
+
+
+ }>
+
+
+
+
+
>
)}
-
- {
- setRehashConfirmOpen(false);
- setRehashDialogType(null);
- // Clear pending values when dialog is cancelled
- setPendingChangedSettings(null);
- }}
- onConfirm={handleRehashConfirm}
- title={
- rehashDialogType === 'save'
- ? 'Save Settings and Rehash Streams'
- : 'Confirm Stream Rehash'
- }
- message={
-
- {`Are you sure you want to rehash all streams?
-
-This process may take a while depending on the number of streams.
-Do not shut down Dispatcharr until the rehashing is complete.
-M3U refreshes will be blocked until this process finishes.
-
-Please ensure you have time to let this complete before proceeding.`}
-
- }
- confirmLabel={
- rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'
- }
- cancelLabel="Cancel"
- actionKey="rehash-streams"
- onSuppressChange={suppressWarning}
- size="md"
- />
-
- setNetworkAccessConfirmOpen(false)}
- onConfirm={saveNetworkAccess}
- title={`Confirm Network Access Blocks`}
- message={
- <>
-
- Your client {clientIpAddress && `(${clientIpAddress}) `}is not included in the allowed networks for the web
- UI. Are you sure you want to proceed?
-
-
-
- {netNetworkAccessConfirmCIDRs.map((cidr) => (
- {cidr}
- ))}
-
- >
- }
- confirmLabel="Save"
- cancelLabel="Cancel"
- size="md"
- />
);
};
diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js
index b7490f88..7b6c6f2f 100644
--- a/frontend/src/utils/dateTimeUtils.js
+++ b/frontend/src/utils/dateTimeUtils.js
@@ -1,4 +1,4 @@
-import { useEffect, useCallback } from 'react';
+import { useCallback, useEffect } from 'react';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
@@ -12,6 +12,41 @@ dayjs.extend(relativeTime);
dayjs.extend(utc);
dayjs.extend(timezone);
+export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
+
+export const initializeTime = (dateTime) => dayjs(dateTime);
+
+export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
+
+export const isBefore = (date1, date2) => dayjs(date1).isBefore(date2);
+
+export const isAfter = (date1, date2) => dayjs(date1).isAfter(date2);
+
+export const isSame = (date1, date2, unit = 'day') =>
+ dayjs(date1).isSame(date2, unit);
+
+export const add = (dateTime, value, unit) => dayjs(dateTime).add(value, unit);
+
+export const diff = (date1, date2, unit = 'millisecond') =>
+ dayjs(date1).diff(date2, unit);
+
+export const format = (dateTime, formatStr) =>
+ dayjs(dateTime).format(formatStr);
+
+export const getNow = () => dayjs();
+
+export const getNowMs = () => Date.now();
+
+export const roundToNearest = (dateTime, minutes) => {
+ const current = initializeTime(dateTime);
+ const minute = current.minute();
+ const snappedMinute = Math.round(minute / minutes) * minutes;
+
+ return snappedMinute === 60
+ ? current.add(1, 'hour').minute(0)
+ : current.minute(snappedMinute);
+};
+
export const useUserTimeZone = () => {
const settings = useSettingsStore((s) => s.settings);
const [timeZone, setTimeZone] = useLocalStorage(
@@ -68,7 +103,7 @@ export const useDateTimeFormat = () => {
const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
- return [timeFormat, dateFormat]
+ return [timeFormat, dateFormat];
};
export const toTimeString = (value) => {
@@ -86,4 +121,138 @@ export const parseDate = (value) => {
if (!value) return null;
const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true);
return parsed.isValid() ? parsed.toDate() : null;
+};
+
+const TIMEZONE_FALLBACKS = [
+ 'UTC',
+ 'America/New_York',
+ 'America/Chicago',
+ 'America/Denver',
+ 'America/Los_Angeles',
+ 'America/Phoenix',
+ 'America/Anchorage',
+ 'Pacific/Honolulu',
+ 'Europe/London',
+ 'Europe/Paris',
+ 'Europe/Berlin',
+ 'Europe/Madrid',
+ 'Europe/Warsaw',
+ 'Europe/Moscow',
+ 'Asia/Dubai',
+ 'Asia/Kolkata',
+ 'Asia/Shanghai',
+ 'Asia/Tokyo',
+ 'Asia/Seoul',
+ 'Australia/Sydney',
+];
+
+const getSupportedTimeZones = () => {
+ try {
+ if (typeof Intl.supportedValuesOf === 'function') {
+ return Intl.supportedValuesOf('timeZone');
+ }
+ } catch (error) {
+ console.warn('Unable to enumerate supported time zones:', error);
+ }
+ return TIMEZONE_FALLBACKS;
+};
+
+const getTimeZoneOffsetMinutes = (date, timeZone) => {
+ try {
+ const dtf = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hourCycle: 'h23',
+ });
+ const parts = dtf.formatToParts(date).reduce((acc, part) => {
+ if (part.type !== 'literal') acc[part.type] = part.value;
+ return acc;
+ }, {});
+ const asUTC = Date.UTC(
+ Number(parts.year),
+ Number(parts.month) - 1,
+ Number(parts.day),
+ Number(parts.hour),
+ Number(parts.minute),
+ Number(parts.second)
+ );
+ return (asUTC - date.getTime()) / 60000;
+ } catch (error) {
+ console.warn(`Failed to compute offset for ${timeZone}:`, error);
+ return 0;
+ }
+};
+
+const formatOffset = (minutes) => {
+ const rounded = Math.round(minutes);
+ const sign = rounded < 0 ? '-' : '+';
+ const absolute = Math.abs(rounded);
+ const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
+ const mins = String(absolute % 60).padStart(2, '0');
+ return `UTC${sign}${hours}:${mins}`;
+};
+
+export const buildTimeZoneOptions = (preferredZone) => {
+ const zones = getSupportedTimeZones();
+ const referenceYear = new Date().getUTCFullYear();
+ const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0));
+ const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0));
+
+ const options = zones
+ .map((zone) => {
+ const janOffset = getTimeZoneOffsetMinutes(janDate, zone);
+ const julOffset = getTimeZoneOffsetMinutes(julDate, zone);
+ const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone);
+ const minOffset = Math.min(janOffset, julOffset);
+ const maxOffset = Math.max(janOffset, julOffset);
+ const usesDst = minOffset !== maxOffset;
+ const labelParts = [`now ${formatOffset(currentOffset)}`];
+ if (usesDst) {
+ labelParts.push(
+ `DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}`
+ );
+ }
+ return {
+ value: zone,
+ label: `${zone} (${labelParts.join(' | ')})`,
+ numericOffset: minOffset,
+ };
+ })
+ .sort((a, b) => {
+ if (a.numericOffset !== b.numericOffset) {
+ return a.numericOffset - b.numericOffset;
+ }
+ return a.value.localeCompare(b.value);
+ });
+ if (
+ preferredZone &&
+ !options.some((option) => option.value === preferredZone)
+ ) {
+ const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone);
+ options.push({
+ value: preferredZone,
+ label: `${preferredZone} (now ${formatOffset(currentOffset)})`,
+ numericOffset: currentOffset,
+ });
+ options.sort((a, b) => {
+ if (a.numericOffset !== b.numericOffset) {
+ return a.numericOffset - b.numericOffset;
+ }
+ return a.value.localeCompare(b.value);
+ });
+ }
+ return options;
+};
+
+export const getDefaultTimeZone = () => {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ } catch (error) {
+ return 'UTC';
+ }
};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
new file mode 100644
index 00000000..7fa272d0
--- /dev/null
+++ b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
@@ -0,0 +1,22 @@
+import API from '../../../api.js';
+
+export const getComskipConfig = async () => {
+ return await API.getComskipConfig();
+};
+
+export const uploadComskipIni = async (file) => {
+ return await API.uploadComskipIni(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,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
new file mode 100644
index 00000000..fe1eea8a
--- /dev/null
+++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
@@ -0,0 +1,29 @@
+import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
+import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
+
+export const getNetworkAccessFormInitialValues = () => {
+ return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
+ acc[key] = '0.0.0.0/0,::/0';
+ return acc;
+ }, {});
+};
+
+export const getNetworkAccessFormValidation = () => {
+ return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
+ acc[key] = (value) => {
+ if (
+ value
+ .split(',')
+ .some(
+ (cidr) =>
+ !(cidr.match(IPV4_CIDR_REGEX) || cidr.match(IPV6_CIDR_REGEX))
+ )
+ ) {
+ return 'Invalid CIDR range';
+ }
+
+ return null;
+ };
+ return acc;
+ }, {});
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
new file mode 100644
index 00000000..864dd9b1
--- /dev/null
+++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
@@ -0,0 +1,18 @@
+import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js';
+
+export const getProxySettingsFormInitialValues = () => {
+ return Object.keys(PROXY_SETTINGS_OPTIONS).reduce((acc, key) => {
+ acc[key] = '';
+ return acc;
+ }, {});
+};
+
+export const getProxySettingDefaults = () => {
+ return {
+ buffering_timeout: 15,
+ buffering_speed: 1.0,
+ redis_chunk_ttl: 60,
+ channel_shutdown_delay: 0,
+ channel_init_grace_period: 5,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
new file mode 100644
index 00000000..2ff5dd55
--- /dev/null
+++ b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
@@ -0,0 +1,19 @@
+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': [],
+ };
+};
+
+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'),
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js
new file mode 100644
index 00000000..75c4f513
--- /dev/null
+++ b/frontend/src/utils/forms/settings/SystemSettingsFormUtils.js
@@ -0,0 +1,5 @@
+export const getSystemSettingsFormInitialValues = () => {
+ return {
+ 'max-system-events': 100,
+ };
+};
diff --git a/frontend/src/utils/forms/settings/UiSettingsFormUtils.js b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js
new file mode 100644
index 00000000..79e99d96
--- /dev/null
+++ b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js
@@ -0,0 +1,14 @@
+import { createSetting, updateSetting } from '../../pages/SettingsUtils.js';
+
+export const saveTimeZoneSetting = async (tzValue, settings) => {
+ const existing = settings['system-time-zone'];
+ if (existing?.id) {
+ await updateSetting({ ...existing, value: tzValue });
+ } else {
+ await createSetting({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: tzValue,
+ });
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js
new file mode 100644
index 00000000..8562face
--- /dev/null
+++ b/frontend/src/utils/networkUtils.js
@@ -0,0 +1,4 @@
+export const IPV4_CIDR_REGEX = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/;
+
+export const IPV6_CIDR_REGEX =
+ /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/;
diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js
new file mode 100644
index 00000000..ba965343
--- /dev/null
+++ b/frontend/src/utils/notificationUtils.js
@@ -0,0 +1,9 @@
+import { notifications } from '@mantine/notifications';
+
+export function showNotification(notificationObject) {
+ return notifications.show(notificationObject);
+}
+
+export function updateNotification(notificationId, notificationObject) {
+ return notifications.update(notificationId, notificationObject);
+}
\ No newline at end of file
diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js
new file mode 100644
index 00000000..e6179f06
--- /dev/null
+++ b/frontend/src/utils/pages/SettingsUtils.js
@@ -0,0 +1,104 @@
+import API from '../../api.js';
+
+export const checkSetting = async (values) => {
+ return await API.checkSetting(values);
+};
+
+export const updateSetting = async (values) => {
+ return await API.updateSetting(values);
+};
+
+export const createSetting = async (values) => {
+ return await API.createSetting(values);
+};
+
+export const rehashStreams = async () => {
+ return await API.rehashStreams();
+};
+
+export const saveChangedSettings = async (settings, changedSettings) => {
+ for (const updatedKey in changedSettings) {
+ const existing = settings[updatedKey];
+ if (existing?.id) {
+ const result = await updateSetting({
+ ...existing,
+ value: changedSettings[updatedKey],
+ });
+ // API functions return undefined on error
+ if (!result) {
+ throw new Error('Failed to update setting');
+ }
+ } else {
+ const result = await createSetting({
+ key: updatedKey,
+ name: updatedKey.replace(/-/g, ' '),
+ value: changedSettings[updatedKey],
+ });
+ // API functions return undefined on error
+ if (!result) {
+ throw new Error('Failed to create setting');
+ }
+ }
+ }
+};
+
+export const getChangedSettings = (values, settings) => {
+ const changedSettings = {};
+
+ for (const settingKey in values) {
+ // 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]}`;
+
+ // Skip empty values to avoid validation errors
+ if (!stringValue) {
+ 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;
+ }
+ }
+ 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;
+ }
+
+ 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;
+ }
+
+ acc[key] = val;
+ return acc;
+ }, {});
+};
\ No newline at end of file