mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge pull request #795 from nick4810/enhancement/component-cleanup-logos-settings
This commit is contained in:
commit
042c34eecc
18 changed files with 1672 additions and 1389 deletions
263
frontend/src/components/forms/settings/DvrSettingsForm.jsx
Normal file
263
frontend/src/components/forms/settings/DvrSettingsForm.jsx
Normal file
|
|
@ -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 (
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap="sm">
|
||||
{saved && (
|
||||
<Alert variant="light" color="green" title="Saved Successfully" />
|
||||
)}
|
||||
<Switch
|
||||
label="Enable Comskip (remove commercials after recording)"
|
||||
{...form.getInputProps('dvr-comskip-enabled', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
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')}
|
||||
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}
|
||||
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')}
|
||||
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')}
|
||||
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')}
|
||||
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')}
|
||||
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')}
|
||||
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')}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
export default DvrSettingsForm;
|
||||
161
frontend/src/components/forms/settings/NetworkAccessForm.jsx
Normal file
161
frontend/src/components/forms/settings/NetworkAccessForm.jsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<form onSubmit={networkAccessForm.onSubmit(onNetworkAccessSubmit)}>
|
||||
<Stack gap="sm">
|
||||
{saved && (
|
||||
<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]) => (
|
||||
<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>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={networkAccessConfirmOpen}
|
||||
onClose={() => setNetworkAccessConfirmOpen(false)}
|
||||
onConfirm={saveNetworkAccess}
|
||||
title={`Confirm Network Access Blocks`}
|
||||
message={
|
||||
<>
|
||||
<Text>
|
||||
Your client {clientIpAddress && `(${clientIpAddress}) `}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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default NetworkAccessForm;
|
||||
166
frontend/src/components/forms/settings/ProxySettingsForm.jsx
Normal file
166
frontend/src/components/forms/settings/ProxySettingsForm.jsx
Normal file
|
|
@ -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 (
|
||||
<NumberInput
|
||||
key={key}
|
||||
label={config.label}
|
||||
{...proxySettingsForm.getInputProps(key)}
|
||||
description={config.description || null}
|
||||
min={0}
|
||||
max={getNumericFieldMax(key)}
|
||||
/>
|
||||
);
|
||||
} else if (isFloatField(key)) {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<form onSubmit={proxySettingsForm.onSubmit(onProxySettingsSubmit)}>
|
||||
<Stack gap="sm">
|
||||
{saved && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="green"
|
||||
title="Saved Successfully"
|
||||
></Alert>
|
||||
)}
|
||||
|
||||
<ProxySettingsOptions proxySettingsForm={proxySettingsForm} />
|
||||
|
||||
<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={proxySettingsForm.submitting}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProxySettingsForm;
|
||||
306
frontend/src/components/forms/settings/StreamSettingsForm.jsx
Normal file
306
frontend/src/components/forms/settings/StreamSettingsForm.jsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
{saved && (
|
||||
<Alert variant="light" color="green" title="Saved Successfully" />
|
||||
)}
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-user-agent')}
|
||||
id={settings['default-user-agent']?.id || 'default-user-agent'}
|
||||
name={settings['default-user-agent']?.key || 'default-user-agent'}
|
||||
label={settings['default-user-agent']?.name || 'Default User Agent'}
|
||||
data={userAgents.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-stream-profile')}
|
||||
id={
|
||||
settings['default-stream-profile']?.id || 'default-stream-profile'
|
||||
}
|
||||
name={
|
||||
settings['default-stream-profile']?.key || 'default-stream-profile'
|
||||
}
|
||||
label={
|
||||
settings['default-stream-profile']?.name || 'Default Stream Profile'
|
||||
}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('preferred-region')}
|
||||
id={settings['preferred-region']?.id || 'preferred-region'}
|
||||
name={settings['preferred-region']?.key || 'preferred-region'}
|
||||
label={settings['preferred-region']?.name || 'Preferred Region'}
|
||||
data={regionChoices.map((r) => ({
|
||||
label: r.label,
|
||||
value: `${r.value}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" pt={5}>
|
||||
<Text size="sm" fw={500}>
|
||||
Auto-Import Mapped Files
|
||||
</Text>
|
||||
<Switch
|
||||
{...form.getInputProps('auto-import-mapped-files', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
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')}
|
||||
/>
|
||||
|
||||
{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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default StreamSettingsForm;
|
||||
|
|
@ -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 (
|
||||
<Stack gap="md">
|
||||
{saved && (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
export default SystemSettingsForm;
|
||||
144
frontend/src/components/forms/settings/UiSettingsForm.jsx
Normal file
144
frontend/src/components/forms/settings/UiSettingsForm.jsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import useSettingsStore from '../../../store/settings.jsx';
|
||||
import useLocalStorage from '../../../hooks/useLocalStorage.jsx';
|
||||
import {
|
||||
buildTimeZoneOptions,
|
||||
getDefaultTimeZone,
|
||||
} from '../../../utils/dateTimeUtils.js';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import { Select } from '@mantine/core';
|
||||
import { saveTimeZoneSetting } from '../../../utils/forms/settings/UiSettingsFormUtils.js';
|
||||
|
||||
const UiSettingsForm = React.memo(() => {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
|
||||
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
|
||||
const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
|
||||
const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy');
|
||||
const [timeZone, setTimeZone] = useLocalStorage(
|
||||
'time-zone',
|
||||
getDefaultTimeZone()
|
||||
);
|
||||
|
||||
const timeZoneOptions = useMemo(
|
||||
() => buildTimeZoneOptions(timeZone),
|
||||
[timeZone]
|
||||
);
|
||||
|
||||
const timeZoneSyncedRef = useRef(false);
|
||||
|
||||
const persistTimeZoneSetting = useCallback(
|
||||
async (tzValue) => {
|
||||
try {
|
||||
await saveTimeZoneSetting(tzValue, settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist time zone setting', error);
|
||||
showNotification({
|
||||
title: 'Failed to update time zone',
|
||||
message: 'Could not save the selected time zone. Please try again.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
},
|
||||
[settings]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const tzSetting = settings['system-time-zone'];
|
||||
if (tzSetting?.value) {
|
||||
timeZoneSyncedRef.current = true;
|
||||
setTimeZone((prev) =>
|
||||
prev === tzSetting.value ? prev : tzSetting.value
|
||||
);
|
||||
} else if (!timeZoneSyncedRef.current && timeZone) {
|
||||
timeZoneSyncedRef.current = true;
|
||||
persistTimeZoneSetting(timeZone);
|
||||
}
|
||||
}
|
||||
}, [settings, timeZone, setTimeZone, persistTimeZoneSetting]);
|
||||
|
||||
const onUISettingsChange = (name, value) => {
|
||||
switch (name) {
|
||||
case 'table-size':
|
||||
if (value) setTableSize(value);
|
||||
break;
|
||||
case 'time-format':
|
||||
if (value) setTimeFormat(value);
|
||||
break;
|
||||
case 'date-format':
|
||||
if (value) setDateFormat(value);
|
||||
break;
|
||||
case 'time-zone':
|
||||
if (value) {
|
||||
setTimeZone(value);
|
||||
persistTimeZoneSetting(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default UiSettingsForm;
|
||||
|
|
@ -1,34 +1,34 @@
|
|||
import React, { useEffect, useCallback, useState } from 'react';
|
||||
import { Box, Tabs, Flex, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { Box, Tabs, Flex, Text, TabsList, TabsTab } from '@mantine/core';
|
||||
import useLogosStore from '../store/logos';
|
||||
import useVODLogosStore from '../store/vodLogos';
|
||||
import LogosTable from '../components/tables/LogosTable';
|
||||
import VODLogosTable from '../components/tables/VODLogosTable';
|
||||
import { showNotification } from '../utils/notificationUtils.js';
|
||||
|
||||
const LogosPage = () => {
|
||||
const { fetchAllLogos, needsAllLogos, logos } = useLogosStore();
|
||||
const { totalCount } = useVODLogosStore();
|
||||
const logos = useLogosStore(s => s.logos);
|
||||
const totalCount = useVODLogosStore(s => s.totalCount);
|
||||
const [activeTab, setActiveTab] = useState('channel');
|
||||
|
||||
const channelLogosCount = Object.keys(logos).length;
|
||||
const vodLogosCount = totalCount;
|
||||
const logoCount = activeTab === 'channel'
|
||||
? Object.keys(logos).length
|
||||
: totalCount;
|
||||
|
||||
const loadChannelLogos = useCallback(async () => {
|
||||
try {
|
||||
// Only fetch all logos if we haven't loaded them yet
|
||||
if (needsAllLogos()) {
|
||||
await fetchAllLogos();
|
||||
if (useLogosStore.getState().needsAllLogos()) {
|
||||
await useLogosStore.getState().fetchAllLogos();
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: 'Failed to load channel logos',
|
||||
color: 'red',
|
||||
});
|
||||
console.error('Failed to load channel logos:', err);
|
||||
}
|
||||
}, [fetchAllLogos, needsAllLogos]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Always load channel logos on mount
|
||||
|
|
@ -39,51 +39,41 @@ const LogosPage = () => {
|
|||
<Box>
|
||||
{/* Header with title and tabs */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '10px 0',
|
||||
}}
|
||||
style={{ justifyContent: 'center' }}
|
||||
display={'flex'}
|
||||
p={'10px 0'}
|
||||
>
|
||||
<Flex
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
paddingBottom: 10,
|
||||
}}
|
||||
w={'100%'}
|
||||
maw={'1200px'}
|
||||
pb={10}
|
||||
>
|
||||
<Flex gap={8} align="center">
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6',
|
||||
marginBottom: 0,
|
||||
}}
|
||||
ff={'Inter, sans-serif'}
|
||||
fz={'20px'}
|
||||
fw={500}
|
||||
lh={1}
|
||||
c='white'
|
||||
mb={0}
|
||||
lts={'-0.3px'}
|
||||
>
|
||||
Logos
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
({activeTab === 'channel' ? channelLogosCount : vodLogosCount}{' '}
|
||||
logo
|
||||
{(activeTab === 'channel' ? channelLogosCount : vodLogosCount) !==
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
)
|
||||
({logoCount} {logoCount !== 1 ? 'logos' : 'logo'})
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Tabs value={activeTab} onChange={setActiveTab} variant="pills">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="channel">Channel Logos</Tabs.Tab>
|
||||
<Tabs.Tab value="vod">VOD Logos</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<TabsList>
|
||||
<TabsTab value="channel">Channel Logos</TabsTab>
|
||||
<TabsTab value="vod">VOD Logos</TabsTab>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
22
frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
Normal file
22
frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
29
frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
Normal file
29
frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
Normal file
|
|
@ -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;
|
||||
}, {});
|
||||
};
|
||||
18
frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
Normal file
18
frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
19
frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
Normal file
19
frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
Normal file
|
|
@ -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'),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export const getSystemSettingsFormInitialValues = () => {
|
||||
return {
|
||||
'max-system-events': 100,
|
||||
};
|
||||
};
|
||||
14
frontend/src/utils/forms/settings/UiSettingsFormUtils.js
Normal file
14
frontend/src/utils/forms/settings/UiSettingsFormUtils.js
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
4
frontend/src/utils/networkUtils.js
Normal file
4
frontend/src/utils/networkUtils.js
Normal file
|
|
@ -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])/;
|
||||
9
frontend/src/utils/notificationUtils.js
Normal file
9
frontend/src/utils/notificationUtils.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
104
frontend/src/utils/pages/SettingsUtils.js
Normal file
104
frontend/src/utils/pages/SettingsUtils.js
Normal file
|
|
@ -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;
|
||||
}, {});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue