Repository: streamlink/streamlink

Issue:
stream.hls: smarter playlist reload times
The `HLSStreamWorker` is currently reloading playlists of live streams in a strict, continuous time interval. Depending on the options used ([`--hls-playlist-reload-time`](https://streamlink.github.io/cli.html#cmdoption-hls-playlist-reload-time)), the reload time is either the playlist's `targetduration` (default), or the duration of the last segment, or the sum of segment durations according to the `live-edge`, or a constant time value. Having strict and continuous playlist reload wait times causes a couple of problems.

The issue is that [HTTP request/response delays (including retries)](https://github.com/streamlink/streamlink/blob/4.2.0/src/streamlink/stream/hls.py#L283-L292), [execution delays](https://github.com/streamlink/streamlink/blob/4.2.0/src/streamlink/stream/hls.py#L383-L429) and [buffer availability wait times](https://github.com/streamlink/streamlink/blob/4.2.0/src/streamlink/stream/hls.py#L302) are [not taken into account when reloading](https://github.com/streamlink/streamlink/blob/4.2.0/src/streamlink/stream/hls.py#L425), so that over time, playlist reloads will get out-of-sync compared to the playlist's segment durations as more and more delay is added between reloads, and the point where the playlist gets reloaded gets shifted.

For most streams, this is no problem, but for low latency streams, where playlist reloads are supposed to only queue the next segment (as soon as possible), shifting the reload points is a problem because the writer's queue can become empty at some point and then it'll get filled with two segments on the following reload, which will unnecessarily introduce a streaming delay.

----

The reload times of the [`DASHStreamWorker`](https://github.com/streamlink/streamlink/blob/4.2.0/src/streamlink/stream/dash.py#L78-L87) already take execution times into consideration. The intention here is of course that [DASH segments can have an availability time](https://github.com/streamlink/streamlink/blob/4.2.0/src/streamlink/stream/dash.py#L36-L40), so being precise is important.

HLS streams however also can have segment time annotations via [`EXT-X-PROGRAM-DATE-TIME`](https://github.com/streamlink/streamlink/blob/4.2.0/src/streamlink/stream/hls_playlist.py#L355-L360). The problem though is that it's an optional tag and the time doesn't necessarily have to be precise. And since the server time is unknown and can only get determined by reading optional HTTP headers or custom playlist tags, calculating an offset to the system time is often impossible. If the time offsets of the segment times could be calculated reliably, then HLS playlist reload times could be calculated so that reloads happen as soon as possible when the next segment becomes available on the server. That would be a micro-optimization, albeit not an important one, as HLS low latency streams have prefetch segments (AHLS (draft) or custom ones (eg. Twitch)).


Discussion:

Resolving patch:
diff --git a/src/streamlink/stream/hls.py b/src/streamlink/stream/hls.py
--- a/src/streamlink/stream/hls.py
+++ b/src/streamlink/stream/hls.py
@@ -2,6 +2,7 @@
 import re
 import struct
 from concurrent.futures import Future
+from datetime import datetime, timedelta
 from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union
 from urllib.parse import urlparse
 
@@ -22,6 +23,7 @@
 from streamlink.stream.segmented import SegmentedStreamReader, SegmentedStreamWorker, SegmentedStreamWriter
 from streamlink.utils.cache import LRUCache
 from streamlink.utils.formatter import Formatter
+from streamlink.utils.times import now
 
 
 log = logging.getLogger(__name__)
@@ -297,6 +299,7 @@ def __init__(self, *args, **kwargs) -> None:
         self.playlist_end: Optional[int] = None
         self.playlist_sequence: int = -1
         self.playlist_sequences: List[Sequence] = []
+        self.playlist_reload_last: datetime = now()
         self.playlist_reload_time: float = 6
         self.playlist_reload_time_override = self.session.options.get("hls-playlist-reload-time")
         self.playlist_reload_retries = self.session.options.get("hls-playlist-reload-attempts")
@@ -412,6 +415,7 @@ def duration_to_sequence(duration: float, sequences: List[Sequence]) -> int:
         return default
 
     def iter_segments(self):
+        self.playlist_reload_last = now()
         try:
             self.reload_playlist()
         except StreamError as err:
@@ -457,7 +461,20 @@ def iter_segments(self):
 
                 self.playlist_sequence = sequence.num + 1
 
-            if self.wait(self.playlist_reload_time):
+            # Exclude playlist fetch+processing time from the overall playlist reload time
+            # and reload playlist in a strict time interval
+            time_completed = now()
+            time_elapsed = max(0.0, (time_completed - self.playlist_reload_last).total_seconds())
+            time_wait = max(0.0, self.playlist_reload_time - time_elapsed)
+            if self.wait(time_wait):
+                if time_wait > 0:
+                    # If we had to wait, then don't call now() twice and instead reference the timestamp from before
+                    # the wait() call, to prevent a shifting time offset due to the execution time.
+                    self.playlist_reload_last = time_completed + timedelta(seconds=time_wait)
+                else:
+                    # Otherwise, get the current time, as the reload interval already has shifted.
+                    self.playlist_reload_last = now()
+
                 try:
                     self.reload_playlist()
                 except StreamError as err:


Test patch:
diff --git a/tests/mixins/stream_hls.py b/tests/mixins/stream_hls.py
--- a/tests/mixins/stream_hls.py
+++ b/tests/mixins/stream_hls.py
@@ -8,13 +8,15 @@
 import requests_mock
 
 from streamlink import Streamlink
-from streamlink.stream.hls import HLSStream, HLSStreamWriter as _HLSStreamWriter
+from streamlink.stream.hls import HLSStream, HLSStreamWorker as _HLSStreamWorker, HLSStreamWriter as _HLSStreamWriter
 from tests.testutils.handshake import Handshake
 
 
 TIMEOUT_AWAIT_READ = 5
 TIMEOUT_AWAIT_READ_ONCE = 5
 TIMEOUT_AWAIT_WRITE = 60  # https://github.com/streamlink/streamlink/issues/3868
+TIMEOUT_AWAIT_PLAYLIST_RELOAD = 5
+TIMEOUT_AWAIT_PLAYLIST_WAIT = 5
 TIMEOUT_AWAIT_CLOSE = 5
 
 
@@ -90,6 +92,23 @@ def build(self, namespace):
         )
 
 
+class EventedHLSStreamWorker(_HLSStreamWorker):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.handshake_reload = Handshake()
+        self.handshake_wait = Handshake()
+        self.time_wait = None
+
+    def reload_playlist(self):
+        with self.handshake_reload():
+            return super().reload_playlist()
+
+    def wait(self, time):
+        self.time_wait = time
+        with self.handshake_wait():
+            return not self.closed
+
+
 class EventedHLSStreamWriter(_HLSStreamWriter):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -230,6 +249,16 @@ def await_close(self, timeout=TIMEOUT_AWAIT_CLOSE):
         thread.join(timeout)
         assert self.thread.reader.closed, "Stream reader is closed"
 
+    def await_playlist_reload(self, timeout=TIMEOUT_AWAIT_PLAYLIST_RELOAD) -> None:
+        worker: EventedHLSStreamWorker = self.thread.reader.worker  # type: ignore[assignment]
+        assert worker.is_alive()
+        assert worker.handshake_reload.step(timeout)
+
+    def await_playlist_wait(self, timeout=TIMEOUT_AWAIT_PLAYLIST_WAIT) -> None:
+        worker: EventedHLSStreamWorker = self.thread.reader.worker  # type: ignore[assignment]
+        assert worker.is_alive()
+        assert worker.handshake_wait.step(timeout)
+
     # make write calls on the write-thread and wait until it has finished
     def await_write(self, write_calls=1, timeout=TIMEOUT_AWAIT_WRITE) -> None:
         writer: EventedHLSStreamWriter = self.thread.reader.writer  # type: ignore[assignment]
diff --git a/tests/stream/test_hls.py b/tests/stream/test_hls.py
--- a/tests/stream/test_hls.py
+++ b/tests/stream/test_hls.py
@@ -1,9 +1,11 @@
 import os
 import typing
 import unittest
+from datetime import datetime, timedelta, timezone
 from threading import Event
 from unittest.mock import Mock, call, patch
 
+import freezegun
 import pytest
 import requests_mock
 from Crypto.Cipher import AES
@@ -13,10 +15,14 @@
 from streamlink.session import Streamlink
 from streamlink.stream.hls import HLSStream, HLSStreamReader, MuxedHLSStream
 from streamlink.stream.hls_playlist import M3U8Parser
-from tests.mixins.stream_hls import EventedHLSStreamWriter, Playlist, Segment, Tag, TestMixinStreamHLS
+from tests.mixins.stream_hls import EventedHLSStreamWorker, EventedHLSStreamWriter, Playlist, Segment, Tag, TestMixinStreamHLS
 from tests.resources import text
 
 
+EPOCH = datetime(2000, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)
+ONE_SECOND = timedelta(seconds=1.0)
+
+
 class EncryptedBase:
     content: bytes
     content_plain: bytes
@@ -118,12 +124,20 @@ def test_url_master(self):
         assert stream.url_master == "http://mocked/master.m3u8"
 
 
-class EventedHLSReader(HLSStreamReader):
+class EventedWorkerHLSStreamReader(HLSStreamReader):
+    __worker__ = EventedHLSStreamWorker
+
+
+class EventedWriterHLSStreamReader(HLSStreamReader):
     __writer__ = EventedHLSStreamWriter
 
 
-class EventedHLSStream(HLSStream):
-    __reader__ = EventedHLSReader
+class EventedWorkerHLSStream(HLSStream):
+    __reader__ = EventedWorkerHLSStreamReader
+
+
+class EventedWriterHLSStream(HLSStream):
+    __reader__ = EventedWriterHLSStreamReader
 
 
 @patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
@@ -173,9 +187,113 @@ def test_map(self):
         assert self.called(map2, once=True), "Downloads second map only once"
 
 
+class TestHLSStreamWorker(TestMixinStreamHLS, unittest.TestCase):
+    __stream__ = EventedWorkerHLSStream
+
+    OPTIONS = {"stream-timeout": 1}
+
+    def tearDown(self) -> None:
+        worker: EventedHLSStreamWorker = self.thread.reader.worker  # type: ignore[assignment]
+        # don't await the handshakes on error
+        worker.handshake_wait.go()
+        worker.handshake_reload.go()
+        return super().tearDown()
+
+    def get_session(self, options=None, *args, **kwargs):
+        return super().get_session({**self.OPTIONS, **(options or {})}, *args, **kwargs)
+
+    def test_playlist_reload_offset(self) -> None:
+        thread, segments = self.subject(
+            start=False,
+            playlists=[
+                Playlist(0, targetduration=5, segments=[Segment(0)]),
+                Playlist(1, targetduration=5, segments=[Segment(1)]),
+                Playlist(2, targetduration=5, segments=[Segment(2)]),
+                Playlist(3, targetduration=5, segments=[Segment(3)]),
+                Playlist(4, targetduration=5, segments=[Segment(4)], end=True),
+            ],
+        )
+        worker: EventedHLSStreamWorker = thread.reader.worker
+        targetduration = ONE_SECOND * 5
+
+        with freezegun.freeze_time(EPOCH) as frozen_time:
+            self.start()
+
+            assert worker.handshake_reload.wait_ready(1), "Arrives at initial playlist reload"
+            assert worker.playlist_reload_last == EPOCH, "Sets the initial value of the last reload time"
+
+            # adjust clock and reload playlist: let it take one second
+            frozen_time.move_to(worker.playlist_reload_last + ONE_SECOND)
+            self.await_playlist_reload()
+            assert worker.playlist_reload_time == 5.0, "Uses the playlist's targetduration as reload time"
+
+            # time_completed = 00:00:01; time_elapsed = 1s
+            assert worker.handshake_wait.wait_ready(1), "Arrives at first wait() call"
+            assert worker.playlist_sequence == 1, "Has queued first segment"
+            assert worker.time_wait == 4.0, "Waits for 4 seconds out of the 5 seconds reload time"
+            self.await_playlist_wait()
+
+            assert worker.handshake_reload.wait_ready(1), "Arrives at second playlist reload"
+            assert worker.playlist_reload_last == EPOCH + targetduration, \
+                "Last reload time is the sum of reload+wait time (=targetduration)"
+
+            # adjust clock and reload playlist: let it exceed targetduration by two seconds
+            frozen_time.move_to(worker.playlist_reload_last + targetduration + ONE_SECOND * 2)
+            self.await_playlist_reload()
+            assert worker.playlist_reload_time == 5.0, "Uses the playlist's targetduration as reload time"
+
+            # time_completed = 00:00:12; time_elapsed = 7s (exceeded 5s targetduration)
+            assert worker.handshake_wait.wait_ready(1), "Arrives at second wait() call"
+            assert worker.playlist_sequence == 2, "Has queued second segment"
+            assert worker.time_wait == 0.0, "Doesn't wait when reloading took too long"
+            self.await_playlist_wait()
+
+            assert worker.handshake_reload.wait_ready(1), "Arrives at third playlist reload"
+            assert worker.playlist_reload_last == EPOCH + targetduration * 2 + ONE_SECOND * 2, \
+                "Sets last reload time to current time when reloading took too long (changes the interval)"
+
+            # adjust clock and reload playlist: let it take one second again
+            frozen_time.move_to(worker.playlist_reload_last + ONE_SECOND)
+            self.await_playlist_reload()
+            assert worker.playlist_reload_time == 5.0, "Uses the playlist's targetduration as reload time"
+
+            # time_completed = 00:00:13; time_elapsed = 1s
+            assert worker.handshake_wait.wait_ready(1), "Arrives at third wait() call"
+            assert worker.playlist_sequence == 3, "Has queued third segment"
+            assert worker.time_wait == 4.0, "Waits for 4 seconds out of the 5 seconds reload time"
+            self.await_playlist_wait()
+
+            assert worker.handshake_reload.wait_ready(1), "Arrives at fourth playlist reload"
+            assert worker.playlist_reload_last == EPOCH + targetduration * 3 + ONE_SECOND * 2, \
+                "Last reload time is the sum of reload+wait time (=targetduration) of the changed interval"
+
+            # adjust clock and reload playlist: simulate no fetch+processing delay
+            frozen_time.move_to(worker.playlist_reload_last)
+            self.await_playlist_reload()
+            assert worker.playlist_reload_time == 5.0, "Uses the playlist's targetduration as reload time"
+
+            # time_completed = 00:00:17; time_elapsed = 0s
+            assert worker.handshake_wait.wait_ready(1), "Arrives at fourth wait() call"
+            assert worker.playlist_sequence == 4, "Has queued fourth segment"
+            assert worker.time_wait == 5.0, "Waits for the whole reload time"
+            self.await_playlist_wait()
+
+            assert worker.handshake_reload.wait_ready(1), "Arrives at fifth playlist reload"
+            assert worker.playlist_reload_last == EPOCH + targetduration * 4 + ONE_SECOND * 2, \
+                "Last reload time is the sum of reload+wait time (no delay)"
+
+            # adjusting the clock is not needed anymore
+            self.await_playlist_reload()
+            assert self.await_read(read_all=True) == self.content(segments)
+            self.await_close()
+            assert worker.playlist_sequence == 4, "Doesn't update sequence number once ended"
+            assert not worker.handshake_wait.wait_ready(0), "Doesn't wait once ended"
+            assert not worker.handshake_reload.wait_ready(0), "Doesn't reload playlist once ended"
+
+
 @patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
 class TestHLSStreamByterange(TestMixinStreamHLS, unittest.TestCase):
-    __stream__ = EventedHLSStream
+    __stream__ = EventedWriterHLSStream
 
     # The dummy segments in the error tests are required because the writer's run loop would otherwise continue forever
     # due to the segment's future result being None (no requests result), and we can't await the end of the stream
@@ -272,7 +390,7 @@ def test_offsets(self):
 
 @patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
 class TestHLSStreamEncrypted(TestMixinStreamHLS, unittest.TestCase):
-    __stream__ = EventedHLSStream
+    __stream__ = EventedWriterHLSStream
 
     def get_session(self, options=None, *args, **kwargs):
         session = super().get_session(options)
@@ -572,7 +690,7 @@ def test_hls_playlist_reload_time_no_data(self):
 @patch("streamlink.stream.hls.log")
 @patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
 class TestHlsPlaylistParseErrors(TestMixinStreamHLS, unittest.TestCase):
-    __stream__ = EventedHLSStream
+    __stream__ = EventedWriterHLSStream
 
     class FakePlaylist(typing.NamedTuple):
         is_master: bool = False