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 ( +
+ + {saved && ( + + )} + + + + + + + + {comskipConfig.exists && comskipConfig.path + ? `Using ${comskipConfig.path}` + : 'No custom comskip.ini uploaded.'} + + + + + + + + + + + +
+ ); +}); + +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 ( + <> +
+ + {saved && ( + + )} + {networkAccessError && ( + + )} + + {Object.entries(NETWORK_ACCESS_OPTIONS).map(([key, config]) => ( + + ))} + + + + + +
+ + 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? + + + + + } + 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 ( +
+ + {saved && ( + + )} + + + + + + + + +
+ ); +}); + +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 ( + <> +
+ {saved && ( + + )} + ({ + value: `${option.id}`, + label: option.name, + }))} + /> + onUISettingsChange('table-size', val)} + data={[ + { + value: 'default', + label: 'Default', + }, + { + value: 'compact', + label: 'Compact', + }, + { + value: 'large', + label: 'Large', + }, + ]} + /> + onUISettingsChange('date-format', val)} + data={[ + { + value: 'mdy', + label: 'MM/DD/YYYY', + }, + { + value: 'dmy', + label: 'DD/MM/YYYY', + }, + ]} + /> + onUISettingsChange('table-size', val)} - data={[ - { - value: 'default', - label: 'Default', - }, - { - value: 'compact', - label: 'Compact', - }, - { - value: 'large', - label: 'Large', - }, - ]} - /> - onUISettingsChange('date-format', val)} - data={[ - { - value: 'mdy', - label: 'MM/DD/YYYY', - }, - { - value: 'dmy', - label: 'DD/MM/YYYY', - }, - ]} - /> - ({ - value: `${option.id}`, - label: option.name, - }))} - /> - ({ - label: r.label, - value: `${r.value}`, - }))} - /> + + DVR + + + }> + + + + + - - - Auto-Import Mapped Files - - - + + Stream Settings + + + }> + + + + + - + + System Settings + + + }> + + + + + - {rehashSuccess && ( - - )} + + User-Agents + + + }> + + + + + - - - - - - - + + 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} - /> - - - - - - - - - User-Agents - - - - - - - Stream Profiles - - - - - - - + + Network Access - {accordianValue == 'network-access' && ( + {accordianValue === 'network-access' && ( Comma-Delimited CIDR ranges )} - - -
- - {networkAccessSaved && ( - - )} - {networkAccessError && ( - - )} - {Object.entries(NETWORK_ACCESS_OPTIONS).map( - ([key, config]) => { - return ( - - ); - } - )} + + + + }> + + + + + - - - - -
-
-
- - - + + Proxy Settings - - -
- - {proxySettingsSaved && ( - - )} - {Object.entries(PROXY_SETTINGS_OPTIONS).map( - ([key, config]) => { - // Determine if this field should be a NumberInput - const isNumericField = [ - 'buffering_timeout', - 'redis_chunk_ttl', - 'channel_shutdown_delay', - 'channel_init_grace_period', - ].includes(key); + + + + }> + + + + + - const isFloatField = key === 'buffering_speed'; - - if (isNumericField) { - return ( - - ); - } else if (isFloatField) { - return ( - - ); - } else { - return ( - - ); - } - } - )} - - - - - - -
-
-
- - - 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