mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/patchy8736/796
This commit is contained in:
commit
1a67f3c8ec
21 changed files with 1683 additions and 1392 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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;
|
||||
|
|
@ -65,6 +65,7 @@ const PageContent = () => {
|
|||
if (!authUser.id) return <></>;
|
||||
|
||||
if (authUser.user_level <= USER_LEVELS.STANDARD) {
|
||||
handleStreamsReady();
|
||||
return (
|
||||
<Box style={{ padding: 10 }}>
|
||||
<ChannelsTable onReady={handleChannelsReady} />
|
||||
|
|
|
|||
|
|
@ -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