Dispatcharr/frontend/src/pages/Settings.jsx

1387 lines
48 KiB
JavaScript

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 {
Accordion,
Alert,
Box,
Button,
Center,
Flex,
Group,
FileInput,
MultiSelect,
Select,
Stack,
Switch,
Text,
TextInput,
NumberInput,
} 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';
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';
}
};
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 [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;
}
// 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 (
<Center
style={{
padding: 10,
}}
>
<Box style={{ width: '100%', maxWidth: 800 }}>
<Accordion
variant="separated"
defaultValue="ui-settings"
onChange={setAccordianValue}
style={{ minWidth: 400 }}
>
<Accordion.Item value="ui-settings">
<Accordion.Control>UI Settings</Accordion.Control>
<Accordion.Panel>
<Select
label="Table Size"
value={tableSize}
onChange={(val) => onUISettingsChange('table-size', val)}
data={[
{
value: 'default',
label: 'Default',
},
{
value: 'compact',
label: 'Compact',
},
{
value: 'large',
label: 'Large',
},
]}
/>
<Select
label="Time format"
value={timeFormat}
onChange={(val) => onUISettingsChange('time-format', val)}
data={[
{
value: '12h',
label: '12 hour time',
},
{
value: '24h',
label: '24 hour time',
},
]}
/>
<Select
label="Date format"
value={dateFormat}
onChange={(val) => onUISettingsChange('date-format', val)}
data={[
{
value: 'mdy',
label: 'MM/DD/YYYY',
},
{
value: 'dmy',
label: 'DD/MM/YYYY',
},
]}
/>
<Select
label="Time zone"
searchable
nothingFoundMessage="No matches"
value={timeZone}
onChange={(val) => onUISettingsChange('time-zone', val)}
data={timeZoneOptions}
/>
</Accordion.Panel>
</Accordion.Item>
{authUser.user_level == USER_LEVELS.ADMIN && (
<>
<Accordion.Item value="dvr-settings">
<Accordion.Control>DVR</Accordion.Control>
<Accordion.Panel>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="sm">
{generalSettingsSaved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
/>
)}
<Switch
label="Enable Comskip (remove commercials after recording)"
{...form.getInputProps('dvr-comskip-enabled', {
type: 'checkbox',
})}
key={form.key('dvr-comskip-enabled')}
id={
settings['dvr-comskip-enabled']?.id ||
'dvr-comskip-enabled'
}
name={
settings['dvr-comskip-enabled']?.key ||
'dvr-comskip-enabled'
}
/>
<TextInput
label="Custom comskip.ini path"
description="Leave blank to use the built-in defaults."
placeholder="/app/docker/comskip.ini"
{...form.getInputProps('dvr-comskip-custom-path')}
key={form.key('dvr-comskip-custom-path')}
id={
settings['dvr-comskip-custom-path']?.id ||
'dvr-comskip-custom-path'
}
name={
settings['dvr-comskip-custom-path']?.key ||
'dvr-comskip-custom-path'
}
/>
<Group align="flex-end" gap="sm">
<FileInput
placeholder="Select comskip.ini"
accept=".ini"
value={comskipFile}
onChange={setComskipFile}
clearable
disabled={comskipUploadLoading}
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={onComskipUpload}
disabled={!comskipFile || comskipUploadLoading}
>
{comskipUploadLoading
? 'Uploading...'
: 'Upload comskip.ini'}
</Button>
</Group>
<Text size="xs" c="dimmed">
{comskipConfig.exists && comskipConfig.path
? `Using ${comskipConfig.path}`
: 'No custom comskip.ini uploaded.'}
</Text>
<NumberInput
label="Start early (minutes)"
description="Begin recording this many minutes before the scheduled start."
min={0}
step={1}
{...form.getInputProps('dvr-pre-offset-minutes')}
key={form.key('dvr-pre-offset-minutes')}
id={
settings['dvr-pre-offset-minutes']?.id ||
'dvr-pre-offset-minutes'
}
name={
settings['dvr-pre-offset-minutes']?.key ||
'dvr-pre-offset-minutes'
}
/>
<NumberInput
label="End late (minutes)"
description="Continue recording this many minutes after the scheduled end."
min={0}
step={1}
{...form.getInputProps('dvr-post-offset-minutes')}
key={form.key('dvr-post-offset-minutes')}
id={
settings['dvr-post-offset-minutes']?.id ||
'dvr-post-offset-minutes'
}
name={
settings['dvr-post-offset-minutes']?.key ||
'dvr-post-offset-minutes'
}
/>
<TextInput
label="TV Path Template"
description="Supports {show}, {season}, {episode}, {sub_title}, {channel}, {year}, {start}, {end}. Use format specifiers like {season:02d}. Relative paths are under your library dir."
placeholder="TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
{...form.getInputProps('dvr-tv-template')}
key={form.key('dvr-tv-template')}
id={
settings['dvr-tv-template']?.id || 'dvr-tv-template'
}
name={
settings['dvr-tv-template']?.key || 'dvr-tv-template'
}
/>
<TextInput
label="TV Fallback Template"
description="Template used when an episode has no season/episode. Supports {show}, {start}, {end}, {channel}, {year}."
placeholder="TV_Shows/{show}/{start}.mkv"
{...form.getInputProps('dvr-tv-fallback-template')}
key={form.key('dvr-tv-fallback-template')}
id={
settings['dvr-tv-fallback-template']?.id ||
'dvr-tv-fallback-template'
}
name={
settings['dvr-tv-fallback-template']?.key ||
'dvr-tv-fallback-template'
}
/>
<TextInput
label="Movie Path Template"
description="Supports {title}, {year}, {channel}, {start}, {end}. Relative paths are under your library dir."
placeholder="Movies/{title} ({year}).mkv"
{...form.getInputProps('dvr-movie-template')}
key={form.key('dvr-movie-template')}
id={
settings['dvr-movie-template']?.id ||
'dvr-movie-template'
}
name={
settings['dvr-movie-template']?.key ||
'dvr-movie-template'
}
/>
<TextInput
label="Movie Fallback Template"
description="Template used when movie metadata is incomplete. Supports {start}, {end}, {channel}."
placeholder="Movies/{start}.mkv"
{...form.getInputProps('dvr-movie-fallback-template')}
key={form.key('dvr-movie-fallback-template')}
id={
settings['dvr-movie-fallback-template']?.id ||
'dvr-movie-fallback-template'
}
name={
settings['dvr-movie-fallback-template']?.key ||
'dvr-movie-fallback-template'
}
/>
<Flex
mih={50}
gap="xs"
justify="flex-end"
align="flex-end"
>
<Button type="submit" variant="default">
Save
</Button>
</Flex>
</Stack>
</form>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="stream-settings">
<Accordion.Control>Stream Settings</Accordion.Control>
<Accordion.Panel>
<form onSubmit={form.onSubmit(onSubmit)}>
{generalSettingsSaved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
/>
)}
<Select
searchable
{...form.getInputProps('default-user-agent')}
key={form.key('default-user-agent')}
id={
settings['default-user-agent']?.id ||
'default-user-agent'
}
name={
settings['default-user-agent']?.key ||
'default-user-agent'
}
label={
settings['default-user-agent']?.name ||
'Default User Agent'
}
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('default-stream-profile')}
key={form.key('default-stream-profile')}
id={
settings['default-stream-profile']?.id ||
'default-stream-profile'
}
name={
settings['default-stream-profile']?.key ||
'default-stream-profile'
}
label={
settings['default-stream-profile']?.name ||
'Default Stream Profile'
}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('preferred-region')}
key={form.key('preferred-region')}
id={
settings['preferred-region']?.id || 'preferred-region'
}
name={
settings['preferred-region']?.key || 'preferred-region'
}
label={
settings['preferred-region']?.name || 'Preferred Region'
}
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/>
<Group justify="space-between" style={{ paddingTop: 5 }}>
<Text size="sm" fw={500}>
Auto-Import Mapped Files
</Text>
<Switch
{...form.getInputProps('auto-import-mapped-files', {
type: 'checkbox',
})}
key={form.key('auto-import-mapped-files')}
id={
settings['auto-import-mapped-files']?.id ||
'auto-import-mapped-files'
}
/>
</Group>
<MultiSelect
id="m3u-hash-key"
name="m3u-hash-key"
label="M3U Hash Key"
data={[
{
value: 'name',
label: 'Name',
},
{
value: 'url',
label: 'URL',
},
{
value: 'tvg_id',
label: 'TVG-ID',
},
{
value: 'm3u_id',
label: 'M3U ID',
},
{
value: 'group',
label: 'Group',
},
]}
{...form.getInputProps('m3u-hash-key')}
key={form.key('m3u-hash-key')}
/>
{rehashSuccess && (
<Alert
variant="light"
color="green"
title="Rehash task queued successfully"
/>
)}
<Flex
mih={50}
gap="xs"
justify="space-between"
align="flex-end"
>
<Button
onClick={onRehashStreams}
loading={rehashingStreams}
variant="outline"
color="blue"
>
Rehash Streams
</Button>
<Button
type="submit"
disabled={form.submitting}
variant="default"
>
Save
</Button>
</Flex>
</form>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="system-settings">
<Accordion.Control>System Settings</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
{generalSettingsSaved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
/>
)}
<Text size="sm" c="dimmed">
Configure how many system events (channel start/stop,
buffering, etc.) to keep in the database. Events are
displayed on the Stats page.
</Text>
<NumberInput
label="Maximum System Events"
description="Number of events to retain (minimum: 10, maximum: 1000)"
value={form.values['max-system-events'] || 100}
onChange={(value) => {
form.setFieldValue('max-system-events', value);
}}
min={10}
max={1000}
step={10}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
onClick={form.onSubmit(onSubmit)}
disabled={form.submitting}
variant="default"
>
Save
</Button>
</Flex>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="user-agents">
<Accordion.Control>User-Agents</Accordion.Control>
<Accordion.Panel>
<UserAgentsTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="stream-profiles">
<Accordion.Control>Stream Profiles</Accordion.Control>
<Accordion.Panel>
<StreamProfilesTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="network-access">
<Accordion.Control>
<Box>Network Access</Box>
{accordianValue == 'network-access' && (
<Box>
<Text size="sm">Comma-Delimited CIDR ranges</Text>
</Box>
)}
</Accordion.Control>
<Accordion.Panel>
<form
onSubmit={networkAccessForm.onSubmit(onNetworkAccessSubmit)}
>
<Stack gap="sm">
{networkAccessSaved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
></Alert>
)}
{networkAccessError && (
<Alert
variant="light"
color="red"
title={networkAccessError}
></Alert>
)}
{Object.entries(NETWORK_ACCESS_OPTIONS).map(
([key, config]) => {
return (
<TextInput
label={config.label}
{...networkAccessForm.getInputProps(key)}
key={networkAccessForm.key(key)}
description={config.description}
/>
);
}
)}
<Flex
mih={50}
gap="xs"
justify="flex-end"
align="flex-end"
>
<Button
type="submit"
disabled={networkAccessForm.submitting}
variant="default"
>
Save
</Button>
</Flex>
</Stack>
</form>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="proxy-settings">
<Accordion.Control>
<Box>Proxy Settings</Box>
</Accordion.Control>
<Accordion.Panel>
<form
onSubmit={proxySettingsForm.onSubmit(onProxySettingsSubmit)}
>
<Stack gap="sm">
{proxySettingsSaved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
></Alert>
)}
{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 (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0}
max={
key === 'buffering_timeout'
? 300
: key === 'redis_chunk_ttl'
? 3600
: key === 'channel_shutdown_delay'
? 300
: 60
}
/>
);
} else if (isFloatField) {
return (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0.0}
max={10.0}
step={0.01}
precision={1}
/>
);
} else {
return (
<TextInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
/>
);
}
}
)}
<Flex
mih={50}
gap="xs"
justify="space-between"
align="flex-end"
>
<Button
variant="subtle"
color="gray"
onClick={resetProxySettingsToDefaults}
>
Reset to Defaults
</Button>
<Button
type="submit"
disabled={networkAccessForm.submitting}
variant="default"
>
Save
</Button>
</Flex>
</Stack>
</form>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="backups">
<Accordion.Control>Backup & Restore</Accordion.Control>
<Accordion.Panel>
<BackupManager />
</Accordion.Panel>
</Accordion.Item>
</>
)}
</Accordion>
</Box>
<ConfirmationDialog
opened={rehashConfirmOpen}
onClose={() => {
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={
<div style={{ whiteSpace: 'pre-line' }}>
{`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.`}
</div>
}
confirmLabel={
rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'
}
cancelLabel="Cancel"
actionKey="rehash-streams"
onSuppressChange={suppressWarning}
size="md"
/>
<ConfirmationDialog
opened={networkAccessConfirmOpen}
onClose={() => setNetworkAccessConfirmOpen(false)}
onConfirm={saveNetworkAccess}
title={`Confirm Network Access Blocks`}
message={
<>
<Text>
Your client is not included in the allowed networks for the web
UI. Are you sure you want to proceed?
</Text>
<ul>
{netNetworkAccessConfirmCIDRs.map((cidr) => (
<li>{cidr}</li>
))}
</ul>
</>
}
confirmLabel="Save"
cancelLabel="Cancel"
size="md"
/>
</Center>
);
};
export default SettingsPage;