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:

Patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index 32666d04..ac73b52e 100644
--- 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 @@ class TwitchM3U8Parser(M3U8Parser[TwitchM3U8, TwitchHLSSegment, HLSPlaylist]):
         # 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 @@ class TwitchM3U8Parser(M3U8Parser[TwitchM3U8, TwitchHLSSegment, HLSPlaylist]):
         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
index 89ea494f..1d2984a0 100644
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -341,12 +341,12 @@ class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
         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 @@ class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
         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)