Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/patchy8736/796

This commit is contained in:
SergeantPanda 2026-01-02 09:53:54 -06:00
commit 1a67f3c8ec
21 changed files with 1683 additions and 1392 deletions

View file

@ -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

View file

@ -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

View 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;

View 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;

View 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;

View 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;

View file

@ -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;

View 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;

View file

@ -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} />

View file

@ -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

View file

@ -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';
}
};

View 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,
};
};

View 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;
}, {});
};

View 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,
};
};

View 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'),
};
};

View file

@ -0,0 +1,5 @@
export const getSystemSettingsFormInitialValues = () => {
return {
'max-system-events': 100,
};
};

View 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,
});
}
};

View 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])/;

View 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);
}

View 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;
}, {});
};