Repository: streamlink/streamlink

Issue:
plugins.twitch: purple screen doesn't get filtered out correctly (embedded ads)
### Checklist

- [X] This is a plugin issue 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 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

Latest build from the master branch

### Description

**Embedded ads meta-thread here: #3210**

----

Twitch has, as expected, made new changes to their embedded ads system after their source code has been leaked a few weeks ago.

New access token request headers were added in #4086, but this, as expected as well, stopped working too, at least for non-preroll ads as far as I can tell.

The `--twitch-disable-ads` parameter still seems to be able to filter out ads, but there's one HLS segment with the purple screen which doesn't get caught by it, so the purple screen appears just before the stream output stops for filtering out the ads. It's possible that some timestamps are set differently now or that they are using different values in the metadata for annotating the ad segments.

To be able to fix this, we need to know the actual HLS playlist contents when the embedded ads start.

### Debug log

```text
-
```



Discussion:
Not sure how the purple screen makes it into the output when filtering is enabled. I've added some debugging info for the parsed segments, and I can't see anything wrong in the ad detection itself.

This is the relevant part of the playlist content when a midroll ad appears (some IDs and tokens replaced):
```m3u
#EXT-X-DATERANGE:ID="SOME-ID",CLASS="twitch-assignment",START-DATE="2021-10-19T21:25:23.502Z",END-ON-NEXT=YES,X-TV-TWITCH-SERVING-ID="SOME-ID",X-TV-TWITCH-NODE="video-edge-c1d600.fra02",X-TV-TWITCH-CLUSTER="fra02"
#EXT-X-PROGRAM-DATE-TIME:2021-10-19T21:25:23.502Z
#EXTINF:2.000,live
https://video-edge-c1d600.fra02.abs.hls.ttvnw.net/v1/segment/...

#EXT-X-DATERANGE:ID="stitched-ad-0123456789-0123456789",CLASS="twitch-stitched-ad",START-DATE="2021-10-19T21:25:25.502Z",DURATION=29.134,X-TV-TWITCH-AD-COMMERCIAL-ID="SOME-ID",X-TV-TWITCH-AD-CLICK-TRACKING-URL="https://example.com",X-TV-TWITCH-AD-POD-LENGTH="1",X-TV-TWITCH-AD-AD-FORMAT="standard_video_ad",X-TV-TWITCH-AD-RADS-TOKEN="SOME-TOKEN",X-TV-TWITCH-AD-HIDE-AD-OVERLAY="1",X-TV-TWITCH-AD-URL="https://twitch.tv/CHANNEL",X-TV-TWITCH-AD-LOUDNESS="-10.980000",X-TV-TWITCH-AD-ROLL-TYPE="MIDROLL",X-TV-TWITCH-AD-AD-SESSION-ID="SOME-ID",X-TV-TWITCH-AD-CLICK-BEACON-ID="clickDropBeacon",X-TV-TWITCH-AD-CREATIVE-ID="2474283100405",X-TV-TWITCH-AD-LINE-ITEM-ID="SOME-ID",X-TV-TWITCH-AD-POD-POSITION="0",X-TV-TWITCH-AD-VLM="1"
#EXT-X-DATERANGE:ID="source-0123456789",CLASS="twitch-stream-source",START-DATE="2021-10-19T21:25:25.502Z",END-ON-NEXT=YES,X-TV-TWITCH-STREAM-SOURCE="Amazon|2474283100405"
#EXT-X-DATERANGE:ID="quartile-0123456789-0",CLASS="twitch-ad-quartile",START-DATE="2021-10-19T21:25:25.502Z",DURATION=2.000,X-TV-TWITCH-AD-QUARTILE="0"
#EXT-X-DISCONTINUITY
#EXT-X-PROGRAM-DATE-TIME:2021-10-19T21:25:25.502Z
#EXTINF:2.000,Amazon|2474283100405
https://video-edge-c1d600.fra02.abs.hls.ttvnw.net/v1/segment/...
#EXT-X-PROGRAM-DATE-TIME:2021-10-19T21:25:27.502Z
#EXTINF:2.000,Amazon|2474283100405
https://video-edge-c1d600.fra02.abs.hls.ttvnw.net/v1/segment/...
```

`2021-10-19T21:25:25.502Z` is the time of the X-DATERANGE tag when the ad starts, which matches the first ad segment and its X-PROGRAM-DATE-TIME value.

And this is the debug log output of the segment creation:
```
[stream.hls][debug] Reloading playlist
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=false ExtInf(duration=2.0, title='live')
[plugins.twitch][debug] Segment info: ad=true ExtInf(duration=2.0, title='Amazon|2474283100405')
[plugins.twitch][debug] Segment info: ad=true ExtInf(duration=2.0, title='Amazon|2474283100405')
[stream.hls][debug] Adding segment 329 to queue
[stream.hls][info] Filtering out segments and pausing stream output
```

