From 04655dce95dd5518b1f30f99b9ec61b278868752 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sun, 18 Oct 2026 14:51:46 +0200 Subject: [PATCH] more hls handling --- .../base/catchup_operations.py | 36 ++- .../base/drm_operations.py | 15 +- lib/streaming_providers/base/manager.py | 31 +- lib/streaming_providers/base/stream_format.py | 68 +++- routes/m3u.py | 1 + routes/streams/__init__.py | 295 +++++++++++++----- routes/streams/channels.py | 24 +- service.py | 27 +- 8 files changed, 395 insertions(+), 102 deletions(-) diff --git a/lib/streaming_providers/base/catchup_operations.py b/lib/streaming_providers/base/catchup_operations.py index 8bfbf4e..355c88f 100644 --- a/lib/streaming_providers/base/catchup_operations.py +++ b/lib/streaming_providers/base/catchup_operations.py @@ -6,6 +6,7 @@ Catchup/timeshift operations. from typing import Dict, List, Optional +from .drm_operations import ResolvedStream from .utils.logger import logger @@ -54,7 +55,7 @@ class CatchupOperations: logger.error(f"Provider '{provider_name}' hasn't implemented catchup") return None - def get_catchup_drm_configs( + def get_catchup_stream_info( self, provider_name: str, channel_id: str, @@ -63,11 +64,11 @@ class CatchupOperations: epg_id: Optional[str] = None, country: Optional[str] = None, drm_variant: Optional[str] = None, - ) -> List: - """Get DRM configs for catchup content.""" + ) -> ResolvedStream: + """Stream format + DRM configs for catchup content.""" provider = self.registry.get_provider(provider_name) if not provider: - raise ValueError(...) + raise ValueError(f"Provider '{provider_name}' not found or disabled") # First, get the catchup manifest URL and headers try: @@ -81,14 +82,14 @@ class CatchupOperations: ) except NotImplementedError: logger.error(f"Provider '{provider_name}' hasn't implemented catchup manifest resolution") - return [] + return ResolvedStream(None, ()) if not manifest_url: logger.warning(f"No catchup manifest URL for '{channel_id}'") - return [] + return ResolvedStream(None, ()) # Use the dedicated catchup DRM pipeline - return self.drm_operations.get_catchup_content_drm_configs( + return self.drm_operations.get_catchup_content_stream_info( provider_name=provider_name, channel_id=channel_id, catchup_manifest_url=manifest_url, @@ -100,6 +101,25 @@ class CatchupOperations: drm_variant=drm_variant, ) + def get_catchup_drm_configs( + self, + provider_name: str, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None, + drm_variant: Optional[str] = None, + ) -> List: + """Get DRM configs for catchup content (list wrapper over + get_catchup_stream_info).""" + return list( + self.get_catchup_stream_info( + provider_name, channel_id, start_time, end_time, + epg_id=epg_id, country=country, drm_variant=drm_variant, + ).drm_configs + ) + def get_catchup_window(self, provider_name: str, channel_id: Optional[str] = None) -> int: """Get catchup window in hours.""" provider = self.registry.get_provider(provider_name) @@ -139,4 +159,4 @@ class CatchupOperations: "catchup_window": 0, "catchup_enabled": False, } - return capabilities + return capabilities \ No newline at end of file diff --git a/lib/streaming_providers/base/drm_operations.py b/lib/streaming_providers/base/drm_operations.py index ed09958..f414467 100644 --- a/lib/streaming_providers/base/drm_operations.py +++ b/lib/streaming_providers/base/drm_operations.py @@ -59,7 +59,6 @@ from contextlib import contextmanager from dataclasses import dataclass from threading import Lock from typing import Any, Callable, Dict, Iterator, List, NamedTuple, Optional, Tuple -from urllib.parse import urljoin, urlsplit, urlunsplit from .drm import DRMPluginManager from .models import DRMConfig, DRMSystem @@ -71,6 +70,7 @@ from .stream_format import ( hls_has_media_segments, hls_key_attributes, is_hls_master, + join_hls_uri, ) from .utils import ManifestParser from .utils.drm_extractor import DRMExtractor @@ -775,17 +775,8 @@ class DRMOperations: @staticmethod def _join_hls_uri(base_url: str, uri: str, inherit_query: bool) -> str: - """Resolve a playlist URI against its parent (RFC 3986). Like segment - URLs for DASH, a query-less reference inherits the parent's query - (auth token) unless the provider opted out via - `inherit_manifest_query_on_segments = False`.""" - joined = urljoin(base_url, uri) - if inherit_query: - base_query = urlsplit(base_url).query - parts = urlsplit(joined) - if base_query and not parts.query: - joined = urlunsplit(parts._replace(query=base_query)) - return joined + """Thin alias for stream_format.join_hls_uri (kept for callers/tests).""" + return join_hls_uri(base_url, uri, inherit_query) @staticmethod def _is_manifest_encrypted(manifest_content: str) -> Optional[bool]: diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py index b51c43d..a8e1b87 100644 --- a/lib/streaming_providers/base/manager.py +++ b/lib/streaming_providers/base/manager.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional from .catchup_operations import CatchupOperations from .channel_operations import ChannelOperations -from .drm_operations import DRMOperations +from .drm_operations import DRMOperations, ResolvedStream from .epg_operations import EPGOperations from .event_operations import EventOperations from .vod_operations import VodOperations @@ -176,6 +176,21 @@ class ProviderManager: def get_channel_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List: return self.drm_ops.get_content_drm_configs(provider_name, channel_id, **kwargs) + # Stream info = detected format (DASH/HLS/None) + DRM configs, resolved in + # one pass and cached together. Same kwargs as the *_drm_configs methods. + + def get_channel_stream_info(self, provider_name: str, channel_id: str, **kwargs) -> ResolvedStream: + return self.drm_ops.get_content_stream_info(provider_name, channel_id, **kwargs) + + def get_event_stream_info(self, provider_name: str, event_id: str, **kwargs) -> ResolvedStream: + return self.drm_ops.get_content_stream_info(provider_name, event_id, **kwargs) + + def get_vod_stream_info(self, provider_name: str, vod_id: str, **kwargs) -> ResolvedStream: + return self.drm_ops.get_content_stream_info(provider_name, vod_id, **kwargs) + + def get_recording_stream_info(self, provider_name: str, recording_id: str, **kwargs) -> ResolvedStream: + return self.drm_ops.get_content_stream_info(provider_name, recording_id, **kwargs) + def list_drm_plugins(self) -> Dict: return self.drm_ops.list_drm_plugins() @@ -214,6 +229,20 @@ class ProviderManager: provider_name, channel_id, start_time, end_time, epg_id, country, drm_variant=drm_variant ) + def get_catchup_stream_info( + self, + provider_name: str, + channel_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + country: Optional[str] = None, + drm_variant: Optional[str] = None, + ) -> ResolvedStream: + return self.catchup_ops.get_catchup_stream_info( + provider_name, channel_id, start_time, end_time, epg_id, country, drm_variant=drm_variant + ) + def get_catchup_window(self, provider_name: str, channel_id: Optional[str] = None) -> int: return self.catchup_ops.get_catchup_window(provider_name, channel_id) diff --git a/lib/streaming_providers/base/stream_format.py b/lib/streaming_providers/base/stream_format.py index 46859be..110690b 100644 --- a/lib/streaming_providers/base/stream_format.py +++ b/lib/streaming_providers/base/stream_format.py @@ -19,7 +19,7 @@ and provider default are applied by the caller. import re from enum import Enum from typing import Dict, Iterator, Optional -from urllib.parse import urlparse +from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit class StreamFormat(str, Enum): @@ -168,4 +168,68 @@ def hls_key_attributes(content: str) -> Iterator[Dict[str, str]]: attrs: Dict[str, str] = {} for match in _HLS_ATTR_RE.finditer(line.split(":", 1)[1]): attrs[match.group(1)] = match.group(2).strip('"') - yield attrs \ No newline at end of file + yield attrs + + +# --------------------------------------------------------------------------- +# Serving helpers +# --------------------------------------------------------------------------- + +DASH_CONTENT_TYPE = "application/dash+xml; charset=utf-8" +HLS_CONTENT_TYPE = "application/vnd.apple.mpegurl" + + +def content_type_for(stream_format: Optional[StreamFormat]) -> str: + """HTTP Content-Type for a manifest of the given format. Unknown falls back + to DASH, which is what every manifest route served before HLS existed.""" + return HLS_CONTENT_TYPE if stream_format is StreamFormat.HLS else DASH_CONTENT_TYPE + + +def join_hls_uri(base_url: str, uri: str, inherit_query: bool = True) -> str: + """Resolve a playlist/segment URI against its parent (RFC 3986). + + Like segment URLs for DASH, a query-less reference inherits the parent's + query (auth token) when inherit_query is true. Providers opt out via + `inherit_manifest_query_on_segments = False`. + """ + joined = urljoin(base_url, uri) + if inherit_query: + base_query = urlsplit(base_url).query + parts = urlsplit(joined) + if base_query and not parts.query: + joined = urlunsplit(parts._replace(query=base_query)) + return joined + + +# URI="..." attribute inside a tag; the lookbehind keeps vendor attributes such +# as X-ASSET-URI untouched. +_HLS_URI_ATTR_RE = re.compile(r'(? str: + """Rewrite every relative reference in an HLS playlist to an absolute URL. + + HLS has no (the DASH trick used by `_inject_base_url`), so a + playlist that is re-served from another host must carry absolute URLs or + the player resolves them against OUR server. Handles URI lines (variants, + segments) and URI="..." attributes (EXT-X-MAP, -KEY, -SESSION-KEY, -MEDIA, + -I-FRAME-STREAM-INF, -PART, -PRELOAD-HINT, ...). data: URIs and already + absolute URLs are left alone. The playlist is otherwise returned verbatim. + """ + + def _abs(uri: str) -> str: + if not uri or uri.lower().startswith("data:"): + return uri + return join_hls_uri(base_url, uri, inherit_query) + + out = [] + for raw in content.splitlines(): + stripped = raw.strip() + if not stripped: + out.append(raw) + elif stripped.startswith("#"): + out.append(_HLS_URI_ATTR_RE.sub(lambda m: f'URI="{_abs(m.group(1))}"', raw)) + else: + out.append(_abs(stripped)) + result = "\n".join(out) + return result + "\n" if content.endswith("\n") else result \ No newline at end of file diff --git a/routes/m3u.py b/routes/m3u.py index 151ad3a..71916dc 100644 --- a/routes/m3u.py +++ b/routes/m3u.py @@ -500,6 +500,7 @@ def setup_m3u_routes(app, manager, service): "stream/index.mpd?client_drm=false" if proxied else "stream/index.mpd?client_drm=true" ) + stream_path = service.stream_path_for(channel, stream_path) stream_url = ( f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/{stream_path}" ) diff --git a/routes/streams/__init__.py b/routes/streams/__init__.py index 6af7b95..0bc109b 100644 --- a/routes/streams/__init__.py +++ b/routes/streams/__init__.py @@ -33,6 +33,12 @@ import time from urllib.parse import urljoin from bottle import redirect, request, response +from streaming_providers.base.stream_format import ( + StreamFormat, + absolutize_hls_playlist, + content_type_for, + detect_format, +) from streaming_providers.base.utils import logger from streaming_providers.base.utils.manifest_utils import ManifestUtils @@ -114,8 +120,8 @@ def setup_stream_routes(app, manager, service): provider, content_id, manifest_url ) - # Return unmodified manifest - response.content_type = "application/dash+xml; charset=utf-8" + # Return unmodified manifest (DASH or HLS — sniffed from the content) + response.content_type = content_type_for(detect_format(manifest_text)) return manifest_text except ValueError as e: @@ -154,6 +160,39 @@ def make_helpers(manager, service): # INTERNAL HELPERS (with manager and service closed over) # ========================================================================= + def _get_stream_info( + content_type: str, + provider: str, + content_id: str, + drm_variant: str = "auto", + **kwargs, + ): + """ + Dispatch to the correct manager *_stream_info method based on content + type. Returns a ResolvedStream: the manifest's detected format + (StreamFormat or None) plus its DRM configs. Raises ValueError for an + unknown provider/content type; re-raises other exceptions. + """ + kwargs.setdefault("drm_variant", drm_variant) + if content_type == CONTENT_TYPE_CHANNEL: + return manager.get_channel_stream_info( + provider_name=provider, channel_id=content_id, **kwargs + ) + elif content_type == CONTENT_TYPE_EVENT: + return manager.get_event_stream_info( + provider_name=provider, event_id=content_id, **kwargs + ) + elif content_type == CONTENT_TYPE_VOD: + return manager.get_vod_stream_info( + provider_name=provider, vod_id=content_id, **kwargs + ) + elif content_type == CONTENT_TYPE_RECORDING: + return manager.get_recording_stream_info( + provider_name=provider, recording_id=content_id, **kwargs + ) + else: + raise ValueError(f"Unknown content_type '{content_type}'") + def _get_drm_configs( content_type: str, provider: str, @@ -162,34 +201,21 @@ def make_helpers(manager, service): **kwargs, ): """ - Dispatch DRM config retrieval to the correct manager method based on - content type. Returns a list of DRMConfig objects (may be empty). - Raises ValueError for unknown provider; re-raises other exceptions. + Return the list of DRMConfig objects (may be empty) for any content + type — a thin wrapper over _get_stream_info that keeps the historic + list return type. Raises ValueError for unknown provider; re-raises + other exceptions. Args: drm_variant: 'auto' (provider default) or 'software' (prefer ClearKey). Passed through to the provider via **kwargs so individual providers can select an appropriate DRM scheme. """ - kwargs.setdefault("drm_variant", drm_variant) - if content_type == CONTENT_TYPE_CHANNEL: - return manager.get_channel_drm_configs( - provider_name=provider, channel_id=content_id, **kwargs - ) - elif content_type == CONTENT_TYPE_EVENT: - return manager.get_event_drm_configs( - provider_name=provider, event_id=content_id, **kwargs - ) - elif content_type == CONTENT_TYPE_VOD: - return manager.get_vod_drm_configs( - provider_name=provider, vod_id=content_id, **kwargs - ) - elif content_type == CONTENT_TYPE_RECORDING: - return manager.get_recording_drm_configs( - provider_name=provider, recording_id=content_id, **kwargs - ) - else: - raise ValueError(f"Unknown content_type '{content_type}'") + return list( + _get_stream_info( + content_type, provider, content_id, drm_variant=drm_variant, **kwargs + ).drm_configs + ) def _get_manifest_url( content_type: str, @@ -227,6 +253,70 @@ def make_helpers(manager, service): else: raise ValueError(f"Unknown content_type '{content_type}'") + def _attach_drm_header( + content_type: str, + provider: str, + content_id: str, + country=None, + # catchup-specific — only used for channels + is_catchup: bool = False, + start_time: int = None, + end_time: int = None, + epg_id: str = None, + drm_variant: str = "auto", + ): + """ + Resolve stream info (format + DRM configs), serialise the configs to + JSON and attach them as a base64-encoded response header + (x-kodi-drm-configs). When the manifest format is known it is also + attached as x-kodi-stream-format ("dash" / "hls"). Non-fatal: logs a + warning and returns None on any error. + + Args: + drm_variant: 'auto' or 'software'. Forwarded to the DRM config + fetch so the provider can return ClearKey configs when + the software-DRM variant is requested. + + Returns: + The ResolvedStream that was fetched, or None on failure. Callers + that need the configs or the format reuse this instead of + resolving a second time. + """ + try: + if is_catchup and content_type == CONTENT_TYPE_CHANNEL: + stream_info = manager.get_catchup_stream_info( + provider_name=provider, + channel_id=content_id, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + country=country, + drm_variant=drm_variant, + ) + else: + stream_info = _get_stream_info( + content_type, provider, content_id, + country=country, drm_variant=drm_variant, + ) + + merged = _serialize_drm_configs(stream_info.drm_configs) + + encoded = base64.b64encode( + json.dumps(merged).encode("utf-8") + ).decode("ascii") + + response.headers["x-kodi-drm-configs"] = encoded + if stream_info.stream_format is not None: + response.headers["x-kodi-stream-format"] = stream_info.stream_format.value + return stream_info + + except Exception as e: + logger.warning( + f"Could not build x-kodi-drm-configs header for " + f"{content_type} {provider}/{content_id} (variant={drm_variant}): {e}" + ) + return None + def _build_drm_header( content_type: str, provider: str, @@ -240,52 +330,19 @@ def make_helpers(manager, service): drm_variant: str = "auto", ): """ - Fetch DRM configs, serialise to JSON and attach as a base64-encoded - response header (x-kodi-drm-configs). Non-fatal: logs a warning and - returns [] on any error. - - Args: - drm_variant: 'auto' or 'software'. Forwarded to the DRM config - fetch so the provider can return ClearKey configs when - the software-DRM variant is requested. - - Returns: - The list of DRMConfig objects that were fetched (or [] on failure). - Callers that also need the raw configs (e.g. _resolve_stream_unified) - can reuse this instead of fetching them again. + Back-compat wrapper around _attach_drm_header: same response headers, + but returns only the list of DRMConfig objects ([] on failure). """ - try: - if is_catchup and content_type == CONTENT_TYPE_CHANNEL: - drm_configs = manager.get_catchup_drm_configs( - provider_name=provider, - channel_id=content_id, - start_time=start_time, - end_time=end_time, - epg_id=epg_id, - country=country, - drm_variant=drm_variant, - ) - else: - drm_configs = _get_drm_configs( - content_type, provider, content_id, - country=country, drm_variant=drm_variant, - ) - - merged = _serialize_drm_configs(drm_configs) - - encoded = base64.b64encode( - json.dumps(merged).encode("utf-8") - ).decode("ascii") - - response.headers["x-kodi-drm-configs"] = encoded - return drm_configs - - except Exception as e: - logger.warning( - f"Could not build x-kodi-drm-configs header for " - f"{content_type} {provider}/{content_id} (variant={drm_variant}): {e}" - ) - return [] + stream_info = _attach_drm_header( + content_type, provider, content_id, + country=country, + is_catchup=is_catchup, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + drm_variant=drm_variant, + ) + return list(stream_info.drm_configs) if stream_info else [] def _build_stream_headers( content_type: str, @@ -485,12 +542,23 @@ def make_helpers(manager, service): manifest_text, _, _, _, effective_url = service.fetch_manifest_for_rewriter( provider, content_id, manifest_url ) - # Inject the upstream manifest URL as a BaseURL so the player can - # resolve relative segment URLs correctly. Without this, segments - # resolve against the local server URL and all requests fail. - manifest_text = _inject_base_url(manifest_text, effective_url) + manifest_format = detect_format(manifest_text) + if manifest_format is StreamFormat.HLS: + # HLS has no : make every relative reference + # absolute so segments don't resolve against our server. + manifest_text = absolutize_hls_playlist( + manifest_text, effective_url, + inherit_query=bool(getattr( + provider_instance, "inherit_manifest_query_on_segments", True + )), + ) + else: + # Inject the upstream manifest URL as a BaseURL so the player can + # resolve relative segment URLs correctly. Without this, segments + # resolve against the local server URL and all requests fail. + manifest_text = _inject_base_url(manifest_text, effective_url) - response.content_type = "application/dash+xml; charset=utf-8" + response.content_type = content_type_for(manifest_format) return manifest_text except Exception as e: @@ -516,6 +584,68 @@ def make_helpers(manager, service): logger.debug(f"Redirecting to manifest: {manifest_url}") return redirect(manifest_url) + def _serve_hls_passthrough( + content_type: str, + provider: str, + content_id: str, + country, + drm_variant: str, + is_catchup: bool, + start_time, + end_time, + epg_id, + is_unencrypted: bool, + ): + """ + HLS phase 1: hand the client the upstream playlist WITHOUT rewriting. + + There is no HLS rewriter or proxy path yet — MPDRewriter and the media + proxy are DASH-only. The DASH routing in _resolve_stream_unified + (ClearKey rewrite, proxied manifest) must therefore never run for HLS: + MPDRewriter cannot parse a playlist and the request would end in a 502. + Instead the playlist is redirected to (or, for providers with + requires_manifest_context, fetched and absolutised by + _redirect_or_fetch). + + Limits until an HLS proxy path exists: providers whose manifest or + segment fetches need an HTTP proxy cannot be served this way, and + provider headers only reach the client via x-kodi-stream-headers / + KODIPROPs. Encrypted or undecidable HLS (e.g. AES-128, SAMPLE-AES) is + passed through as-is; the client's player and the x-kodi-drm-configs + header have to cope with it. + """ + if not is_unencrypted: + logger.debug( + f"HLS {provider}/{content_id}: not verified clear — passing the " + f"playlist through unchanged" + ) + if manager.needs_proxy(provider): + logger.warning( + f"HLS {provider}/{content_id}: provider uses an HTTP proxy but HLS " + f"proxying is not implemented — redirecting to the upstream " + f"playlist; playback may fail" + ) + + if is_catchup: + manifest_url = manager.get_catchup_manifest( + provider_name=provider, + channel_id=content_id, + start_time=start_time, + end_time=end_time, + epg_id=epg_id, + country=country, + drm_variant=drm_variant, + ) + if not manifest_url: + response.status = 404 + return { + "error": f'Catchup manifest not available for channel "{content_id}"' + } + logger.debug(f"Redirecting to HLS catchup playlist: {manifest_url}") + return redirect(manifest_url) + + return _redirect_or_fetch(content_type, provider, content_id, country, drm_variant) + def _resolve_stream_unified( content_type: str, provider: str, @@ -599,7 +729,7 @@ def make_helpers(manager, service): # --- DRM header (best-effort, never fatal). Also returns the DRM # configs it fetched so the branches below can reuse them instead of # fetching from the provider a second time. --- - drm_configs_for_header = _build_drm_header( + stream_info = _attach_drm_header( content_type, provider, content_id, country=country, is_catchup=is_catchup, @@ -608,6 +738,10 @@ def make_helpers(manager, service): epg_id=epg_id, drm_variant=drm_variant, ) + drm_configs_for_header = list(stream_info.drm_configs) if stream_info else [] + # None = unknown (resolution failed or manifest unfetchable) → the + # DASH routing below, i.e. exactly the behaviour before HLS existed. + stream_format = stream_info.stream_format if stream_info else None # --- Stream headers (best-effort, never fatal) --- _build_stream_headers( content_type, provider, content_id, @@ -626,6 +760,15 @@ def make_helpers(manager, service): ) is_unencrypted = "none" in drm_dict + # ================================================================== + # HLS: no rewriter exists, so none of the DASH routing below applies. + # ================================================================== + if stream_format is StreamFormat.HLS: + return _serve_hls_passthrough( + content_type, provider, content_id, country, drm_variant, + is_catchup, start_time, end_time, epg_id, is_unencrypted, + ) + # ================================================================== # Catchup path (channel-specific) # ================================================================== @@ -856,6 +999,8 @@ def make_helpers(manager, service): "_get_drm_configs": _get_drm_configs, "_get_manifest_url": _get_manifest_url, "_build_drm_header": _build_drm_header, + "_attach_drm_header": _attach_drm_header, + "_get_stream_info": _get_stream_info, "_build_stream_headers": _build_stream_headers, "_stream_needs_headers": _stream_needs_headers, "_inject_base_url": _inject_base_url, diff --git a/routes/streams/channels.py b/routes/streams/channels.py index 4250abe..27a72b9 100644 --- a/routes/streams/channels.py +++ b/routes/streams/channels.py @@ -10,6 +10,7 @@ channel-specific catchup handling logic. import time from bottle import HTTPResponse, request, response +from streaming_providers.base.stream_format import StreamFormat from streaming_providers.base.utils import logger @@ -17,6 +18,7 @@ def setup_channel_routes(app, manager, service, helpers): """Setup all channel-related stream routes.""" CONTENT_TYPE_CHANNEL = helpers["CONTENT_TYPE_CHANNEL"] _build_drm_header = helpers["_build_drm_header"] + _attach_drm_header = helpers["_attach_drm_header"] _build_stream_headers = helpers["_build_stream_headers"] _resolve_stream_unified = helpers["_resolve_stream_unified"] _get_drm_configs = helpers["_get_drm_configs"] @@ -130,20 +132,30 @@ def setup_channel_routes(app, manager, service, helpers): country = request.query.get("country") base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}" qs = f"?country={country}" if country else "" + + # Resolve first: the detected format decides which alias the URLs + # point at. Both aliases hit the same handler — the extension only + # helps clients that pick their player from the URL. Unknown + # format (None) keeps index.mpd, i.e. the pre-HLS behaviour. + stream_info = _attach_drm_header( + CONTENT_TYPE_CHANNEL, provider, channel_id, country=country + ) + stream_format = stream_info.stream_format if stream_info else None + index_name = "index.m3u8" if stream_format is StreamFormat.HLS else "index.mpd" + stream_url = ( f"{base_url}/api/providers/{provider}/channels/{channel_id}" - f"/stream/index.mpd{qs}" + f"/stream/{index_name}{qs}" ) # Catchup template — callers substitute {start_time}/{end_time} with # Unix timestamps. Matches the query params consumed by _handle_channel_stream. catchup_qs_sep = "&" if qs else "?" catchup_stream_url_template = ( f"{base_url}/api/providers/{provider}/channels/{channel_id}" - f"/stream/index.mpd{qs}{catchup_qs_sep}" + f"/stream/{index_name}{qs}{catchup_qs_sep}" f"start_time={{start_time}}&end_time={{end_time}}" ) - _build_drm_header(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) _build_stream_headers(CONTENT_TYPE_CHANNEL, provider, channel_id, country=country) return { @@ -151,6 +163,7 @@ def setup_channel_routes(app, manager, service, helpers): "channel_id": channel_id, "manifest_url": stream_url, "catchup_stream_url_template": catchup_stream_url_template, + "stream_format": stream_format.value if stream_format else None, } except ValueError as e: @@ -163,9 +176,14 @@ def setup_channel_routes(app, manager, service, helpers): return {"error": f"Internal server error: {str(e)}"} @app.route("/api/providers//channels//stream/index.mpd") + @app.route("/api/providers//channels//stream/index.m3u8") def get_channel_stream(provider, channel_id): """ Single stream endpoint for all channel playback — live and catchup. + index.m3u8 is an alias of index.mpd served by the same handler; the + format actually returned follows the upstream manifest, not the + extension. Playlists point HLS channels at the alias so clients that + select a player from the URL pick the right one. Replaces the former /stream/sw-drm/, /stream/noproxy/, /stream/proxied/, and /stream/proxied/ffmpeg/ routes, which are removed. Every combination those routes used to hardcode (and some that were diff --git a/service.py b/service.py index 5267a8c..40629b7 100644 --- a/service.py +++ b/service.py @@ -23,6 +23,7 @@ try: # Add EPG Manager import from streaming_providers.base.epg.epg_manager import EPGManager from streaming_providers.base.models import StreamingChannel + from streaming_providers.base.stream_format import StreamFormat from streaming_providers.base.settings.provider_enable_manager import ( ProviderEnableManager, ) @@ -831,6 +832,10 @@ class UltimateService: if manifest_headers_encoded or segment_headers_encoded: if "#KODIPROP:inputstream=inputstream.adaptive\n" not in entry_content: entry_content += "#KODIPROP:inputstream=inputstream.adaptive\n" + if self._is_hls_hint(channel): + # inputstream.adaptive was activated above; don't leave the + # manifest type to autodetection when the hint says HLS. + entry_content += "#KODIPROP:inputstream.adaptive.manifest_type=hls\n" if manifest_headers_encoded: entry_content += ( f"#KODIPROP:inputstream.adaptive.manifest_headers={manifest_headers_encoded}\n" @@ -842,6 +847,22 @@ class UltimateService: return entry_content + @staticmethod + def _is_hls_hint(channel) -> bool: + """True if the channel's streaming_format HINT says HLS. Only a hint: + unknown (None) means "no information", not DASH.""" + return StreamFormat.parse(getattr(channel, "streaming_format", None)) is StreamFormat.HLS + + @staticmethod + def stream_path_for(channel, stream_path: str) -> str: + """Point HLS-hinted channels at the index.m3u8 alias of the single + stream route; everything else keeps index.mpd. Query strings are + untouched. Same handler either way — the alias only helps clients that + choose their player from the URL extension.""" + if UltimateService._is_hls_hint(channel): + return stream_path.replace("stream/index.mpd", "stream/index.m3u8", 1) + return stream_path + def _generate_m3u_entry( self, base_url, @@ -869,6 +890,7 @@ class UltimateService: it's appended as-is. """ channel_id = channel.channel_id + stream_path = self.stream_path_for(channel, stream_path) header = self._build_m3u_entry_header( provider_name, channel, provider_label=provider_label, @@ -1048,7 +1070,10 @@ class UltimateService: # client_drm=false matches this playlist's static KODIPROP # line below (server decrypts); highest_quality_only=true # replaces the old dedicated /stream/proxied/ffmpeg/ path. - stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/index.mpd?client_drm=false&highest_quality_only=true" + stream_path = self.stream_path_for( + channel, "stream/index.mpd?client_drm=false&highest_quality_only=true" + ) + stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/{stream_path}" # Build ffmpeg pipe command ffmpeg_cmd = self._build_ffmpeg_pipe_command(stream_url, channel_name)