more hls handling

This commit is contained in:
Nirvana
2026-10-18 14:51:46 +02:00
parent 0a2d5cd7ad
commit 04655dce95
8 changed files with 395 additions and 102 deletions
@@ -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
+3 -12
View File
@@ -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]:
+30 -1
View File
@@ -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)
+66 -2
View File
@@ -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
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'(?<![A-Za-z0-9-])URI="([^"]*)"')
def absolutize_hls_playlist(content: str, base_url: str, inherit_query: bool = True) -> str:
"""Rewrite every relative reference in an HLS playlist to an absolute URL.
HLS has no <BaseURL> (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
+1
View File
@@ -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}"
)
+220 -75
View File
@@ -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 <BaseURL>: 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,
+21 -3
View File
@@ -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/<provider>/channels/<channel_id>/stream/index.mpd")
@app.route("/api/providers/<provider>/channels/<channel_id>/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
+26 -1
View File
@@ -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)