This shows that the segment's `ad` attribute gets correctly set to True for embedded ads.

Filtering depends on that attribute, which is fully tested:
- https://github.com/streamlink/streamlink/blob/765f969dfd6348a5d07c442c8980ea674bf6035f/src/streamlink/plugins/twitch.py#L112-L114
- https://github.com/streamlink/streamlink/blob/765f969dfd6348a5d07c442c8980ea674bf6035f/src/streamlink/stream/hls.py#L158-L171
- https://github.com/streamlink/streamlink/blob/765f969dfd6348a5d07c442c8980ea674bf6035f/tests/stream/test_hls_filtered.py
Something else seems to have changed now and the midroll ads filtering doesn't stop and it keeps filtering indefinitely. This happened twice to me today now.

I haven't had a look at this yet, but it's possible that something related to the daterange values has changed in Twitch's HLS playlists.
The switch from playerType="embed" to "site" is the reason why new version is having issues but not older versions.
Edit: Can confirm I get no midroll ads with "embed" (local hunk revert).
This only affects the access token, which determines whether you're seeing ads or not. Both access token request parameters cause ads, but I feel like the switch from `embed` to `site` in #4156 is causing more ads now, so I think we should revert that. It also seems like `embed` doesn't cause any midroll ads.

However, this thread is about ads filtering, and the current access token request parameter value shows that this isn't working 100% reliably anymore, so a fix is needed here as well. I've been observing HLS streams on Twitch for a bit but haven't been able to find anything yet. My gut feeling says that the issue with the stuck filtering is caused by an `END-ON-NEXT` daterange tag, which is currently unsupported. It might also be due to an invalid playlist reload time calculation.
i fixed this by using a vpn 
> i fixed this by using a vpn

This is completely unrelated to the topic of this thread.
https://streamlink.github.io/latest/cli/plugins/twitch.html#embedded-ads
I've had another look at the issue and it's now clear to me why Twitch's midroll ads (if they occur - I haven't seen one in a year or so) don't get filtered out properly. The issue is caused by the low-latency and segment prefetch implementation, which isn't solved in an ideal way.

The prefetching works like this: there are regular HLS segments with time and duration metadata, and there are two prefetch URLs added to the playlist if the stream is a low latency stream.

Streamlink currently clones the last regular segment and replaces its URL for each available prefetch URL and then extends the playlist by appending those prefetch segments. Since prefetch items don't include any metadata because it is unknown ahead of time, the metadata of the last regular segment gets re-used when appending prefetch segments.

Streamlink already guesses the duration of the appended prefetch segments so that the playlist refresh times can be as low as possible, as the refresh time is determined by the duration of the last segment. However, it is currently not re-calculating the time of the prefetch segments, which means that the ad-calculation is wrong too, so those prefetch segments will never get filtered out unless the last regular segment was detected as an ad. But since Streamlink is extending the playlist with prefetch segments, the last regular segment is always a couple of seconds behind. Whether Twitch includes ads in the prefetch data is the big question here, and if they do, then this is the cause of this issue.

A proper prefetch implementation would be not cloning the last regular segment and instead only downloading and caching the prefetch data, and using the cached data on the next playlist refresh for the regular segment that matches the sequence number and/or URL of the cached data. I have already successfully rewritten this locally and experimented with it a bit, but unfortunately it looks like it's adding a delay to the stream output, because it has to wait for the next playlist refresh first in order to use the cached prefetch data. This means that by using this prefetch implementation, there will always be a delay of the duration of the last regular segment, which is about two seconds.

I will take a look at re-calculating the time and ad status of prefetch segments with the current implementation. This won't change the low latency stuff and will hopefully fix this issue.

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
@@ -10,7 +10,7 @@
 import logging
 import re
 import sys
-from datetime import datetime
+from datetime import datetime, timedelta
 from random import random
 from typing import List, NamedTuple, Optional
 from urllib.parse import urlparse
@@ -19,7 +19,7 @@
 from streamlink.plugin import Plugin, pluginargument, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.stream.hls import HLSStream, HLSStreamReader, HLSStreamWorker, HLSStreamWriter
-from streamlink.stream.hls_playlist import ByteRange, ExtInf, Key, M3U8, M3U8Parser, Map, load as load_hls_playlist
+from streamlink.stream.hls_playlist import ByteRange, DateRange, ExtInf, Key, M3U8, M3U8Parser, Map, load as load_hls_playlist
 from streamlink.stream.http import HTTPStream
 from streamlink.utils.args import keyvalue
 from streamlink.utils.parse import parse_json, parse_qsd
@@ -70,27 +70,31 @@ def parse_tag_ext_x_twitch_prefetch(self, value):
         # 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))
