Repository: streamlink/streamlink

Issue:
hls.py: Exception in _playlist_reload_time() when hls-playlist-reload-time is "segment" or "live-edge" and sequences is empty
### Checklist

- [X] This is a bug report and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed bug reports](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest build from the master branch

### Description

An exception of `TypeError: '>' not supported between instances of 'str' and 'int'` occurs here on line 273:
https://github.com/streamlink/streamlink/blob/193c5c6eb9147a56df25de160f4d35d6f230733a/src/streamlink/stream/hls.py#L268-L280

The first 2 `if` statements depend on `sequences` to not be empty, and the third `if` statement makes the assumption that `self.playlist_reload_time_override` is a numeric type.

https://github.com/streamlink/streamlink/blob/193c5c6eb9147a56df25de160f4d35d6f230733a/src/streamlink/stream/hls.py#L229-L232
We can see here `self.playlist_reload_time_override` is only guaranteed to be a numeric type when `hls-playlist-reload-time` is neither `segment` nor `live-edge`.

So when `hls-playlist-reload-time` is either `segment` or `live-edge`, and `sequences` is empty, `self.playlist_reload_time_override` will be a non-numeric type and an exception will occur when trying to do a comparison against an `int` in the third `if` statement.

A fix for this would be to verify `self.playlist_reload_time_override` is a numeric type in the third `if` statement.
```diff
-        if self.playlist_reload_time_override > 0:
+        if isinstance(self.playlist_reload_time_override, (float, int)) and self.playlist_reload_time_override > 0:
```

### Debug log

```text
[cli][debug] OS:         Linux-5.13.4-arch2-1-x86_64-with-glibc2.33
[cli][debug] Python:     3.9.6
[cli][debug] Streamlink: 2.2.0+46.g193c5c6
[cli][debug] Requests(2.26.0), Socks(1.7.1), Websocket(0.59.0)
[cli][debug] Arguments:
[cli][debug]  url=https://hakk.in/crash.m3u8
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --hls-playlist-reload-time=segment
[cli][info] Found matching plugin hls for URL https://hakk.in/crash.m3u8
[plugins.hls][debug] URL=https://hakk.in/crash.m3u8; params={}
[utils.l10n][debug] Language code: en_US
[cli][info] Available streams: live (worst, best)
[cli][info] Opening stream: live (hls)
[stream.hls][debug] Reloading playlist
[cli][debug] Pre-buffering 8192 bytes
Exception in thread Thread-HLSStreamWorker:
Traceback (most recent call last):
  File "/usr/lib/python3.9/threading.py", line 973, in _bootstrap_inner
    self.run()
  File "/home/hakkin/.local/lib/python3.9/site-packages/streamlink/stream/segmented.py", line 84, in run
    for segment in self.iter_segments():
  File "/home/hakkin/.local/lib/python3.9/site-packages/streamlink/stream/hls.py", line 325, in iter_segments
    self.reload_playlist()
  File "/home/hakkin/.local/lib/python3.9/site-packages/streamlink/stream/hls.py", line 263, in reload_playlist
    self.playlist_reload_time = self._playlist_reload_time(playlist, sequences)
  File "/home/hakkin/.local/lib/python3.9/site-packages/streamlink/stream/hls.py", line 273, in _playlist_reload_time
    if self.playlist_reload_time_override > 0:
TypeError: '>' not supported between instances of 'str' and 'int'
[stream.segmented][debug] Closing worker thread
[stream.segmented][debug] Closing writer thread
[cli][error] Try 1/1: Could not open stream <HLSStream('https://hakk.in/crash.m3u8', None)> (Failed to read data from stream: Read timeout)
error: Could not open stream <HLSStream('https://hakk.in/crash.m3u8', None)>, tried 1 times, exiting
[cli][info] Closing currently open stream...
```



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -118,6 +118,16 @@ class TwitchHLSStreamReader(HLSStreamReader):
     __worker__ = TwitchHLSStreamWorker
     __writer__ = TwitchHLSStreamWriter
 
+    def __init__(self, stream):
+        if stream.disable_ads:
+            log.info("Will skip ad segments")
+        if stream.low_latency:
+            live_edge = max(1, min(LOW_LATENCY_MAX_LIVE_EDGE, stream.session.options.get("hls-live-edge")))
+            stream.session.options.set("hls-live-edge", live_edge)
+            stream.session.options.set("hls-segment-stream-data", True)
+            log.info(f"Low latency streaming (HLS live edge: {live_edge})")
+        super().__init__(stream)
+
 
 class TwitchHLSStream(HLSStream):
     __reader__ = TwitchHLSStreamReader
@@ -127,17 +137,6 @@ def __init__(self, *args, **kwargs):
         self.disable_ads = self.session.get_plugin_option("twitch", "disable-ads")
         self.low_latency = self.session.get_plugin_option("twitch", "low-latency")
 
-    def open(self):
-        if self.disable_ads:
-            log.info("Will skip ad segments")
-        if self.low_latency:
-            live_edge = max(1, min(LOW_LATENCY_MAX_LIVE_EDGE, self.session.options.get("hls-live-edge")))
-            self.session.options.set("hls-live-edge", live_edge)
-            self.session.options.set("hls-segment-stream-data", True)
-            log.info("Low latency streaming (HLS live edge: {0})".format(live_edge))
-
-        return super().open()
-
     @classmethod
     def _get_variant_playlist(cls, res):
         return load_hls_playlist(res.text, base_uri=res.url)
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
@@ -270,7 +270,7 @@ def _playlist_reload_time(self, playlist: M3U8, sequences: List[Sequence]) -> fl
             return sequences[-1].segment.duration
         if self.playlist_reload_time_override == "live-edge" and sequences:
             return sum([s.segment.duration for s in sequences[-max(1, self.live_edge - 1):]])
-        if self.playlist_reload_time_override > 0:
+        if type(self.playlist_reload_time_override) is float and self.playlist_reload_time_override > 0:
             return self.playlist_reload_time_override
         if playlist.target_duration:
             return playlist.target_duration


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
@@ -137,7 +137,7 @@ def __init__(self, session: Streamlink, stream: HLSStream, *args, **kwargs):
 
         self.session = session
         self.stream = stream
-        self.reader = stream.open()
+        self.reader = stream.__reader__(stream)
 
         # ensure that at least one read was attempted before closing the writer thread early
         # otherwise, the writer will close the reader's buffer, making it not block on read and yielding empty results
@@ -275,7 +275,7 @@ def get_session(self, options=None, *args, **kwargs):
         return Streamlink(options)
 
     # set up HLS responses, create the session and read thread and start it
-    def subject(self, playlists, options=None, streamoptions=None, threadoptions=None, *args, **kwargs):
+    def subject(self, playlists, options=None, streamoptions=None, threadoptions=None, start=True, *args, **kwargs):
         # filter out tags and duplicate segments between playlist responses while keeping index order
         segments_all = [item for playlist in playlists for item in playlist.items if isinstance(item, Segment)]
         segments = OrderedDict([(segment.num, segment) for segment in segments_all])
@@ -287,6 +287,12 @@ def subject(self, playlists, options=None, streamoptions=None, threadoptions=Non
         self.session = self.get_session(options, *args, **kwargs)
         self.stream = self.__stream__(self.session, self.url(playlists[0]), **(streamoptions or {}))
         self.thread = self.__readthread__(self.session, self.stream, name=f"ReadThread-{self.id()}", **(threadoptions or {}))
-        self.thread.start()
+
+        if start:
+            self.start()
 
         return self.thread, segments
+
+    def start(self):
+        self.thread.reader.open()
+        self.thread.start()
diff --git a/tests/streams/test_hls.py b/tests/streams/test_hls.py
--- a/tests/streams/test_hls.py
+++ b/tests/streams/test_hls.py
@@ -1,5 +1,6 @@
 import os
 import unittest
+from threading import Event
 from unittest.mock import Mock, call, patch
 
 import pytest
@@ -289,11 +290,24 @@ def get_session(self, options=None, reload_time=None, *args, **kwargs):
         }))
 
     def subject(self, *args, **kwargs):
