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..69feec74
--- /dev/null
+++ b/frontend/src/components/forms/settings/UiSettingsForm.jsx
@@ -0,0 +1,142 @@
+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';
+
+export 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}
+ />
+ >
+ );
+});
\ No newline at end of file
diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx
index 889e32c9..dd0bb5ad 100644
--- a/frontend/src/pages/Logos.jsx
+++ b/frontend/src/pages/Logos.jsx
@@ -1,24 +1,23 @@
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';
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({
@@ -28,7 +27,7 @@ const LogosPage = () => {
});
console.error('Failed to load channel logos:', err);
}
- }, [fetchAllLogos, needsAllLogos]);
+ }, []);
useEffect(() => {
// Always load channel logos on mount
@@ -39,51 +38,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..a1a54435 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 && (
+ {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/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