From ba76a4d5f2d28ba6cb362888530246ba530eb96d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 10 May 2026 11:14:02 -0500 Subject: [PATCH] feat: Add HDHR output profile support and reorganize settings - 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. --- CHANGELOG.md | 9 +++ apps/hdhr/api_views.py | 62 +++++++++++----- apps/hdhr/urls.py | 21 +++++- ...gion_and_auto_import_to_system_settings.py | 72 +++++++++++++++++++ core/models.py | 17 +++-- .../forms/settings/StreamSettingsForm.jsx | 63 ++++++++-------- .../forms/settings/SystemSettingsForm.jsx | 39 ++++++++-- .../src/components/tables/ChannelsTable.jsx | 32 +++++++-- .../forms/settings/StreamSettingsFormUtils.js | 4 +- .../forms/settings/SystemSettingsFormUtils.js | 2 + .../__tests__/StreamSettingsFormUtils.test.js | 17 ++--- .../__tests__/SystemSettingsFormUtils.test.js | 4 ++ frontend/src/utils/pages/SettingsUtils.js | 33 +++++++-- .../pages/__tests__/SettingsUtils.test.js | 30 +++++++- 14 files changed, 312 insertions(+), 93 deletions(-) create mode 100644 core/migrations/0025_move_preferred_region_and_auto_import_to_system_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c071d05a..4ae81782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//lineup.json` - output profile only + - `/hdhr//output_profile//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=` 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. diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 41c1a3d9..b4b91bd0 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -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 diff --git a/apps/hdhr/urls.py b/apps/hdhr/urls.py index 2659cd7b..f1f0f6f5 100644 --- a/apps/hdhr/urls.py +++ b/apps/hdhr/urls.py @@ -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('/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'), + + # channel_profile + output_profile_id (/hdhr//output_profile//...) + path('/output_profile//discover.json', DiscoverAPIView.as_view(), name='discover_with_profile_and_output'), + path('/output_profile//lineup.json', LineupAPIView.as_view(), name='lineup_with_profile_and_output'), + path('/output_profile//lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile_and_output'), + + # output_profile_id only (/hdhr/output_profile//...) + path('output_profile//discover.json', DiscoverAPIView.as_view(), name='discover_with_output'), + path('output_profile//lineup.json', LineupAPIView.as_view(), name='lineup_with_output'), + path('output_profile//lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_output'), + + # channel_profile only + path('/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'), + path('/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'), + path('/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile'), + + # bare endpoints path('discover.json', DiscoverAPIView.as_view(), name='discover_no_profile'), - path('/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'), path('lineup.json', LineupAPIView.as_view(), name='lineup_no_profile'), - path('/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'), ] diff --git a/core/migrations/0025_move_preferred_region_and_auto_import_to_system_settings.py b/core/migrations/0025_move_preferred_region_and_auto_import_to_system_settings.py new file mode 100644 index 00000000..7a8a8f84 --- /dev/null +++ b/core/migrations/0025_move_preferred_region_and_auto_import_to_system_settings.py @@ -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), + ] diff --git a/core/models.py b/core/models.py index 5cae7e09..293a40f3 100644 --- a/core/models.py +++ b/core/models.py @@ -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, { diff --git a/frontend/src/components/forms/settings/StreamSettingsForm.jsx b/frontend/src/components/forms/settings/StreamSettingsForm.jsx index 4cf1e294..4b7aa4a3 100644 --- a/frontend/src/components/forms/settings/StreamSettingsForm.jsx +++ b/frontend/src/components/forms/settings/StreamSettingsForm.jsx @@ -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, }))} /> - - - - - Auto-Import Mapped Files - - - + ({ + label: r.label, + value: `${r.value}`, + }))} + /> + +
+ + Auto-Import Mapped Files + + + Automatically import media files when they are mapped to a channel. + +
+ +
{isModular && ( <> diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index b35ae440..1405fd01 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -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 }) => { Use this URL in HDHomeRun-compatible apps and IPTV clients. { } /> +