mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-25 08:12:26 +01:00
196 lines
7.4 KiB
Python
196 lines
7.4 KiB
Python
# streaming_providers/base/utils/hls_rewriter.py
|
|
"""
|
|
HLS playlist rewriter (phase 2, Option A).
|
|
|
|
Pure text transformation, no I/O and no knowledge of the proxy URL scheme or
|
|
token signing: every reference in the playlist is resolved to an absolute
|
|
upstream URL and handed to a caller-supplied `build_url(url, kind)`, which
|
|
returns the URL to put into the playlist. Under Option A the caller maps
|
|
PLAYLIST -> the backend's playlist endpoint
|
|
SEGMENT / INIT / KEY -> the media proxy (/api/proxy/<token>)
|
|
Kind is decided from the TAG a reference sits in, never from the file
|
|
extension (tokenised CDN URLs usually have none).
|
|
|
|
Guarantees
|
|
- Line order, comments, #EXT-X-MEDIA-SEQUENCE, discontinuity tags, BYTERANGE
|
|
and every attribute other than URI="..." are passed through untouched.
|
|
- data: URIs and non-http(s) schemes (skd://, ...) are never rewritten.
|
|
- Relative references resolve against `base_url` (the post-redirect playlist
|
|
URL), inheriting its query like the DASH path when `inherit_query` is true.
|
|
|
|
Not supported (raises UnsupportedHLSFeature): low-latency HLS tags. Whether to
|
|
support or reject LL-HLS is still an open decision (handoff question 5);
|
|
rejecting is the safe default because blocking reloads and part requests
|
|
cannot be served through a rewritten playlist.
|
|
|
|
AES-128 key URIs are only routed through `build_url` when `proxy_keys=True`
|
|
(and METHOD=AES-128 with an identity KEYFORMAT). Default is False, matching
|
|
the current "AES-128 = unknown, pass through" policy (absolutised only).
|
|
"""
|
|
|
|
import re
|
|
from enum import Enum
|
|
from typing import Callable, Optional
|
|
from urllib.parse import urlsplit
|
|
|
|
from ..stream_format import join_hls_uri
|
|
|
|
|
|
class HLSResource(str, Enum):
|
|
PLAYLIST = "playlist" # variant / audio / subtitle / I-frame playlist
|
|
SEGMENT = "segment" # media segment
|
|
INIT = "init" # EXT-X-MAP initialisation segment
|
|
KEY = "key" # AES-128 key file
|
|
|
|
|
|
class HLSRewriteError(ValueError):
|
|
"""The input is not a rewritable HLS playlist."""
|
|
|
|
|
|
class UnsupportedHLSFeature(HLSRewriteError):
|
|
"""The playlist uses a feature the proxied path does not support."""
|
|
|
|
|
|
UrlBuilder = Callable[[str, HLSResource], str]
|
|
|
|
# Same lookbehind as stream_format._HLS_URI_ATTR_RE: vendor attributes such as
|
|
# X-ASSET-URI stay untouched.
|
|
_URI_ATTR_RE = re.compile(r'(?<![A-Za-z0-9-])URI="([^"]*)"')
|
|
_METHOD_RE = re.compile(r'(?<![A-Za-z0-9-])METHOD=([A-Z0-9-]+)')
|
|
_KEYFORMAT_RE = re.compile(r'(?<![A-Za-z0-9-])KEYFORMAT="([^"]*)"')
|
|
|
|
_LOW_LATENCY_TAGS = frozenset({
|
|
"EXT-X-PART",
|
|
"EXT-X-PART-INF",
|
|
"EXT-X-PRELOAD-HINT",
|
|
"EXT-X-RENDITION-REPORT",
|
|
})
|
|
|
|
_PLAYLIST_TAGS = frozenset({"EXT-X-MEDIA", "EXT-X-I-FRAME-STREAM-INF"})
|
|
_KEY_TAGS = frozenset({"EXT-X-KEY", "EXT-X-SESSION-KEY"})
|
|
|
|
|
|
def _is_rewritable_reference(uri: str) -> bool:
|
|
"""Relative references and http(s) URLs only. data:, skd:, etc. are left."""
|
|
return urlsplit(uri).scheme.lower() in ("", "http", "https")
|
|
|
|
|
|
def _kind_for_tag(name: str, line: str, proxy_keys: bool) -> Optional[HLSResource]:
|
|
"""Resource kind of the URI="..." attribute of a tag, None = absolutise only."""
|
|
if name in _PLAYLIST_TAGS:
|
|
return HLSResource.PLAYLIST
|
|
if name == "EXT-X-MAP":
|
|
return HLSResource.INIT
|
|
if name in _KEY_TAGS and proxy_keys:
|
|
method = _METHOD_RE.search(line)
|
|
keyformat = _KEYFORMAT_RE.search(line)
|
|
if (
|
|
method and method.group(1) == "AES-128"
|
|
and (keyformat is None or keyformat.group(1).lower() == "identity")
|
|
):
|
|
return HLSResource.KEY
|
|
return None
|
|
|
|
|
|
_TARGET_DURATION_RE = re.compile(r"^#EXT-X-TARGETDURATION:(\d+)", re.MULTILINE)
|
|
|
|
|
|
def hls_cache_ttl(
|
|
content: str,
|
|
*,
|
|
master_ttl: float = 30.0,
|
|
vod_ttl: float = 120.0,
|
|
live_min: float = 1.0,
|
|
live_max: float = 5.0,
|
|
live_default: float = 2.0,
|
|
) -> float:
|
|
"""How long a REWRITTEN playlist may be reused, in seconds.
|
|
|
|
master (multivariant) -> master_ttl: stable within a session
|
|
media + #EXT-X-ENDLIST -> vod_ttl: complete, does not change
|
|
live media playlist -> half the target duration, clamped to
|
|
[live_min, live_max]: viewers share one
|
|
rewrite per refresh interval, but nobody is
|
|
served a playlist older than half a segment
|
|
The upstream Cache-Control header is deliberately not consulted: CDNs
|
|
often send none or a value unrelated to the refresh interval.
|
|
"""
|
|
if "#EXT-X-STREAM-INF" in content:
|
|
return master_ttl
|
|
if "#EXT-X-ENDLIST" in content:
|
|
return vod_ttl
|
|
match = _TARGET_DURATION_RE.search(content)
|
|
if not match:
|
|
return live_default
|
|
return max(live_min, min(live_max, int(match.group(1)) / 2.0))
|
|
|
|
|
|
def rewrite_hls_playlist(
|
|
content: str,
|
|
base_url: str,
|
|
build_url: UrlBuilder,
|
|
*,
|
|
inherit_query: bool = True,
|
|
proxy_keys: bool = False,
|
|
) -> str:
|
|
"""Rewrite every reference in an HLS playlist.
|
|
|
|
Args:
|
|
content: playlist text (master or media).
|
|
base_url: URL the playlist was actually fetched from (post-redirect).
|
|
build_url: maps (absolute upstream URL, kind) -> URL for the output.
|
|
inherit_query: relative references inherit base_url's query (see
|
|
stream_format.join_hls_uri); providers opt out through
|
|
`inherit_manifest_query_on_segments = False`.
|
|
proxy_keys: route AES-128 key URIs through build_url (default: only
|
|
absolutise them).
|
|
|
|
Raises:
|
|
HLSRewriteError: content is not an HLS playlist.
|
|
UnsupportedHLSFeature: low-latency HLS tags present.
|
|
"""
|
|
content = content.lstrip("\ufeff") # a BOM must not turn #EXTM3U into a "segment"
|
|
if not content.lstrip(" \t\r\n").startswith("#EXTM3U"):
|
|
raise HLSRewriteError("not an HLS playlist (missing #EXTM3U)")
|
|
|
|
def resolve(uri: str, kind: Optional[HLSResource]) -> str:
|
|
if not uri or not _is_rewritable_reference(uri):
|
|
return uri
|
|
# An absolute URL is taken exactly as the upstream wrote it (no query
|
|
# inheritance, as in MPDRewriter._urljoin_preserve_query); only relative
|
|
# references resolve against base_url.
|
|
absolute = uri if urlsplit(uri).scheme else join_hls_uri(base_url, uri, inherit_query)
|
|
return absolute if kind is None else build_url(absolute, kind)
|
|
|
|
out = []
|
|
expect_variant_uri = False # the line after #EXT-X-STREAM-INF is a playlist
|
|
|
|
for raw in content.splitlines():
|
|
line = raw.strip()
|
|
if not line:
|
|
out.append(raw)
|
|
continue
|
|
|
|
if line.startswith("#"):
|
|
name = line[1:].split(":", 1)[0]
|
|
if name in _LOW_LATENCY_TAGS:
|
|
raise UnsupportedHLSFeature(
|
|
f"low-latency HLS tag {name} is not supported on the proxied path"
|
|
)
|
|
if name == "EXT-X-STREAM-INF":
|
|
expect_variant_uri = True
|
|
out.append(line)
|
|
continue
|
|
kind = _kind_for_tag(name, line, proxy_keys)
|
|
out.append(_URI_ATTR_RE.sub(
|
|
lambda m, k=kind: f'URI="{resolve(m.group(1), k)}"', line
|
|
))
|
|
continue
|
|
|
|
kind = HLSResource.PLAYLIST if expect_variant_uri else HLSResource.SEGMENT
|
|
expect_variant_uri = False
|
|
out.append(resolve(line, kind))
|
|
|
|
result = "\n".join(out)
|
|
return result + "\n" if content.endswith("\n") else result
|