mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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
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:
parent
84fbbfa3c4
commit
ba76a4d5f2
14 changed files with 312 additions and 93 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export const getSystemSettingsFormInitialValues = () => {
|
||||
return {
|
||||
max_system_events: 100,
|
||||
preferred_region: '',
|
||||
auto_import_mapped_files: true,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue