Repository: streamlink/streamlink

Issue:
Refactor segmented streams and use a common BaseSegment
While talking about the extensibility and lack of full typing information of the segmented stream implementations in the past, I already mentioned that the base implementations of segmented streams, as well as HLS and DASH are in need of a huge code refactoring.

Now with my attempt at turning the `--hls-duration` and `--hls-start-offset` options into the generic `--stream-segmented-duration` and `--stream-segmented-start-offset` options, it became clear to me that it didn't make sense finishing that work with the current code.

See #5450 and https://github.com/streamlink/streamlink/pull/5493#issuecomment-1677680058.

----

The issue I'm intending to solve, ignoring all other flaws for now (there are a lot), is making both HLS, DASH and other subclasses of the segmented stream base classes share more common logic in regards to how segments are handled. I also want to fix the lack of typing information, so working with and understanding the segmented stream implementations is much easier.

Currently, each segmented stream implementation does its own thing with its segments, so implementing common logic for calculating a max stream duration or a stream start offset for example is impossible without writing lots of duplicate code with only small variations. This is also the reason why adding proper typing information can't be done. Writing logic for the same thing in each segmented stream type just doesn't make sense.

----

The big outlier here is HLS, where segments are wrapped in a `Sequence` named tuple. This is due to historic reasons when the segmented streams were initially implemented. Segments of each stream type were defined as named tuples which don't support subclassing or changing values after instantiation, so in order for the HLS stream's M3U8 parser to work properly, the generated segments needed to be wrapped, so a sequence number could be added after parsing the playlist as a whole (`EXT-X-MEDIA-SEQUENCE`).

While refactoring/rewriting the DASH implementation earlier this year, I already replaced its segments with ones that are based on the stdlib's `dataclasses`. Those fix all the issues that come with named tuples, but `dataclasses` come at the cost of a slight performance hit compared to named tuples. That is negligible though and is not important. It doesn't matter for Streamlink.

With dataclass segments, a `BaseSegment` type can be created which HLS, DASH and other can inherit from. The base `SegmentedStream{Worker,Writer,Reader}` classes can then properly handle the `BaseSegment` objects, and common base logic for max stream durations and start offsets could be added. Then fixing the lack of typing information would be much easier, too.

----

While this would affect huge parts of the base segmented stream stuff, as well as HLS and DASH, I don't think that there are any breaking changes involved. Public interfaces have never been declared on the stream implementations other than the main classes' constructors and classmethods like `HLSStream.parse_variant_stream()` and `DASHStream.parse_manifest()`. Those won't be touched.

Anything internal, like certain attribute names and methods, regardless whether they have docstrings attached or their names not starting with an underscore character are not part of the public interface, even if some plugins create stream subclasses and override them.

That's why I won't feel bad if any changes being made will break 3rd party plugins.

----

My initial improvised plan is this:

