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:
KynikossDragonn: This unfortunately didn't seem to work out, I appear to be getting warned of discontinuities that aren't actually happening after the initial pre-roll ad:
```
[session][info] Plugin twitch is being overridden by /home/kynikos/.local/share/streamlink/plugins/twitch.py
[cli][info] Found matching plugin twitch for URL twitch.tv/doctorcdcs
[cli][info] Available streams: audio_only, 160p (worst), 360p, 480p, 720p60, 1080p60 (best)
[cli][info] Opening stream: 360p (hls)
[cli][info] Starting player: mpv
[plugins.twitch][info] Low latency streaming (HLS live edge: 1)
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[cli][info] Player closed
[cli][info] Stream ended
[cli][info] Closing currently open stream...
```
Here's a verbose log:
[streamlink.txt.gz](https://github.com/user-attachments/files/15598331/streamlink.txt.gz)

bastimeyer: Are you loading the right plugin version? I can't find any streams with the same playlist behavior, so I'm just relying on the added test data.

See this test fixture (from the current PR version)
https://github.com/streamlink/streamlink/pull/6022/commits/c262b236dbf7e79c1c30f35481cba304e528ab56#diff-48c96f8246c6a3e90a6bc303e504500d3a32f392bb876541469fa9b8de6915d9R404

which is the equivalent of this playlist data from your log output
```
[20:16:02.373832][stream.hls.m3u8][all] #EXT-X-PROGRAM-DATE-TIME:2024-06-06T01:15:56.627Z
[20:16:02.374206][stream.hls.m3u8][all] #EXTINF:2.000,live
[20:16:02.374597][stream.hls.m3u8][all] URL
[20:16:02.374878][stream.hls.m3u8][all] #EXT-X-DISCONTINUITY
[20:16:02.375099][stream.hls.m3u8][all] #EXT-X-TWITCH-LIVE-SEQUENCE:2249
[20:16:02.375443][stream.hls.m3u8][all] #EXT-X-PROGRAM-DATE-TIME:2024-06-06T01:15:58.627Z
[20:16:02.375741][stream.hls.m3u8][all] #EXTINF:2.000,live
[20:16:02.376007][stream.hls.m3u8][all] URL
[20:16:02.376403][stream.hls.m3u8][all] #EXT-X-TWITCH-PREFETCH:URL
[20:16:02.376766][stream.hls.m3u8][all] #EXT-X-TWITCH-PREFETCH:URL
[20:16:02.377407][stream.hls][debug] Adding segment 28 to queue
[20:16:03.826540][stream.hls][debug] Segment 27 complete
[20:16:03.827270][stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
```

Basically a simple discontinuity between two non-ad segments, which should be ignored, so it surprises me to see these warnings in your log output.
KynikossDragonn: > Are you loading the right plugin version? I can't find any streams with the same playlist behavior, so I'm just relying on the added test data.

Unless there's something somehow caching the previous version of the py file even though I overwrote it with the one from this PR, I should be using the right version. I'll try again some more later tonight.


voidlily: I was running into stream discontinuity issues as well today and found this issue. I tested against this branch and it didn't help. These are actual discontinuities and not just spurious log messages. I run with `twitch-disable-ads`, `twitch-low-latency`, and an oauth token from my browser.

I wonder if this might be an issue with low latency, because I tried without `twitch-low-latency` and the discontinuity errors went away

```
❯ [cli][info] Found matching plugin twitch for URL https://twitch.tv/missshadowlovely
[cli][info] Available streams: audio_only, 160p (worst), 360p, 480p, 720p, 720p60, 1080p60 (best)
[cli][info] Opening stream: 1080p60 (hls)
[cli][info] Starting player: mpv
[plugins.twitch][info] Will skip ad segments
[plugins.twitch][info] Low latency streaming (HLS live edge: 2)
[stream.hls][info] Filtering out segments and pausing stream output
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][info] Resuming stream output
[stream.hls][info] Filtering out segments and pausing stream output
[stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
[stream.hls][info] Resuming stream output
```

bastimeyer: > ```
> [stream.hls][info] Filtering out segments and pausing stream output
> [stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
> [stream.hls][info] Resuming stream output
> [stream.hls][info] Filtering out segments and pausing stream output
> [stream.hls][warning] Encountered a stream discontinuity. This is unsupported and will result in incoherent output data.
> [stream.hls][info] Resuming stream output
> ```

> These are actual discontinuities

Which means that both the `#EXT-X-DISCONTINUITY` HLS tags and the warning messages are valid. Remember that discontinuities are not supported and thus warnings must be emitted by Streamlink's HLS implementation, because the bitstream written to the output is incoherent.

Stream output pausing is related to ads filtering, so unless the ads detection is broken, this is working as intended. Without an "all" log output (`--loglevel=all`) which includes the actual HLS playlist content, nobody will be able to tell if there's an issue or not.

As said, I can't reproduce this, so this must only be the case for people who set authentication headers while being subscribed to certain channels. None of the "all" log outputs posted by @KynikossDragonn suggest any issues with prefetch segments (low latency segments).
bastimeyer: Had another look at the changes of this PR and I think I understand the issue with prefetch segments now. I'll fix this tomorrow once I get the time. It's way too late here now.
bastimeyer: I've updated the PR. Please re-check the new changes.

As said, I don't see the discontinuities in any HLS playlists myself, so this is purely based on the `--loglevel=all` log outputs which were uploaded here, which means I still could've missed some cases.
KynikossDragonn: > I've updated the PR. Please re-check the new changes.

I've so far not have the warning repetitively show up with the latest change so I think the issue is properly resolved now.



Patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index 7ae078e9..06278b09 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -71,26 +71,45 @@ class TwitchM3U8Parser(M3U8Parser[TwitchM3U8, TwitchHLSSegment, HLSPlaylist]):
     __m3u8__: ClassVar[Type[TwitchM3U8]] = TwitchM3U8
     __segment__: ClassVar[Type[TwitchHLSSegment]] = TwitchHLSSegment
 
+    @parse_tag("EXT-X-TWITCH-LIVE-SEQUENCE")
+    def parse_ext_x_twitch_live_sequence(self, value):
+        # Unset discontinuity state if the previous segment was not an ad,
+        # as the following segment won't be an ad
+        if self.m3u8.segments and not self.m3u8.segments[-1].ad:
+            self._discontinuity = False
+
     @parse_tag("EXT-X-TWITCH-PREFETCH")
     def parse_tag_ext_x_twitch_prefetch(self, value):
         segments = self.m3u8.segments
         if not segments:  # pragma: no cover
             return
         last = segments[-1]
+
         # Use the average duration of all regular segments for the duration of prefetch segments.
         # This is better than using the duration of the last segment when regular segment durations vary a lot.
         # In low latency mode, the playlist reload time is the duration of the last segment.
         duration = last.duration if last.prefetch else sum(segment.duration for segment in segments) / float(len(segments))
+
         # 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
+        # (discontinuity tag inserted after last regular segment)
+        # Don't reset discontinuity state: the date extrapolation might be inaccurate,
+        # so all following prefetch segments should be considered an ad after a discontinuity
         ad = self._discontinuity or self._is_segment_ad(date)
+
+        # Since we don't reset the discontinuity state in prefetch segments for the purpose of ad detection,
+        # set the prefetch segment's discontinuity attribute based on ad transitions
+        discontinuity = ad != last.ad
+
         segment = dataclass_replace(
             last,
             uri=self.uri(value),
             duration=duration,
             title=None,
+            discontinuity=discontinuity,
             date=date,
             ad=ad,
             prefetch=True,
@@ -108,6 +127,15 @@ 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
+
         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 e8997dbb..00bdd023 100644
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -363,6 +363,7 @@ class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
             Tag("EXT-X-DISCONTINUITY"),
             TagDateRangeAd(start=DATETIME_BASE + timedelta(seconds=3), duration=4),
         ]
+        tls = Tag("EXT-X-TWITCH-LIVE-SEQUENCE", 7)
         # noinspection PyTypeChecker
         segments = self.subject([
             # regular stream data with prefetch segments
@@ -374,9 +375,9 @@ class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
             # still no prefetch segments while ads are playing
             Playlist(3, [*ads, Seg(3), Seg(4), Seg(5), Seg(6)]),
             # new prefetch segments on the first regular segment occurrence
-            Playlist(4, [*ads, Seg(4), Seg(5), Seg(6), Seg(7), Pre(8), Pre(9)]),
-            Playlist(5, [*ads, Seg(5), Seg(6), Seg(7), Seg(8), Pre(9), Pre(10)]),
-            Playlist(6, [*ads, Seg(6), Seg(7), Seg(8), Seg(9), Pre(10), Pre(11)]),
+            Playlist(4, [*ads, Seg(4), Seg(5), Seg(6), tls, Seg(7), Pre(8), Pre(9)]),
+            Playlist(5, [*ads, Seg(5), Seg(6), tls, Seg(7), Seg(8), Pre(9), Pre(10)]),
+            Playlist(6, [*ads, Seg(6), tls, Seg(7), Seg(8), Seg(9), Pre(10), Pre(11)]),
             Playlist(7, [Seg(7), Seg(8), Seg(9), Seg(10), Pre(11), Pre(12)], end=True),
         ], streamoptions={"disable_ads": True, "low_latency": True})
 
@@ -415,6 +416,36 @@ 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_prefetch_after_discontinuity(self, mock_log):
+        segments = self.subject([
+            Playlist(0, [Segment(0), Segment(1)]),
+            Playlist(2, [Segment(2), Segment(3), Tag("EXT-X-DISCONTINUITY"), SegmentPrefetch(4), SegmentPrefetch(5)]),
+            Playlist(6, [Segment(6), Segment(7)], end=True),
+        ], streamoptions={"disable_ads": True, "low_latency": True})
+
+        self.await_write(8)
+        assert self.await_read(read_all=True) == self.content(segments, cond=lambda seg: seg.num not in (4, 5))
+        assert mock_log.warning.mock_calls == [
+            call("Encountered a stream discontinuity. This is unsupported and will result in incoherent output data."),
+        ]
+
+    @patch("streamlink.stream.hls.hls.log")
+    def test_hls_ignored_discontinuity(self, mock_log):
+        Seg, Pre = Segment, SegmentPrefetch
+        discontinuity = Tag("EXT-X-DISCONTINUITY")
+        tls = Tag("EXT-X-TWITCH-LIVE-SEQUENCE", 1234)  # value is irrelevant
+        segments = self.subject([
+            Playlist(0, [Seg(0), discontinuity, Seg(1)]),
+            Playlist(2, [Seg(2), Seg(3), discontinuity, Seg(4), Seg(5)]),
+            Playlist(6, [Seg(6), Seg(7), discontinuity, tls, Pre(8), Pre(9)]),
+            Playlist(10, [Seg(10), Seg(11), discontinuity, tls, Pre(12), discontinuity, tls, Pre(13)], end=True),
+        ], streamoptions={"disable_ads": True, "low_latency": True})
+
+        self.await_write(14)
+        assert self.await_read(read_all=True) == self.content(segments)
+        assert mock_log.warning.mock_calls == []
+
 
 class TestTwitchAPIAccessToken:
     @pytest.fixture(autouse=True)