diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3d06dd..00a87240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal) with loading fallbacks - Removed unused Dashboard and Home pages - Logo loading optimization: Logos now load only after both Channels and Streams tables complete loading to prevent blocking initial page render, with rendering gated by table readiness to ensure data loads before visual elements +- M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs ### Fixed +- M3U and EPG URLs now correctly preserve non-standard HTTPS ports (e.g., `:8443`) when accessed behind reverse proxies that forward the port in headers — `get_host_and_port()` now properly checks `X-Forwarded-Port` header before falling back to other detection methods (Fixes #704) - M3U and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation) - Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect - XtreamCodes EPG limit parameter now properly converted to integer to prevent type errors when accessing EPG listings (Fixes #781) diff --git a/apps/output/views.py b/apps/output/views.py index ca222ed5..aa7fd1bb 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -180,7 +180,7 @@ def generate_m3u(request, profile_name=None, user=None): if user is not None and xc_username and xc_password: # This is an XC API request - use XC-style EPG URL - base_url = request.build_absolute_uri('/')[:-1] + base_url = build_absolute_uri_with_port(request, '') epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}" else: # Regular request - use standard EPG endpoint @@ -257,12 +257,10 @@ def generate_m3u(request, profile_name=None, user=None): stream_url = first_stream.url else: # Fall back to proxy URL if no direct URL available - base_url = request.build_absolute_uri('/')[:-1] - stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}" + stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") else: # Standard behavior - use proxy URL - base_url = request.build_absolute_uri('/')[:-1] - stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}" + stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") m3u_content += extinf_line + stream_url + "\n" @@ -2942,19 +2940,16 @@ def get_host_and_port(request): if xfh: if ":" in xfh: host, port = xfh.split(":", 1) - # Omit standard ports from URLs, or omit if port doesn't match standard for scheme - # (e.g., HTTPS but port is 9191 = behind external reverse proxy) + # Omit standard ports from URLs if port == standard_port: return host, None - # If port doesn't match standard and X-Forwarded-Proto is set, likely behind external RP - if request.META.get("HTTP_X_FORWARDED_PROTO"): - host = xfh.split(":")[0] # Strip port, will check for proper port below - else: - return host, port + # Non-standard port in X-Forwarded-Host - return it + # This handles reverse proxies on non-standard ports (e.g., https://example.com:8443) + return host, port else: host = xfh - # Check for X-Forwarded-Port header (if we didn't already find a valid port) + # Check for X-Forwarded-Port header (if we didn't find a port in X-Forwarded-Host) port = request.META.get("HTTP_X_FORWARDED_PORT") if port: # Omit standard ports from URLs @@ -2972,22 +2967,28 @@ def get_host_and_port(request): else: host = raw_host - # 3. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present) + # 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy) + port = request.META.get("HTTP_X_FORWARDED_PORT") + if port: + # Omit standard ports from URLs + return host, None if port == standard_port else port + + # 4. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present) # If so, assume standard port for the scheme (don't trust SERVER_PORT in this case) if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"): return host, None - # 4. Try SERVER_PORT from META (only if NOT behind reverse proxy) + # 5. Try SERVER_PORT from META (only if NOT behind reverse proxy) port = request.META.get("SERVER_PORT") if port: # Omit standard ports from URLs return host, None if port == standard_port else port - # 5. Dev fallback: guess port 5656 + # 6. Dev fallback: guess port 5656 if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"): return host, "5656" - # 6. Final fallback: assume standard port for scheme (omit from URL) + # 7. Final fallback: assume standard port for scheme (omit from URL) return host, None def build_absolute_uri_with_port(request, path):