mirror of https://github.com/streamlink/streamlink
stream.hls: refactor tag parser mapping
- Declaratively define tag parsers explicitly using a decorator instead of relying on the method name format - Build the tag-parser mapping when creating the parser class, and don't build it on the parser class when first instantiating it - Update parser subclasses (Twitch) - Update tests
This commit is contained in:
parent
9d5e2bfc17
commit
4631aeffed
|
@ -24,7 +24,17 @@ from streamlink.plugin import Plugin, pluginargument, pluginmatcher
|
|||
from streamlink.plugin.api import validate
|
||||
from streamlink.session import Streamlink
|
||||
from streamlink.stream.hls import HLSStream, HLSStreamReader, HLSStreamWorker, HLSStreamWriter
|
||||
from streamlink.stream.hls_playlist import M3U8, ByteRange, DateRange, ExtInf, Key, M3U8Parser, Map, load as load_hls_playlist
|
||||
from streamlink.stream.hls_playlist import (
|
||||
M3U8,
|
||||
ByteRange,
|
||||
DateRange,
|
||||
ExtInf,
|
||||
Key,
|
||||
M3U8Parser,
|
||||
Map,
|
||||
load as load_hls_playlist,
|
||||
parse_tag,
|
||||
)
|
||||
from streamlink.stream.http import HTTPStream
|
||||
from streamlink.utils.args import keyvalue
|
||||
from streamlink.utils.parse import parse_json, parse_qsd
|
||||
|
@ -68,6 +78,7 @@ class TwitchM3U8(M3U8):
|
|||
class TwitchM3U8Parser(M3U8Parser):
|
||||
m3u8: TwitchM3U8
|
||||
|
||||
@parse_tag("EXT-X-TWITCH-PREFETCH")
|
||||
def parse_tag_ext_x_twitch_prefetch(self, value):
|
||||
segments = self.m3u8.segments
|
||||
if not segments: # pragma: no cover
|
||||
|
@ -95,6 +106,7 @@ class TwitchM3U8Parser(M3U8Parser):
|
|||
)
|
||||
segments.append(segment)
|
||||
|
||||
@parse_tag("EXT-X-DATERANGE")
|
||||
def parse_tag_ext_x_daterange(self, value):
|
||||
super().parse_tag_ext_x_daterange(value)
|
||||
daterange = self.m3u8.dateranges[-1]
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import inspect
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
|
@ -13,6 +12,12 @@ from requests import Response
|
|||
from streamlink.logger import ALL, StreamlinkLogger
|
||||
|
||||
|
||||
try:
|
||||
from typing import Self # type: ignore[attr-defined]
|
||||
except ImportError: # pragma: no cover
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
log: StreamlinkLogger = logging.getLogger(__name__) # type: ignore[assignment]
|
||||
|
||||
|
||||
|
@ -154,8 +159,33 @@ class M3U8:
|
|||
return daterange.start_date <= date
|
||||
|
||||
|
||||
class M3U8Parser:
|
||||
_TAGS: ClassVar[Mapping[str, Callable[["M3U8Parser", str], None]]]
|
||||
_symbol_tag_parser = "__PARSE_TAG_NAME"
|
||||
|
||||
|
||||
def parse_tag(tag: str):
|
||||
def decorator(func: Callable[[str], None]) -> Callable[[str], None]:
|
||||
setattr(func, _symbol_tag_parser, tag)
|
||||
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class M3U8ParserMeta(type):
|
||||
def __init__(cls, name, bases, namespace, **kwargs):
|
||||
super().__init__(name, bases, namespace, **kwargs)
|
||||
|
||||
tags = dict(**getattr(cls, "_TAGS", {}))
|
||||
for member in namespace.values():
|
||||
tag = getattr(member, _symbol_tag_parser, None)
|
||||
if type(tag) is not str:
|
||||
continue
|
||||
tags[tag] = member
|
||||
cls._TAGS = tags
|
||||
|
||||
|
||||
class M3U8Parser(metaclass=M3U8ParserMeta):
|
||||
_TAGS: ClassVar[Mapping[str, Callable[[Self, str], None]]]
|
||||
|
||||
_extinf_re = re.compile(r"(?P<duration>\d+(\.\d+)?)(,(?P<title>.+))?")
|
||||
_attr_re = re.compile(r"""
|
||||
|
@ -186,20 +216,6 @@ class M3U8Parser:
|
|||
self.m3u8: M3U8 = m3u8(base_uri)
|
||||
self.state: Dict[str, Any] = {}
|
||||
|
||||
self._add_tag_callbacks()
|
||||
|
||||
def _add_tag_callbacks(self):
|
||||
# ignore previously generated tag-callback mapping on parent classes when initializing subclasses
|
||||
if "_TAGS" in self.__class__.__dict__:
|
||||
return
|
||||
tags = {}
|
||||
self.__class__._TAGS = tags
|
||||
for name, method in inspect.getmembers(self.__class__, inspect.isfunction):
|
||||
if not name.startswith("parse_tag_"):
|
||||
continue
|
||||
tag = name[10:].upper().replace("_", "-")
|
||||
tags[tag] = method
|
||||
|
||||
@classmethod
|
||||
def create_stream_info(cls, streaminf: Dict[str, Optional[str]], streaminfoclass=None):
|
||||
program_id = streaminf.get("PROGRAM-ID")
|
||||
|
@ -324,6 +340,7 @@ class M3U8Parser:
|
|||
|
||||
# 4.3.1: Basic Tags
|
||||
|
||||
@parse_tag("EXT-X-VERSION")
|
||||
def parse_tag_ext_x_version(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-VERSION
|
||||
|
@ -333,6 +350,7 @@ class M3U8Parser:
|
|||
|
||||
# 4.3.2: Media Segment Tags
|
||||
|
||||
@parse_tag("EXTINF")
|
||||
def parse_tag_extinf(self, value: str) -> None:
|
||||
"""
|
||||
EXTINF
|
||||
|
@ -341,6 +359,7 @@ class M3U8Parser:
|
|||
self.state["expect_segment"] = True
|
||||
self.state["extinf"] = self.parse_extinf(value)
|
||||
|
||||
@parse_tag("EXT-X-BYTERANGE")
|
||||
def parse_tag_ext_x_byterange(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-BYTERANGE
|
||||
|
@ -350,6 +369,7 @@ class M3U8Parser:
|
|||
self.state["byterange"] = self.parse_byterange(value)
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
@parse_tag("EXT-X-DISCONTINUITY")
|
||||
def parse_tag_ext_x_discontinuity(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-DISCONTINUITY
|
||||
|
@ -358,6 +378,7 @@ class M3U8Parser:
|
|||
self.state["discontinuity"] = True
|
||||
self.state["map"] = None
|
||||
|
||||
@parse_tag("EXT-X-KEY")
|
||||
def parse_tag_ext_x_key(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-KEY
|
||||
|
@ -376,6 +397,7 @@ class M3U8Parser:
|
|||
key_format_versions=attr.get("KEYFORMATVERSIONS"),
|
||||
)
|
||||
|
||||
@parse_tag("EXT-X-MAP")
|
||||
def parse_tag_ext_x_map(self, value: str) -> None: # version >= 5
|
||||
"""
|
||||
EXT-X-MAP
|
||||
|
@ -391,6 +413,7 @@ class M3U8Parser:
|
|||
byterange=byterange,
|
||||
)
|
||||
|
||||
@parse_tag("EXT-X-PROGRAM-DATE-TIME")
|
||||
def parse_tag_ext_x_program_date_time(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-PROGRAM-DATE-TIME
|
||||
|
@ -398,6 +421,7 @@ class M3U8Parser:
|
|||
"""
|
||||
self.state["date"] = self.parse_iso8601(value)
|
||||
|
||||
@parse_tag("EXT-X-DATERANGE")
|
||||
def parse_tag_ext_x_daterange(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-DATERANGE
|
||||
|
@ -418,6 +442,7 @@ class M3U8Parser:
|
|||
|
||||
# 4.3.3: Media Playlist Tags
|
||||
|
||||
@parse_tag("EXT-X-TARGETDURATION")
|
||||
def parse_tag_ext_x_targetduration(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-TARGETDURATION
|
||||
|
@ -425,6 +450,7 @@ class M3U8Parser:
|
|||
"""
|
||||
self.m3u8.targetduration = float(value)
|
||||
|
||||
@parse_tag("EXT-X-MEDIA-SEQUENCE")
|
||||
def parse_tag_ext_x_media_sequence(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-MEDIA-SEQUENCE
|
||||
|
@ -432,6 +458,7 @@ class M3U8Parser:
|
|||
"""
|
||||
self.m3u8.media_sequence = int(value)
|
||||
|
||||
@parse_tag("EXT-X-DISCONTINUTY-SEQUENCE")
|
||||
def parse_tag_ext_x_discontinuity_sequence(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-DISCONTINUITY-SEQUENCE
|
||||
|
@ -440,6 +467,7 @@ class M3U8Parser:
|
|||
self.m3u8.discontinuity_sequence = int(value)
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
@parse_tag("EXT-X-ENDLIST")
|
||||
def parse_tag_ext_x_endlist(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-ENDLIST
|
||||
|
@ -447,6 +475,7 @@ class M3U8Parser:
|
|||
"""
|
||||
self.m3u8.is_endlist = True
|
||||
|
||||
@parse_tag("EXT-X-PLAYLIST-TYPE")
|
||||
def parse_tag_ext_x_playlist_type(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-PLAYLISTTYPE
|
||||
|
@ -455,6 +484,7 @@ class M3U8Parser:
|
|||
self.m3u8.playlist_type = value
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
@parse_tag("EXT-X-I-FRAMES-ONLY")
|
||||
def parse_tag_ext_x_i_frames_only(self, value: str) -> None: # version >= 4
|
||||
"""
|
||||
EXT-X-I-FRAMES-ONLY
|
||||
|
@ -464,6 +494,7 @@ class M3U8Parser:
|
|||
|
||||
# 4.3.4: Master Playlist Tags
|
||||
|
||||
@parse_tag("EXT-X-MEDIA")
|
||||
def parse_tag_ext_x_media(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-MEDIA
|
||||
|
@ -489,6 +520,7 @@ class M3U8Parser:
|
|||
)
|
||||
self.m3u8.media.append(media)
|
||||
|
||||
@parse_tag("EXT-X-STREAM-INF")
|
||||
def parse_tag_ext_x_stream_inf(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-STREAM-INF
|
||||
|
@ -497,6 +529,7 @@ class M3U8Parser:
|
|||
self.state["streaminf"] = self.parse_attributes(value)
|
||||
self.state["expect_playlist"] = True
|
||||
|
||||
@parse_tag("EXT-X-I-FRAME-STREAM-INF")
|
||||
def parse_tag_ext_x_i_frame_stream_inf(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-I-FRAME-STREAM-INF
|
||||
|
@ -516,12 +549,14 @@ class M3U8Parser:
|
|||
)
|
||||
self.m3u8.playlists.append(playlist)
|
||||
|
||||
@parse_tag("EXT-X-SESSION-DATA")
|
||||
def parse_tag_ext_x_session_data(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-SESSION-DATA
|
||||
https://datatracker.ietf.org/doc/html/rfc8216#section-4.3.4.4
|
||||
"""
|
||||
|
||||
@parse_tag("EXT-X-SESSION-KEY")
|
||||
def parse_tag_ext_x_session_key(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-SESSION-KEY
|
||||
|
@ -530,12 +565,14 @@ class M3U8Parser:
|
|||
|
||||
# 4.3.5: Media or Master Playlist Tags
|
||||
|
||||
@parse_tag("EXT-X-INDEPENDENT-SEGMENTS")
|
||||
def parse_tag_ext_x_independent_segments(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-INDEPENDENT-SEGMENTS
|
||||
https://datatracker.ietf.org/doc/html/rfc8216#section-4.3.5.1
|
||||
"""
|
||||
|
||||
@parse_tag("EXT-X-START")
|
||||
def parse_tag_ext_x_start(self, value: str) -> None:
|
||||
"""
|
||||
EXT-X-START
|
||||
|
@ -550,6 +587,7 @@ class M3U8Parser:
|
|||
# Removed tags
|
||||
# https://datatracker.ietf.org/doc/html/rfc8216#section-7
|
||||
|
||||
@parse_tag("EXT-X-ALLOW-CACHE")
|
||||
def parse_tag_ext_x_allow_cache(self, value: str) -> None: # version < 7
|
||||
self.m3u8.allow_cache = self.parse_bool(value)
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ from streamlink.stream.hls_playlist import (
|
|||
Segment,
|
||||
StreamInfo,
|
||||
load,
|
||||
parse_tag,
|
||||
)
|
||||
from tests.resources import text
|
||||
|
||||
|
@ -20,26 +21,37 @@ from tests.resources import text
|
|||
UTC = timezone.utc
|
||||
|
||||
|
||||
def test_parse_tag_callback_cache():
|
||||
def test_parse_tag_mapping():
|
||||
class M3U8ParserSubclass(M3U8Parser):
|
||||
@parse_tag("EXT-X-VERSION")
|
||||
def parse_tag_ext_x_version(self): # pragma: no cover
|
||||
pass
|
||||
|
||||
@parse_tag("FOO-BAR")
|
||||
def parse_tag_foo_bar(self): # pragma: no cover
|
||||
pass
|
||||
|
||||
parent = M3U8Parser()
|
||||
assert hasattr(parent, "_TAGS")
|
||||
assert "EXT-X-VERSION" in parent._TAGS
|
||||
assert parent._TAGS["EXTINF"] is M3U8Parser.parse_tag_extinf
|
||||
assert parent._TAGS["EXT-X-VERSION"] is M3U8Parser.parse_tag_ext_x_version
|
||||
assert "FOO-BAR" not in parent._TAGS
|
||||
|
||||
childA = M3U8ParserSubclass()
|
||||
assert hasattr(childA, "_TAGS")
|
||||
assert "FOO-BAR" in childA._TAGS
|
||||
assert childA._TAGS["EXTINF"] is M3U8Parser.parse_tag_extinf
|
||||
assert childA._TAGS["EXT-X-VERSION"] is M3U8ParserSubclass.parse_tag_ext_x_version
|
||||
assert childA._TAGS["FOO-BAR"] is M3U8ParserSubclass.parse_tag_foo_bar
|
||||
|
||||
assert parent._TAGS is not childA._TAGS
|
||||
|
||||
childB = M3U8ParserSubclass()
|
||||
assert hasattr(childB, "_TAGS")
|
||||
assert "FOO-BAR" in childB._TAGS
|
||||
|
||||
assert parent._TAGS is not childA._TAGS
|
||||
assert childA._TAGS is childB._TAGS
|
||||
|
||||
assert parent._TAGS["EXT-X-VERSION"].__doc__ is not None
|
||||
assert childA._TAGS["EXT-X-VERSION"].__doc__ is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("string", "expected"), [
|
||||
("", (None, None)),
|
||||
|
|
Loading…
Reference in New Issue