-        segments.append(last._replace(
+        # 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)
+        ad = self._is_segment_ad(date)
+        segment = last._replace(
             uri=self.uri(value),
             duration=duration,
-            prefetch=True
-        ))
+            title=None,
+            discontinuity=self.state.pop("discontinuity", False),
+            date=date,
+            ad=ad,
+            prefetch=True,
+        )
+        segments.append(segment)
 
     def parse_tag_ext_x_daterange(self, value):
         super().parse_tag_ext_x_daterange(value)
         daterange = self.m3u8.dateranges[-1]
-        is_ad = (
-            daterange.classname == "twitch-stitched-ad"
-            or str(daterange.id or "").startswith("stitched-ad-")
-            or any(attr_key.startswith("X-TV-TWITCH-AD-") for attr_key in daterange.x.keys())
-        )
-        if is_ad:
+        if self._is_daterange_ad(daterange):
             self.m3u8.dateranges_ads.append(daterange)
 
     def get_segment(self, uri: str) -> TwitchSegment:  # type: ignore[override]
         extinf: ExtInf = self.state.pop("extinf", None) or ExtInf(0, None)
         date = self.state.pop("date", None)
-        ad = any(self.m3u8.is_date_in_daterange(date, daterange) for daterange in self.m3u8.dateranges_ads)
+        ad = self._is_segment_ad(date, extinf.title)
 
         return TwitchSegment(
             uri=uri,
@@ -102,7 +106,21 @@ def get_segment(self, uri: str) -> TwitchSegment:  # type: ignore[override]
             date=date,
             map=self.state.get("map"),
             ad=ad,
-            prefetch=False
+            prefetch=False,
+        )
+
+    def _is_segment_ad(self, date: datetime, title: Optional[str] = None) -> bool:
+        return (
+            title is not None and "Amazon" in title
+            or any(self.m3u8.is_date_in_daterange(date, daterange) for daterange in self.m3u8.dateranges_ads)
+        )
+
+    @staticmethod
+    def _is_daterange_ad(daterange: DateRange) -> bool:
+        return (
+            daterange.classname == "twitch-stitched-ad"
+            or str(daterange.id or "").startswith("stitched-ad-")
+            or any(attr_key.startswith("X-TV-TWITCH-AD-") for attr_key in daterange.x.keys())
         )
 
 


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
@@ -328,6 +328,37 @@ def test_hls_low_latency_has_prefetch_disable_ads_has_preroll(self, mock_log):
             call("Waiting for pre-roll ads to finish, be patient")
         ])
 
+    @patch("streamlink.plugins.twitch.log")
+    def test_hls_low_latency_has_prefetch_disable_ads_no_preroll_with_prefetch_ads(self, mock_log):
+        # segments 3-6 are ads
+        ads = TagDateRangeAd(start=DATETIME_BASE + timedelta(seconds=3), duration=4)
+        thread, segments = self.subject([
+            # regular stream data with prefetch segments
+            Playlist(0, [Segment(0), Segment(1), SegmentPrefetch(2), SegmentPrefetch(3)]),
+            # three prefetch segments, one regular (2) and two ads (3 and 4)
+            Playlist(1, [Segment(1), SegmentPrefetch(2), ads, SegmentPrefetch(3), SegmentPrefetch(4)]),
+            # all prefetch segments are gone once regular prefetch segments have shifted
+            Playlist(2, [Segment(2), ads, Segment(3), Segment(4), Segment(5)]),
+            # still no prefetch segments while ads are playing
+            Playlist(3, [ads, Segment(3), Segment(4), Segment(5), Segment(6)]),
+            # new prefetch segments on the first regular segment occurrence
+            Playlist(4, [ads, Segment(4), Segment(5), Segment(6), Segment(7), SegmentPrefetch(8), SegmentPrefetch(9)]),
+            Playlist(5, [ads, Segment(5), Segment(6), Segment(7), Segment(8), SegmentPrefetch(9), SegmentPrefetch(10)]),
+            Playlist(6, [ads, Segment(6), Segment(7), Segment(8), Segment(9), SegmentPrefetch(10), SegmentPrefetch(11)]),
+            Playlist(7, [Segment(7), Segment(8), Segment(9), Segment(10), SegmentPrefetch(11), SegmentPrefetch(12)], end=True),
+        ], disable_ads=True, low_latency=True)
+
+        self.await_write(11)
+        content = self.await_read(read_all=True)
+        self.assertEqual(
+            content,
+            self.content(segments, cond=lambda s: 2 <= s.num <= 3 or 7 <= s.num)
+        )
+        self.assertEqual(mock_log.info.mock_calls, [
+            call("Will skip ad segments"),
+            call("Low latency streaming (HLS live edge: 2)"),
+        ])
+
     @patch("streamlink.plugins.twitch.log")
     def test_hls_low_latency_no_prefetch_disable_ads_has_preroll(self, mock_log):
         daterange = TagDateRangeAd(duration=4)