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
I took another log after a streamer ran a several minute long midroll ad break:
[streamlink.txt.gz](https://github.com/user-attachments/files/15527113/streamlink.txt.gz)

I might try seeing what happens if I don't use the "Low Latency" option, but this is really strange behavior coming from the playlists.
There is no point doing this unless you're using the branch of the linked pull request (#6016).
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#pull-request-feedback
Apologies, I noticed the pull request a bit late; I grabbed the `twitch.py` from it and ran this test.

The fix seems to work from my testing; I only get a warning on the actual adbreak segments.
[streamlink.txt.gz](https://github.com/user-attachments/files/15527285/streamlink.txt.gz)

Something else bizarre happened towards the end, after the adbreak the video froze and I just keep seeing streamlink reloading the playlist, but mpv receives nothing; just stuck on the last frame of the "Commercial Break In Progress" video. That's a separate issue I'm trying to understand the underlying cause of that's not related to this issue though.



Patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index 32666d04852..ac73b52e044 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 @@ 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
index 89ea494f900..1d2984a0525 100644
--- 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)