diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c1a6d7..50f8b9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **HDHR lineup and discovery URLs now use the shared port-aware URI builder.** `lineup.json`, `discover.json`, and `device.xml` previously called Django's `request.build_absolute_uri()`, which drops non-standard ports behind reverse proxies and on dev installs. They now use `build_absolute_uri_with_port()` from `core/utils.py`, matching M3U, EPG, XC, and logo cache URLs. - **EPG channel list did not refresh after a source finished parsing channels.** The `epg_refresh` WebSocket handler closed over a stale `epgs` snapshot from when the socket connected. When a newly created source was not found in that snapshot, the handler updated progress and returned early without calling `fetchEPGData()`, so the channel picker and EPG assignment UI stayed empty until a full page reload. The handler now reads the current store via `getState()` and still calls `fetchEPGData()` when `parsing_channels` completes. - **DVR recordings no longer stop immediately when FFmpeg exits mid-stream.** If FFmpeg crashes, stalls, or loses the source before the scheduled end time, `run_recording` now restarts it in-process instead of going straight to HLS→MKV concat and marking the recording complete. Each restart reuses the same HLS working directory and continues segment numbering (`append_list`, `-start_number`) so the final MKV is a single continuous file. Retries are bounded by a per-outage time window matching live-proxy client tolerance (`STREAM_TIMEOUT` + `FAILOVER_GRACE_PERIOD`, default 80s); the window resets whenever new segments resume, so a long recording can survive multiple separate outages. Reconnect pacing between attempts follows the same `min(0.25 × attempt, 3s)` backoff used by the live-proxy `StreamManager`. Input demuxing also uses `-err_detect ignore_err` to tolerate minor TS corruption without aborting the process. If the outage window expires without recovery, the recording is saved as `interrupted` with `ffmpeg_outage_window_exhausted` rather than falsely reported as `completed`. (Closes #1170) - **DVR HLS→MKV concat now tolerates timestamp splices and corrupt segments.** The finalize step (and startup recovery concat) previously used a bare `ffmpeg -f concat -c copy`, which could fail on truncated tail segments or PTS discontinuities from FFmpeg restarts mid-recording. Concat now uses a shared `_dvr_build_hls_concat_cmd` helper with `-fflags +genpts+igndts+discardcorrupt`, `-err_detect ignore_err`, and `-avoid_negative_ts make_zero`; the existing MP4-intermediate fallback path uses the same tolerant input flags. diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index b4b91bd0..7da59f95 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -19,6 +19,7 @@ from django.utils.decorators import method_decorator from django.contrib.auth.decorators import login_required from django.views.decorators.csrf import csrf_exempt from dispatcharr.utils import network_access_allowed +from core.utils import build_absolute_uri_with_port # Configure logger logger = logging.getLogger(__name__) @@ -75,7 +76,7 @@ class DiscoverAPIView(APIView): uri_parts.append("output_profile") uri_parts.append(str(output_profile_id)) - base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip("/") + base_url = build_absolute_uri_with_port(request, f'/{"/".join(uri_parts)}/').rstrip("/") device = HDHRDevice.objects.first() from apps.m3u.utils import calculate_tuner_count @@ -166,6 +167,13 @@ class LineupAPIView(APIView): resolved_output_profile_id = _resolve_hdhr_output_profile_id(output_profile_id) + _stream_url_prefix = build_absolute_uri_with_port(request, "/proxy/ts/stream/") + _output_profile_qs = ( + f"?output_profile={resolved_output_profile_id}" + if resolved_output_profile_id is not None + else "" + ) + lineup = [] for ch in channels: formatted = format_channel_number(ch.effective_channel_number, empty=None) @@ -173,9 +181,7 @@ class LineupAPIView(APIView): 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}" + stream_url = f"{_stream_url_prefix}{ch.uuid}{_output_profile_qs}" lineup.append( { @@ -224,7 +230,7 @@ class HDHRDeviceXMLAPIView(APIView): if blocked is not None: return blocked - base_url = request.build_absolute_uri("/hdhr/").rstrip("/") + base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/") xml_response = f""" diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 6777bdbf..35a32586 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -15,6 +15,7 @@ from django.views import View from django.utils.decorators import method_decorator from django.contrib.auth.decorators import login_required from django.views.decorators.csrf import csrf_exempt +from core.utils import build_absolute_uri_with_port @login_required @@ -46,7 +47,7 @@ class DiscoverAPIView(APIView): description="Retrieve HDHomeRun device discovery information", ) def get(self, request): - base_url = request.build_absolute_uri("/hdhr/").rstrip("/") + base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/") device = HDHRDevice.objects.first() if not device: @@ -92,6 +93,8 @@ class LineupAPIView(APIView): .exclude(hidden_from_output=True) .order_by("effective_channel_number") ) + _stream_url_prefix = build_absolute_uri_with_port(request, "/proxy/ts/stream/") + lineup = [] for ch in channels: formatted = format_channel_number(ch.effective_channel_number, empty=None) @@ -102,7 +105,7 @@ class LineupAPIView(APIView): { "GuideNumber": formatted_channel_number, "GuideName": ch.effective_name, - "URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"), + "URL": f"{_stream_url_prefix}{ch.uuid}", } ) return JsonResponse(lineup, safe=False) @@ -133,7 +136,7 @@ class HDHRDeviceXMLAPIView(APIView): description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): - base_url = request.build_absolute_uri("/hdhr/").rstrip("/") + base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/") xml_response = f"""