-        thread, segments = super().subject(*args, **kwargs)
-        # wait for the segments to be written, so that we're sure the playlist was parsed
-        self.await_write(len(segments))
+        thread, segments = super().subject(start=False, *args, **kwargs)
 
-        return thread.reader.worker.playlist_reload_time
+        # mock the worker thread's _playlist_reload_time method, so that the main thread can wait on its call
+        playlist_reload_time_called = Event()
+        orig_playlist_reload_time = thread.reader.worker._playlist_reload_time
+
+        def mocked_playlist_reload_time(*args, **kwargs):
+            playlist_reload_time_called.set()
+            return orig_playlist_reload_time(*args, **kwargs)
+
+        with patch.object(thread.reader.worker, "_playlist_reload_time", side_effect=mocked_playlist_reload_time):
+            self.start()
+            self.await_write(len(segments))
+
+            if not playlist_reload_time_called.wait(timeout=5):  # pragma: no cover
+                raise RuntimeError("Missing _playlist_reload_time() call")
+
+            return thread.reader.worker.playlist_reload_time
 
     def test_hls_playlist_reload_time_default(self):
         time = self.subject([Playlist(0, self.segments, end=True, targetduration=6)], reload_time="default")
@@ -303,10 +317,26 @@ def test_hls_playlist_reload_time_segment(self):
         time = self.subject([Playlist(0, self.segments, end=True, targetduration=6)], reload_time="segment")
         self.assertEqual(time, 3, "segment sets the reload time to the playlist's last segment")
 