- [x] Refactor `stream.segmented`
  - [x] Change how the segments get put into and read from the queue (#5504)
  - [x] Add typing, with generic classes which define the segment type and segment data return type (#5507)
- [x] Refactor `stream.hls` and `stream.hls_playlist` (#5526)
  - Turn `Segment` and `Playlist` from named tuples into dataclasses
  - Move segment number generation from `HLSStreamWorker` to the `M3U8` parser
  - Replace the `Sequence` named tuple wrapper in HLS streams with a flat `Segment` dataclass type
  - Rename internal `HLSStream{Writer,Worker}` attributes and methods (including args+keywords)
  - Refactor M3U8 playlist loading
  - Fix / add more typing annotations
- [x] Refactor `stream.dash` and `stream.dash_manifest`
  - Fix/update all the worker/writer/reader parts
  - Fix / add more typing
- [x] Refactor / fix plugins
  - [x] `plugins.twitch` (#5526)
  - [x] `plugins.ustreamtv`
  - [x] other plugins with HLS stream subclasses (#5526)
- [x] Switch to sub-packages for `stream.segmented`, `stream.hls` and `stream.dash`
- [x] Rename HLS `Segment` to `HLSSegment` and DASH `Segment` to `DASHSegment`
- [x] Add base `Segment` with common attributes (`uri`, `num`, `duration`)
  - [x] Make `{HLS,DASH,UStreamTV}Segment` inherit from it and bind `Segment` to `TSegment_co`
- [ ] Add common logic for segmented stream max duration and start offset

Discussion:

Patch:
diff --git a/src/streamlink/stream/dash.py b/src/streamlink/stream/dash.py
index 725d94e9..23cc09a2 100644
--- a/src/streamlink/stream/dash.py
+++ b/src/streamlink/stream/dash.py
@@ -26,8 +26,8 @@ class DASHStreamWriter(SegmentedStreamWriter):
     reader: "DASHStreamReader"
     stream: "DASHStream"
 
-    def fetch(self, segment: Segment, retries: Optional[int] = None):
-        if self.closed or not retries:
+    def fetch(self, segment: Segment):
+        if self.closed:
             return
 
         name = segment.name
@@ -53,7 +53,7 @@ class DASHStreamWriter(SegmentedStreamWriter):
                 timeout=self.timeout,
                 exception=StreamError,
                 headers=headers,
-                retries=retries,
+                retries=self.retries,
                 **request_args,
             )
         except StreamError as err:
@@ -175,10 +175,8 @@ class DASHStreamReader(SegmentedStreamReader):
         stream: "DASHStream",
         representation: Representation,
         timestamp: datetime,
-        *args,
-        **kwargs,
     ):
-        super().__init__(stream, *args, **kwargs)
+        super().__init__(stream)
         self.ident = representation.ident
         self.mime_type = representation.mimeType
         self.timestamp = timestamp
diff --git a/src/streamlink/stream/segmented.py b/src/streamlink/stream/segmented.py
index 2261fb35..49b49dc8 100644
--- a/src/streamlink/stream/segmented.py
+++ b/src/streamlink/stream/segmented.py
@@ -50,59 +50,10 @@ class AwaitableMixin:
         return not self._wait.wait(time)
 
 
-class SegmentedStreamWorker(AwaitableMixin, Thread):
-    """The general worker thread.
-
-    This thread is responsible for queueing up segments in the
-    writer thread.
-    """
-
-    reader: "SegmentedStreamReader"
-    writer: "SegmentedStreamWriter"
-    stream: "Stream"
-
-    def __init__(self, reader: "SegmentedStreamReader", **kwargs):
-        super().__init__(daemon=True, name=f"Thread-{self.__class__.__name__}")
-        self.closed = False
-        self.reader = reader
-        self.writer = reader.writer
-        self.stream = reader.stream
-        self.session = reader.session
-
-    def close(self):
-        """Shuts down the thread."""
-        if self.closed:  # pragma: no cover
-            return
-
-        log.debug("Closing worker thread")
-
-        self.closed = True
-        self._wait.set()
-
-    def iter_segments(self):
-        """The iterator that generates segments for the worker thread.
-
-        Should be overridden by the inheriting class.
-        """
-        return
-        yield
-
-    def run(self):
-        for segment in self.iter_segments():
-            if self.closed:  # pragma: no cover
-                break
-            self.writer.put(segment)
-
-        # End of stream, tells the writer to exit
-        self.writer.put(None)
-        self.close()
-
-
 class SegmentedStreamWriter(AwaitableMixin, Thread):
-    """The writer thread.
-
-    This thread is responsible for fetching segments, processing them
-    and finally writing the data to the buffer.
+    """
+    The base writer thread.
+    This thread is responsible for fetching segments, processing them and finally writing the data to the buffer.
     """
 
     reader: "SegmentedStreamReader"
@@ -110,28 +61,25 @@ class SegmentedStreamWriter(AwaitableMixin, Thread):
 
     def __init__(self, reader: "SegmentedStreamReader", size=20, retries=None, threads=None, timeout=None):
         super().__init__(daemon=True, name=f"Thread-{self.__class__.__name__}")
+
         self.closed = False
+
         self.reader = reader
         self.stream = reader.stream
         self.session = reader.session
 
-        if not retries:
-            retries = self.session.options.get("stream-segment-attempts")
+        self.retries = retries or self.session.options.get("stream-segment-attempts")
+        self.threads = threads or self.session.options.get("stream-segment-threads")
+        self.timeout = timeout or self.session.options.get("stream-segment-timeout")
 
-        if not threads:
-            threads = self.session.options.get("stream-segment-threads")
-
-        if not timeout:
-            timeout = self.session.options.get("stream-segment-timeout")
-
-        self.retries = retries
-        self.timeout = timeout
-        self.threads = threads
         self.executor = CompatThreadPoolExecutor(max_workers=self.threads)
-        self.futures: queue.Queue[Future] = queue.Queue(size)
+        self._queue: queue.Queue[Future] = queue.Queue(size)
 
     def close(self):
-        """Shuts down the thread, its executor and closes the reader (worker thread and buffer)."""
+        """
+        Shuts down the thread, its executor and closes the reader (worker thread and buffer).
+        """
+
         if self.closed:  # pragma: no cover
             return
 
@@ -144,59 +92,67 @@ class SegmentedStreamWriter(AwaitableMixin, Thread):
         self.executor.shutdown(wait=True, cancel_futures=True)
 
     def put(self, segment):
-        """Adds a segment to the download pool and write queue."""
+        """
+        Adds a segment to the download pool and write queue.
+        """
+
         if self.closed:  # pragma: no cover
             return
 
         if segment is None:
             future = None
         else:
-            future = self.executor.submit(self.fetch, segment, retries=self.retries)
+            future = self.executor.submit(self.fetch, segment)
 
         self.queue(segment, future)
 
     def queue(self, segment: Any, future: Optional[Future], *data):
-        """Puts values into a queue but aborts if this thread is closed."""
+        """
+        Puts values into a queue but aborts if this thread is closed.
+        """
+
+        item = None if segment is None or future is None else (segment, future, data)
         while not self.closed:  # pragma: no branch
             try:
-                self._futures_put((segment, future, *data))
+                self._queue_put(item)
                 return
             except queue.Full:  # pragma: no cover
                 continue
 
-    def _futures_put(self, item):
-        self.futures.put(item, block=True, timeout=1)
+    def _queue_put(self, item):
+        self._queue.put(item, block=True, timeout=1)
 
-    def _futures_get(self):
-        return self.futures.get(block=True, timeout=0.5)
+    def _queue_get(self):
+        return self._queue.get(block=True, timeout=0.5)
 
     @staticmethod
     def _future_result(future: Future):
         return future.result(timeout=0.5)
 
     def fetch(self, segment):
-        """Fetches a segment.
-
+        """
+        Fetches a segment.
         Should be overridden by the inheriting class.
         """
 
     def write(self, segment, result, *data):
-        """Writes a segment to the buffer.
-
+        """
+        Writes a segment to the buffer.
         Should be overridden by the inheriting class.
         """
 
     def run(self):
         while not self.closed:
             try:
-                segment, future, *data = self._futures_get()
+                item = self._queue_get()
             except queue.Empty:  # pragma: no cover
                 continue
 
             # End of stream
-            if future is None:
+            if item is None:
                 break
 
+            segment, future, data = item
             while not self.closed:  # pragma: no branch
                 try:
                     result = self._future_result(future)
@@ -213,6 +169,60 @@ class SegmentedStreamWriter(AwaitableMixin, Thread):
         self.close()
 
 
+class SegmentedStreamWorker(AwaitableMixin, Thread):
+    """
+    The base worker thread.
+    This thread is responsible for queueing up segments in the writer thread.
+    """
+
+    reader: "SegmentedStreamReader"
+    writer: "SegmentedStreamWriter"
+    stream: "Stream"
+
+    def __init__(self, reader: "SegmentedStreamReader", **kwargs):
+        super().__init__(daemon=True, name=f"Thread-{self.__class__.__name__}")
+
+        self.closed = False
+
+        self.reader = reader
+        self.writer = reader.writer
+        self.stream = reader.stream
+        self.session = reader.session
+
+    def close(self):
+        """
+        Shuts down the thread.
+        """
+
+        if self.closed:  # pragma: no cover
+            return
+
+        log.debug("Closing worker thread")
+
+        self.closed = True
+        self._wait.set()
+
+    def iter_segments(self):
+        """
+        The iterator that generates segments for the worker thread.
+        Should be overridden by the inheriting class.
+        """
+
+        return
+        # noinspection PyUnreachableCode
+        yield
+
+    def run(self):
+        for segment in self.iter_segments():
+            if self.closed:  # pragma: no cover
+                break
+            self.writer.put(segment)
+
+        # End of stream, tells the writer to exit
+        self.writer.put(None)
+        self.close()
+
+
 class SegmentedStreamReader(StreamIO):
     __worker__ = SegmentedStreamWorker
     __writer__ = SegmentedStreamWriter
@@ -221,14 +231,17 @@ class SegmentedStreamReader(StreamIO):
     writer: "SegmentedStreamWriter"
     stream: "Stream"
 
-    def __init__(self, stream: "Stream", timeout=None):
+    def __init__(self, stream: "Stream"):
         super().__init__()
+
         self.stream = stream
         self.session = stream.session
-        self.timeout = timeout or self.session.options.get("stream-timeout")
+
+        self.timeout = self.session.options.get("stream-timeout")
 
         buffer_size = self.session.get_option("ringbuffer-size")
         self.buffer = RingBuffer(buffer_size)
+
         self.writer = self.__writer__(self)
         self.worker = self.__worker__(self)
 


Test patch:
diff --git a/tests/mixins/stream_hls.py b/tests/mixins/stream_hls.py
index 430e4889..ff6a9308 100644
--- a/tests/mixins/stream_hls.py
+++ b/tests/mixins/stream_hls.py
@@ -114,11 +114,11 @@ class EventedHLSStreamWriter(_HLSStreamWriter):
         super().__init__(*args, **kwargs)
         self.handshake = Handshake()
 
-    def _futures_put(self, item):
-        self.futures.put_nowait(item)
+    def _queue_put(self, item):
+        self._queue.put_nowait(item)
 
-    def _futures_get(self):
-        return self.futures.get_nowait()
+    def _queue_get(self):
+        return self._queue.get_nowait()
 
     @staticmethod
     def _future_result(future):
diff --git a/tests/stream/test_hls.py b/tests/stream/test_hls.py
index 0550af6e..7036397c 100644
--- a/tests/stream/test_hls.py
+++ b/tests/stream/test_hls.py
@@ -774,11 +774,11 @@ class TestHlsPlaylistReloadTime(TestMixinStreamHLS, unittest.TestCase):
             return orig_playlist_reload_time(*args, **kwargs)
 
         # immediately kill the writer thread as we don't need it and don't want to wait for its queue polling to end
-        def mocked_futures_get():
-            return None, None
+        def mocked_queue_get():
+            return None
 
         with patch.object(thread.reader.worker, "_playlist_reload_time", side_effect=mocked_playlist_reload_time), \
-             patch.object(thread.reader.writer, "_futures_get", side_effect=mocked_futures_get):
+             patch.object(thread.reader.writer, "_queue_get", side_effect=mocked_queue_get):
             self.start()
 
             if not playlist_reload_time_called.wait(timeout=5):  # pragma: no cover