Repository: streamlink/streamlink

Issue:
plugins.twitch: incorrect discontinuity tags lead to warnings being spammed
### Checklist

- [X] This is a [plugin issue](https://streamlink.github.io/plugins.html) and not [a different kind of issue](https://github.com/streamlink/streamlink/issues/new/choose)
- [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 plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

streamlink 6.7.4

### Description

Recently, I've noticed constant warnings about stream discontinuities despite not seeing any actually happen.

According to the verbose log the playlists seem to keep having the X-DISCONTINUITY tag in them even though I'm not getting served some sort of mid-roll or pre-roll due to having a subscription to the channel in question. I'm still receiving the live broadcast.

There are also instances of repetitively getting this warning even after getting past the initial pre-roll in the event I don't have a subscription to a particular streamer.

I'm not sure what's going on in the Twitch side of things, is this some attempt at trying to break streamlink usage again?

The verbose log is too large for the issue report so I'm attaching it as a file:
[streamlink.txt.gz](https://github.com/user-attachments/files/15524324/streamlink.txt.gz)



### Debug log

```text
https://github.com/user-attachments/files/15524324/streamlink.txt.gz
```



Discussion:
This has nothing to do with Twitch. It's a bug in the HLS implementation which warns when there's a stream discontinuity, which is not supported by Streamlink, as two different bitstreams are concatenated. This happens during ad transitions. The warning however is not supposed to be written to the log output every time. This looks like a bug in regards to the ad segment filtering.
After having another closer look at the logs, the warning behavior seems to be working correctly. What's wrong is the discontinuity tag in between two "live" segments where there's no actual discontinuity.

No idea why Twitch is inserting this here, but it's clearly wrong. You said it yourself, you get the regular live content without ads.

I guess this could be covered by the Twitch plugin's HLS subclasses. The discontinuity status of a segment would need to be cleared when it and the segment before it are both considered "live" segments. 

Prefetch segments however might cause a few problems:
https://github.com/streamlink/streamlink/blob/6.7.4/src/streamlink/plugins/twitch.py#L87

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
@@ -71,6 +71,15 @@ class TwitchM3U8Parser(M3U8Parser[TwitchM3U8, TwitchHLSSegment, HLSPlaylist]):
     __m3u8__: ClassVar[Type[TwitchM3U8]] = TwitchM3U8
     __segment__: ClassVar[Type[TwitchHLSSegment]] = TwitchHLSSegment
 
+    def __init__(self, *args, **kwargs) -> None:
+        super().__init__(*args, **kwargs)
+        self._twitch_live_sequence: Optional[int] = None
+
+    # additional metadata available on prefetch segments, indicating live content
+    @parse_tag("EXT-X-TWITCH-LIVE-SEQUENCE")
+    def parse_ext_x_twitch_live_sequence(self, value):
+        self._twitch_live_sequence = int(value)
+
     @parse_tag("EXT-X-TWITCH-PREFETCH")
     def parse_tag_ext_x_twitch_prefetch(self, value):
         segments = self.m3u8.segments
@@ -84,19 +93,23 @@ def parse_tag_ext_x_twitch_prefetch(self, value):
         # Use the last duration for extrapolating the start time of the prefetch segment, which is needed for checking
         # whether it is an ad segment and matches the parsed date ranges or not
         date = last.date + timedelta(seconds=last.duration)
-        # Always treat prefetch segments after a discontinuity as ad segments
-        ad = self._discontinuity or self._is_segment_ad(date)
+        ad = self._is_segment_ad(date)
         segment = dataclass_replace(
             last,
             uri=self.uri(value),
             duration=duration,
             title=None,
+            discontinuity=self._discontinuity and self._twitch_live_sequence is None,
             date=date,
             ad=ad,
             prefetch=True,
         )
         segments.append(segment)
 
+        # Reset state
+        self._discontinuity = False
+        self._twitch_live_sequence = None
+
     @parse_tag("EXT-X-DATERANGE")
     def parse_tag_ext_x_daterange(self, value):
         super().parse_tag_ext_x_daterange(value)
@@ -108,6 +121,18 @@ def get_segment(self, uri: str, **data) -> TwitchHLSSegment:
         ad = self._is_segment_ad(self._date, self._extinf.title if self._extinf else None)
         segment: TwitchHLSSegment = super().get_segment(uri, ad=ad, prefetch=False)  # type: ignore[assignment]
 
+        # Special case where Twitch incorrectly inserts discontinuity tags between segments of the live content
+        if (
+            segment.discontinuity
+            and not segment.ad
+            and self.m3u8.segments
+            and not self.m3u8.segments[-1].ad
+        ):
+            segment.discontinuity = False
+
+        # Reset state
+        self._twitch_live_sequence = None
+
         return segment
 
     def _is_segment_ad(self, date: Optional[datetime], title: Optional[str] = None) -> bool:


Test patch:
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -341,12 +341,12 @@ def test_hls_low_latency_has_prefetch_disable_ads_no_preroll_with_prefetch_ads(s
         Seg, Pre = Segment, SegmentPrefetch
         ads = [
             Tag("EXT-X-DISCONTINUITY"),
-            TagDateRangeAd(start=DATETIME_BASE + timedelta(seconds=3), duration=4),
+            TagDateRangeAd(start=DATETIME_BASE + timedelta(seconds=2.5), duration=4),
         ]
         # noinspection PyTypeChecker
         segments = self.subject([
             # regular stream data with prefetch segments
-            Playlist(0, [Seg(0), Seg(1, duration=0.5), Pre(2), Pre(3)]),
+            Playlist(0, [Seg(0), Seg(1, duration=0.5), Pre(2), *ads, Pre(3)]),
             # three prefetch segments, one regular (2) and two ads (3 and 4)
             Playlist(1, [Seg(1, duration=0.5), Pre(2), *ads, Pre(3), Pre(4)]),
             # all prefetch segments are gone once regular prefetch segments have shifted
@@ -395,6 +395,33 @@ def test_hls_low_latency_no_ads_reload_time(self):
         self.await_read(read_all=True)
         assert self.thread.reader.worker.playlist_reload_time == pytest.approx(23 / 3)
 
+    @patch("streamlink.stream.hls.hls.log")
+    def test_hls_incorrect_discontinuity(self, mock_log):
+        discontinuity = Tag("EXT-X-DISCONTINUITY")
+        self.subject([
+            Playlist(0, [Segment(0), Segment(1), discontinuity, Segment(2), Segment(3)]),
+            Playlist(4, [Segment(4), discontinuity, Segment(5), Segment(6), Segment(7)], end=True),
+        ], streamoptions={"disable_ads": True, "low_latency": True})
+
+        self.await_write(6)
+        self.await_read(read_all=True)
+        assert mock_log.warning.mock_calls == []
+
+    @patch("streamlink.stream.hls.hls.log")
+    def test_hls_incorrect_discontinuity_prefetch(self, mock_log):
+        Seg, Pre = Segment, SegmentPrefetch
+        discontinuity = Tag("EXT-X-DISCONTINUITY")
+        tls = Tag("EXT-X-TWITCH-LIVE-SEQUENCE", 1234)  # value is irrelevant
+        self.subject([
+            Playlist(0, [Seg(0), Seg(1), discontinuity, tls, Pre(2), discontinuity, tls, Pre(3)]),
+            Playlist(1, [Seg(1), discontinuity, tls, Seg(2), discontinuity, tls, Pre(3), Pre(4)]),
+            Playlist(2, [Seg(2), discontinuity, tls, Seg(3), discontinuity, tls, Pre(4), Pre(5)], end=True),
+        ], streamoptions={"disable_ads": True, "low_latency": True})
+
+        self.await_write(4)
+        self.await_read(read_all=True)
+        assert mock_log.warning.mock_calls == []
+
 
 class TestTwitchAPIAccessToken:
     @pytest.fixture(autouse=True)