feat: Add HDHR output profile support and reorganize settings
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled

- Implemented HDHR output profile URL support for specific transcode profiles.
- Introduced a default output profile setting in Stream Settings.
- Updated API views to handle channel profiles and output profiles.
- Migrated preferred region and auto-import settings to system settings.
- Enhanced frontend forms to include output profile selection and descriptions.
This commit is contained in:
SergeantPanda 2026-05-10 11:14:02 -05:00
parent 84fbbfa3c4
commit ba76a4d5f2
14 changed files with 312 additions and 93 deletions

View file

@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted:
- `/hdhr/output_profile/<id>/lineup.json` - output profile only
- `/hdhr/<channel_profile>/output_profile/<id>/lineup.json` - channel profile + output profile
- Bare `/hdhr/lineup.json` - existing behavior, uses the new system default (see below)
- The profile ID is resolved against active `OutputProfile` rows; if the ID is inactive or does not exist, a warning is logged and the stream is served without transcoding.
- **HDHR default output profile setting.** A new "HDHR Default Output Profile" select in Settings → Stream Settings lets admins pick an output profile that is applied to all HDHR stream URLs that do not specify one in the path. When cleared, streams are served as-is (pass-through).
- **fMP4 streaming support.** Dispatcharr can now serve live channels as fragmented MP4 in addition to MPEG-TS. A dedicated `FMP4RemuxManager` runs a single FFmpeg remux process per active channel, muxes incoming TS data into fMP4 fragments, and stores them in a Redis-backed ring buffer. Multiple clients requesting the same channel in fMP4 all read from the same shared buffer, so only one FFmpeg process runs regardless of viewer count. Clients that request MPEG-TS continue to be served as before. The output format is selected via `?output_format=mpegts|fmp4` on the stream URL, or by the per-user default configured by an admin on the user account, or by the server-wide default in Stream Settings.
- **Output profiles.** Admins can define named transcode profiles under Settings → Output Profiles. Each profile specifies an executable (e.g. `ffmpeg`) and a parameter string that reads raw TS from `pipe:0` and writes the transcoded output to `pipe:1`. When a client requests a profile, one FFmpeg transcode process runs per active (channel, profile) pair and all requesting clients share the resulting output buffer. Common use case: a profile that converts AC3 audio to AAC for browser and mobile clients while the native stream (AC3 intact) continues to serve Plex/Emby/Jellyfin. Profiles are applied via `?output_profile=<id>` on the stream URL, or by per-user and server-wide defaults. Two locked built-in profiles are seeded on first run: **Media Server (AC3 Audio)** (copies video, re-encodes audio to AC3 384k) and **Web Player (AAC Audio)** (copies video, re-encodes audio to AAC 192k). (Closes #407)
- **Container format and output profile shown in Stats client rows.** The expanded client row on the Stats page now displays the container format (`mpegts` or `fmp4`) and, when an output profile is active, the profile name.
@ -24,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs.
- **Stream Settings descriptions added.** Default User Agent, Default Stream Profile, Default Output Format, M3U Hash Key, and HDHR Default Output Profile all now have inline description text below their labels explaining their purpose and effect.
- **System Settings descriptions added.** Maximum System Events, Preferred Region, and Auto-Import Mapped Files now have inline description text. The redundant description paragraph that duplicated the accordion header text has been removed.
- **`get_client_ip()` in `dispatcharr/utils.py` cleaned up.** Removed dead `.split(',')[0]` call and misleading variable name left over from a copy-paste of the `X-Forwarded-For` pattern. `X-Real-IP` is always a single IP (set by nginx from `$remote_addr`), so no splitting is needed. Behavior is unchanged.
- **`ts_proxy` module refactored and renamed to `live_proxy`.** The live-streaming proxy was reorganized into a structured package (`apps/proxy/live_proxy/`) with explicit submodules for each stage of the pipeline: `input/` (HTTP streamer, stream manager, TS ring buffer), `output/ts/` (MPEG-TS client generator), `output/fmp4/` (fMP4 remux manager, Redis buffer, client generator), and `output/profile/` (output profile transcode manager). A dedicated `redis_keys.py` module centralizes all Redis key name construction for the proxy. Existing behavior for MPEG-TS clients is unchanged.
- **XC API `allowed_output_formats` now includes `mp4`.** The `user_info` block returned by `player_api.php` and `get.php` previously advertised only `["ts"]`. It now advertises `["ts", "mp4"]` for all users, enabling XC-compatible clients that support fMP4 to request `.mp4` stream URLs which are proxied as fMP4.

View file

@ -63,28 +63,28 @@ class DiscoverAPIView(APIView):
@extend_schema(
description="Retrieve HDHomeRun device discovery information",
)
def get(self, request, profile=None):
def get(self, request, channel_profile=None, output_profile_id=None):
blocked = _hdhr_network_check(request)
if blocked is not None:
return blocked
uri_parts = ["hdhr"]
if profile is not None:
uri_parts.append(profile)
if channel_profile is not None:
uri_parts.append(channel_profile)
if output_profile_id is not None:
uri_parts.append("output_profile")
uri_parts.append(str(output_profile_id))
base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip("/")
device = HDHRDevice.objects.first()
# Calculate tuner count using centralized function
from apps.m3u.utils import calculate_tuner_count
tuner_count = calculate_tuner_count(minimum=1, unlimited_default=10)
# Create a unique DeviceID for the HDHomeRun device based on profile ID or a default value
device_ID = "12345678" # Default DeviceID
friendly_name = "Dispatcharr HDHomeRun"
if profile is not None:
device_ID = f"dispatcharr-hdhr-{profile}"
friendly_name = f"Dispatcharr HDHomeRun - {profile}"
slug_parts = [p for p in [channel_profile, str(output_profile_id) if output_profile_id is not None else None] if p]
device_ID = f"dispatcharr-hdhr-{'-'.join(slug_parts)}" if slug_parts else "12345678"
friendly_name = f"Dispatcharr HDHomeRun - {' / '.join(slug_parts)}" if slug_parts else "Dispatcharr HDHomeRun"
if not device:
data = {
"FriendlyName": friendly_name,
@ -112,6 +112,24 @@ class DiscoverAPIView(APIView):
return JsonResponse(data)
def _resolve_hdhr_output_profile_id(output_profile_id):
"""Return a validated output profile ID for HDHR lineup stream URLs.
Priority: URL path segment -> system default -> None (pass-through).
"""
from core.models import OutputProfile, CoreSettings
candidate = output_profile_id if output_profile_id is not None else CoreSettings.get_hdhr_output_profile_id()
if candidate is None:
return None
try:
OutputProfile.objects.get(id=candidate, is_active=True)
return candidate
except OutputProfile.DoesNotExist:
source = "URL" if output_profile_id is not None else "system default"
logger.warning("HDHR output profile id=%s (%s) not found or inactive - serving without transcoding", candidate, source)
return None
# 🔹 3) Lineup API
class LineupAPIView(APIView):
"""Returns available channel lineup"""
@ -120,7 +138,7 @@ class LineupAPIView(APIView):
@extend_schema(
description="Retrieve the available channel lineup",
)
def get(self, request, profile=None):
def get(self, request, channel_profile=None, output_profile_id=None):
blocked = _hdhr_network_check(request)
if blocked is not None:
return blocked
@ -128,10 +146,13 @@ class LineupAPIView(APIView):
from apps.channels.managers import with_effective_values
from apps.channels.utils import format_channel_number
if profile is not None:
channel_profile = ChannelProfile.objects.get(name=profile)
if channel_profile is not None:
try:
cp = ChannelProfile.objects.get(name=channel_profile)
except ChannelProfile.DoesNotExist:
return JsonResponse([], safe=False)
base_qs = Channel.objects.filter(
channelprofilemembership__channel_profile=channel_profile,
channelprofilemembership__channel_profile=cp,
channelprofilemembership__enabled=True,
)
else:
@ -143,21 +164,24 @@ class LineupAPIView(APIView):
.order_by("effective_channel_number")
)
resolved_output_profile_id = _resolve_hdhr_output_profile_id(output_profile_id)
lineup = []
for ch in channels:
# HDHR clients reject lineup entries with empty/non-numeric
# GuideNumber and may drop the whole lineup. With nullable
# channel_number, skip rows that have no usable number.
formatted = format_channel_number(ch.effective_channel_number, empty=None)
if formatted is None:
continue
formatted_channel_number = str(formatted)
stream_url = request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}")
if resolved_output_profile_id is not None:
stream_url += f"?output_profile={resolved_output_profile_id}"
lineup.append(
{
"GuideNumber": formatted_channel_number,
"GuideName": ch.effective_name,
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"),
"URL": stream_url,
"Guide_ID": formatted_channel_number,
"Station": formatted_channel_number,
}
@ -173,7 +197,7 @@ class LineupStatusAPIView(APIView):
@extend_schema(
description="Retrieve the HDHomeRun lineup status",
)
def get(self, request, profile=None):
def get(self, request, channel_profile=None, output_profile_id=None):
blocked = _hdhr_network_check(request)
if blocked is not None:
return blocked

View file

@ -10,12 +10,27 @@ router.register(r'devices', HDHRDeviceViewSet, basename='hdhr-device')
urlpatterns = [
path('dashboard/', hdhr_dashboard_view, name='hdhr_dashboard'),
path('', hdhr_dashboard_view, name='hdhr_dashboard'),
path('<str:profile>/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'),
# channel_profile + output_profile_id (/hdhr/<channel_profile>/output_profile/<id>/...)
path('<str:channel_profile>/output_profile/<int:output_profile_id>/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile_and_output'),
path('<str:channel_profile>/output_profile/<int:output_profile_id>/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile_and_output'),
path('<str:channel_profile>/output_profile/<int:output_profile_id>/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile_and_output'),
# output_profile_id only (/hdhr/output_profile/<id>/...)
path('output_profile/<int:output_profile_id>/discover.json', DiscoverAPIView.as_view(), name='discover_with_output'),
path('output_profile/<int:output_profile_id>/lineup.json', LineupAPIView.as_view(), name='lineup_with_output'),
path('output_profile/<int:output_profile_id>/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_output'),
# channel_profile only
path('<str:channel_profile>/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'),
path('<str:channel_profile>/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'),
path('<str:channel_profile>/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile'),
# bare endpoints
path('discover.json', DiscoverAPIView.as_view(), name='discover_no_profile'),
path('<str:profile>/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'),
path('lineup.json', LineupAPIView.as_view(), name='lineup_no_profile'),
path('<str:profile>/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile'),
path('lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_no_profile'),
path('device.xml', HDHRDeviceXMLAPIView.as_view(), name='device_xml'),
]

View file

@ -0,0 +1,72 @@
from django.db import migrations
def move_to_system_settings(apps, schema_editor):
CoreSettings = apps.get_model("core", "CoreSettings")
try:
stream_obj = CoreSettings.objects.get(key="stream_settings")
except CoreSettings.DoesNotExist:
stream_obj = None
system_obj, _ = CoreSettings.objects.get_or_create(
key="system_settings",
defaults={"name": "System Settings", "value": {}},
)
system_value = system_obj.value if isinstance(system_obj.value, dict) else {}
if stream_obj:
stream_value = stream_obj.value if isinstance(stream_obj.value, dict) else {}
for field in ("preferred_region", "auto_import_mapped_files"):
if field in stream_value:
# Only migrate to system if not already explicitly set there
if field not in system_value or system_value[field] is None:
system_value[field] = stream_value.pop(field)
else:
stream_value.pop(field)
stream_obj.value = stream_value
stream_obj.save()
# Ensure sensible defaults if the fields are still absent
system_value.setdefault("preferred_region", None)
system_value.setdefault("auto_import_mapped_files", True)
system_obj.value = system_value
system_obj.save()
def reverse_move(apps, schema_editor):
CoreSettings = apps.get_model("core", "CoreSettings")
try:
system_obj = CoreSettings.objects.get(key="system_settings")
except CoreSettings.DoesNotExist:
return
stream_obj, _ = CoreSettings.objects.get_or_create(
key="stream_settings",
defaults={"name": "Stream Settings", "value": {}},
)
system_value = system_obj.value if isinstance(system_obj.value, dict) else {}
stream_value = stream_obj.value if isinstance(stream_obj.value, dict) else {}
for field in ("preferred_region", "auto_import_mapped_files"):
if field in system_value:
stream_value[field] = system_value.pop(field)
system_obj.value = system_value
system_obj.save()
stream_obj.value = stream_value
stream_obj.save()
class Migration(migrations.Migration):
dependencies = [
("core", "0024_outputprofile"),
]
operations = [
migrations.RunPython(move_to_system_settings, reverse_move),
]

View file

@ -243,9 +243,8 @@ class CoreSettings(models.Model):
"default_user_agent": None,
"default_stream_profile": None,
"m3u_hash_key": "",
"preferred_region": None,
"auto_import_mapped_files": None,
"default_output_format": "mpegts",
"hdhr_output_profile_id": None,
})
@classmethod
@ -266,11 +265,11 @@ class CoreSettings(models.Model):
@classmethod
def get_preferred_region(cls):
return cls.get_stream_settings().get("preferred_region")
return cls.get_system_settings().get("preferred_region")
@classmethod
def get_auto_import_mapped_files(cls):
return cls.get_stream_settings().get("auto_import_mapped_files")
return cls.get_system_settings().get("auto_import_mapped_files")
# EPG Settings
@classmethod
@ -394,6 +393,8 @@ class CoreSettings(models.Model):
return cls._get_group(SYSTEM_SETTINGS_KEY, {
"time_zone": getattr(settings, "TIME_ZONE", "UTC") or "UTC",
"max_system_events": 100,
"preferred_region": None,
"auto_import_mapped_files": True,
})
@classmethod
@ -406,6 +407,14 @@ class CoreSettings(models.Model):
cls._update_group(SYSTEM_SETTINGS_KEY, "System Settings", {"time_zone": value})
return value
@classmethod
def get_hdhr_output_profile_id(cls):
raw = cls.get_stream_settings().get("hdhr_output_profile_id")
try:
return int(raw) if raw is not None else None
except (ValueError, TypeError):
return None
@classmethod
def get_user_limits_settings(cls):
return cls._get_group(USER_LIMITS_SETTINGS_KEY, {

View file

@ -2,7 +2,7 @@ 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 useOutputProfilesStore from '../../../store/outputProfiles.jsx';
import React, { useEffect, useState } from 'react';
import {
getChangedSettings,
@ -10,16 +10,7 @@ import {
rehashStreams,
saveChangedSettings,
} from '../../../utils/pages/SettingsUtils.js';
import {
Alert,
Button,
Flex,
Group,
MultiSelect,
Select,
Switch,
Text,
} from '@mantine/core';
import { Alert, Button, Flex, MultiSelect, Select } from '@mantine/core';
import ConfirmationDialog from '../../ConfirmationDialog.jsx';
import { useForm } from '@mantine/form';
import {
@ -33,7 +24,7 @@ const StreamSettingsForm = React.memo(({ active }) => {
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const userAgents = useUserAgentsStore((s) => s.userAgents);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const regionChoices = REGION_CHOICES;
const outputProfiles = useOutputProfilesStore((s) => s.profiles);
// Store pending changed settings when showing the dialog
const [pendingChangedSettings, setPendingChangedSettings] = useState(null);
@ -168,6 +159,7 @@ const StreamSettingsForm = React.memo(({ active }) => {
id="default_user_agent"
name="default_user_agent"
label="Default User Agent"
description="User agent string sent when fetching streams. Some providers require a specific value to serve content."
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
@ -179,49 +171,50 @@ const StreamSettingsForm = React.memo(({ active }) => {
id="default_stream_profile"
name="default_stream_profile"
label="Default Stream Profile"
description="Stream profile applied when a channel has no profile assigned."
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('preferred_region')}
id="preferred_region"
name="preferred_region"
label="Preferred Region"
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/>
<Select
{...form.getInputProps('default_output_format')}
id="default_output_format"
name="default_output_format"
label="Default Output Format"
description="Container format used when proxying streams. MPEG-TS is broadly compatible with media players and devices; fMP4 has better support for modern codecs like AV1 and is preferred by some newer clients."
data={[
{ value: 'mpegts', label: 'MPEG-TS' },
{ value: 'fmp4', label: 'fMP4 (fragmented MP4)' },
]}
/>
<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="auto_import_mapped_files"
/>
</Group>
<Select
label="HDHR Default Output Profile"
description="Output profile applied to all HDHR stream URLs when no profile is specified in the URL path."
clearable
searchable
placeholder="No transcoding (pass-through)"
value={
form.values['hdhr_output_profile_id'] != null
? `${form.values['hdhr_output_profile_id']}`
: null
}
onChange={(value) =>
form.setFieldValue(
'hdhr_output_profile_id',
value ? parseInt(value, 10) : null
)
}
data={outputProfiles
.filter((p) => p.is_active)
.map((p) => ({ value: `${p.id}`, label: p.name }))}
/>
<MultiSelect
id="m3u_hash_key"
name="m3u_hash_key"
label="M3U Hash Key"
description="Fields used to generate a stable identifier for each stream. Changing this requires rehashing all streams."
data={[
{
value: 'name',

View file

@ -10,13 +10,17 @@ import {
Button,
Divider,
Flex,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
} from '@mantine/core';
import ConnectionSecurityPanel from './ConnectionSecurityPanel.jsx';
import { useForm } from '@mantine/form';
import { getSystemSettingsFormInitialValues } from '../../../utils/forms/settings/SystemSettingsFormUtils.js';
import { REGION_CHOICES } from '../../../constants.js';
const SystemSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
@ -64,13 +68,9 @@ const SystemSettingsForm = React.memo(({ active }) => {
{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)"
description="Number of events to retain (minimum: 10, maximum: 1000). Events are displayed on the Stats page."
value={form.values['max_system_events'] || 100}
onChange={(value) => {
form.setFieldValue('max_system_events', value);
@ -79,6 +79,35 @@ const SystemSettingsForm = React.memo(({ active }) => {
max={1000}
step={10}
/>
<Select
searchable
clearable
{...form.getInputProps('preferred_region')}
id="preferred_region"
name="preferred_region"
label="Preferred Region"
description="Used when matching EPG data to channels. Prioritizes guide entries from the selected region."
data={REGION_CHOICES.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/>
<Group justify="space-between" pt={5}>
<div>
<Text size="sm" fw={500}>
Auto-Import Mapped Files
</Text>
<Text size="xs" c="dimmed">
Automatically import media files when they are mapped to a channel.
</Text>
</div>
<Switch
{...form.getInputProps('auto_import_mapped_files', {
type: 'checkbox',
})}
id="auto_import_mapped_files"
/>
</Group>
{isModular && (
<>
<Divider my="md" label="Connection Security" labelPosition="left" />

View file

@ -344,6 +344,7 @@ const ChannelsTable = ({ onReady }) => {
const [, setIsLoading] = useState(true);
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
const [hdhrOutputProfileId, setHdhrOutputProfileId] = useState('');
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
@ -795,7 +796,7 @@ const ChannelsTable = ({ onReady }) => {
};
const copyHDHRUrl = async () => {
await copyToClipboard(hdhrUrl, {
await copyToClipboard(buildHDHRUrl(), {
successTitle: 'HDHR URL Copied!',
successMessage: 'The HDHR URL has been copied to your clipboard.',
});
@ -882,6 +883,13 @@ const ChannelsTable = ({ onReady }) => {
setM3UUrl(`${m3uUrlBase}${profileString}`);
}, [selectedProfileId, profiles]);
const buildHDHRUrl = () => {
if (!hdhrOutputProfileId) return hdhrUrl;
// Insert output_profile segment before the trailing slash (or at end)
const base = hdhrUrl.replace(/\/$/, '');
return `${base}/output_profile/${hdhrOutputProfileId}`;
};
useEffect(() => {
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
const endItem = Math.min(
@ -1307,19 +1315,22 @@ const ChannelsTable = ({ onReady }) => {
<Stack
gap="sm"
style={{
minWidth: 250,
maxWidth: 'min(400px, 80vw)',
minWidth: 300,
maxWidth: 'min(500px, 90vw)',
width: 'max-content',
}}
onClick={stopPropagation}
onMouseDown={stopPropagation}
>
<Text size="sm" c="dimmed">
Use this URL in HDHomeRun-compatible apps and IPTV
clients.
</Text>
<TextInput
value={hdhrUrl}
value={buildHDHRUrl()}
size="sm"
readOnly
label="Generated URL"
style={{ width: '100%' }}
rightSection={
<ActionIcon
@ -1332,6 +1343,19 @@ const ChannelsTable = ({ onReady }) => {
</ActionIcon>
}
/>
<Select
label="Output Profile"
description="Pre-delivery transcode profile. Overrides the system-wide HDHR default."
clearable
searchable
placeholder="System default"
value={hdhrOutputProfileId || null}
onChange={(value) => setHdhrOutputProfileId(value || '')}
comboboxProps={{ withinPortal: false }}
data={outputProfiles
.filter((p) => p.is_active)
.map((p) => ({ value: `${p.id}`, label: p.name }))}
/>
</Stack>
</Popover.Dropdown>
</Popover>

View file

@ -4,10 +4,9 @@ export const getStreamSettingsFormInitialValues = () => {
return {
default_user_agent: '',
default_stream_profile: '',
preferred_region: '',
auto_import_mapped_files: true,
m3u_hash_key: [],
default_output_format: 'mpegts',
hdhr_output_profile_id: null,
};
};
@ -15,6 +14,5 @@ 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

@ -1,5 +1,7 @@
export const getSystemSettingsFormInitialValues = () => {
return {
max_system_events: 100,
preferred_region: '',
auto_import_mapped_files: true,
};
};

View file

@ -19,19 +19,17 @@ describe('StreamSettingsFormUtils', () => {
expect(result).toEqual({
default_user_agent: '',
default_stream_profile: '',
preferred_region: '',
auto_import_mapped_files: true,
m3u_hash_key: [],
default_output_format: 'mpegts',
hdhr_output_profile_id: null,
});
});
it('should return boolean true for auto-import-mapped-files', () => {
it('should return null for hdhr_output_profile_id', () => {
const result =
StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result['auto_import_mapped_files']).toBe(true);
expect(typeof result['auto_import_mapped_files']).toBe('boolean');
expect(result['hdhr_output_profile_id']).toBeNull();
});
it('should return empty array for m3u-hash-key', () => {
@ -69,7 +67,6 @@ describe('StreamSettingsFormUtils', () => {
expect(Object.keys(result)).toEqual([
'default_user_agent',
'default_stream_profile',
'preferred_region',
]);
});
@ -85,10 +82,10 @@ describe('StreamSettingsFormUtils', () => {
expect(isNotEmpty).toHaveBeenCalledWith('Select a stream profile');
});
it('should use isNotEmpty validator for preferred_region', () => {
StreamSettingsFormUtils.getStreamSettingsFormValidation();
it('should not include validation for preferred_region', () => {
const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
expect(isNotEmpty).toHaveBeenCalledWith('Select a region');
expect(result).not.toHaveProperty('preferred_region');
});
it('should not include validation for auto-import-mapped-files', () => {
@ -108,7 +105,7 @@ describe('StreamSettingsFormUtils', () => {
expect(result['default_user_agent']).toBe('Select a user agent');
expect(result['default_stream_profile']).toBe('Select a stream profile');
expect(result['preferred_region']).toBe('Select a region');
expect(result).not.toHaveProperty('preferred_region');
});
});
});

View file

@ -9,6 +9,8 @@ describe('SystemSettingsFormUtils', () => {
expect(result).toEqual({
max_system_events: 100,
preferred_region: '',
auto_import_mapped_files: true,
});
});
@ -35,6 +37,8 @@ describe('SystemSettingsFormUtils', () => {
SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result).toHaveProperty('max_system_events');
expect(result).toHaveProperty('preferred_region');
expect(result).toHaveProperty('auto_import_mapped_files');
});
});
});

View file

@ -31,9 +31,8 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'default_user_agent',
'default_stream_profile',
'm3u_hash_key',
'preferred_region',
'auto_import_mapped_files',
'default_output_format',
'hdhr_output_profile_id',
];
const epgFields = [
'epg_match_mode',
@ -61,7 +60,12 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'retention_count',
'schedule_cron_expression',
];
const systemFields = ['time_zone', 'max_system_events'];
const systemFields = [
'time_zone',
'max_system_events',
'preferred_region',
'auto_import_mapped_files',
];
for (const formKey in changedSettings) {
let value = changedSettings[formKey];
@ -102,10 +106,14 @@ export const saveChangedSettings = async (settings, changedSettings) => {
}
if (
['default_user_agent', 'default_stream_profile'].includes(formKey) &&
[
'default_user_agent',
'default_stream_profile',
'hdhr_output_profile_id',
].includes(formKey) &&
value != null
) {
value = parseInt(value, 10);
value = value === '' ? null : parseInt(value, 10) || null;
}
const numericFields = [
@ -249,8 +257,14 @@ export const parseSettings = (settings) => {
streamSettings.default_stream_profile != null
? String(streamSettings.default_stream_profile)
: null;
parsed.preferred_region = streamSettings.preferred_region;
parsed.auto_import_mapped_files = streamSettings.auto_import_mapped_files;
parsed.default_output_format =
streamSettings.default_output_format != null
? String(streamSettings.default_output_format)
: 'mpegts';
parsed.hdhr_output_profile_id =
streamSettings.hdhr_output_profile_id != null
? String(streamSettings.hdhr_output_profile_id)
: null;
// m3u_hash_key should be array
const hashKey = streamSettings.m3u_hash_key;
@ -335,6 +349,11 @@ export const parseSettings = (settings) => {
typeof systemSettings.max_system_events === 'number'
? systemSettings.max_system_events
: parseInt(systemSettings.max_system_events, 10) || 100;
parsed.preferred_region = systemSettings.preferred_region ?? null;
parsed.auto_import_mapped_files =
typeof systemSettings.auto_import_mapped_files === 'boolean'
? systemSettings.auto_import_mapped_files
: true;
}
// Proxy and network access are already grouped objects

View file

@ -82,6 +82,12 @@ describe('SettingsUtils', () => {
value: {
default_user_agent: 7,
m3u_hash_key: 'channel_name',
},
});
expect(API.createSetting).toHaveBeenCalledWith({
key: 'system_settings',
name: 'System Settings',
value: {
preferred_region: 'UK',
},
});
@ -146,9 +152,9 @@ describe('SettingsUtils', () => {
key: 'dvr_settings',
value: {},
},
stream_settings: {
id: 1,
key: 'stream_settings',
system_settings: {
id: 3,
key: 'system_settings',
value: {},
},
};
@ -162,6 +168,18 @@ describe('SettingsUtils', () => {
await SettingsUtils.saveChangedSettings(settings, changedSettings);
expect(API.updateSetting).toHaveBeenCalledTimes(2);
expect(API.updateSetting).toHaveBeenCalledWith(
expect.objectContaining({
key: 'dvr_settings',
value: { comskip_enabled: true },
})
);
expect(API.updateSetting).toHaveBeenCalledWith(
expect.objectContaining({
key: 'system_settings',
value: { auto_import_mapped_files: false },
})
);
});
it('should handle proxy_settings specially', async () => {
@ -250,6 +268,12 @@ describe('SettingsUtils', () => {
default_user_agent: 5,
default_stream_profile: 3,
m3u_hash_key: 'channel_name,channel_number',
},
},
system_settings: {
id: 3,
key: 'system_settings',
value: {
preferred_region: 'US',
auto_import_mapped_files: true,
},