+    def test_hls_playlist_reload_time_segment_no_segments(self):
+        time = self.subject([Playlist(0, [], end=True, targetduration=6)], reload_time="segment")
+        self.assertEqual(time, 6, "segment sets the reload time to the targetduration if no segments are available")
+
+    def test_hls_playlist_reload_time_segment_no_segments_no_targetduration(self):
+        time = self.subject([Playlist(0, [], end=True, targetduration=0)], reload_time="segment")
+        self.assertEqual(time, 15, "sets reload time to 15 seconds when no segments and no targetduration are available")
+
     def test_hls_playlist_reload_time_live_edge(self):
         time = self.subject([Playlist(0, self.segments, end=True, targetduration=6)], reload_time="live-edge")
         self.assertEqual(time, 8, "live-edge sets the reload time to the sum of the number of segments of the live-edge")
 
+    def test_hls_playlist_reload_time_live_edge_no_segments(self):
+        time = self.subject([Playlist(0, [], end=True, targetduration=6)], reload_time="live-edge")
+        self.assertEqual(time, 6, "live-edge sets the reload time to the targetduration if no segments are available")
+
+    def test_hls_playlist_reload_time_live_edge_no_segments_no_targetduration(self):
+        time = self.subject([Playlist(0, [], end=True, targetduration=0)], reload_time="live-edge")
+        self.assertEqual(time, 15, "sets reload time to 15 seconds when no segments and no targetduration are available")
+
     def test_hls_playlist_reload_time_number(self):
         time = self.subject([Playlist(0, self.segments, end=True, targetduration=6)], reload_time="4")
         self.assertEqual(time, 4, "number values override the reload time")
diff --git a/tests/streams/test_hls_filtered.py b/tests/streams/test_hls_filtered.py
--- a/tests/streams/test_hls_filtered.py
+++ b/tests/streams/test_hls_filtered.py
@@ -132,7 +132,7 @@ def test_filtered_no_timeout(self):
 
     @patch("streamlink.stream.hls.HLSStreamWriter.should_filter_sequence", new=filter_sequence)
     def test_filtered_closed(self):
-        thread, reader, writer, segments = self.subject([
+        thread, reader, writer, segments = self.subject(start=False, playlists=[
             Playlist(0, [SegmentFiltered(0), SegmentFiltered(1)], end=True)
         ])
 
@@ -145,6 +145,8 @@ def mocked_wait(*args, **kwargs):
             return orig_wait(*args, **kwargs)
 
         with patch.object(reader.filter_event, "wait", side_effect=mocked_wait):
+            self.start()
+
             # write first filtered segment and trigger the filter_event's lock
             self.assertTrue(reader.filter_event.is_set(), "Doesn't let the reader wait if not filtering")
             self.await_write()