mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-19 22:32:39 +02:00
more hls handling
This commit is contained in:
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user