Repository: streamlink/streamlink

Issue:
Twitch Pre-Roll ads (even on audio_only) -- Yes, tested using git snapshot post "fix"
- [x] This is a bug report and I have read the contribution guidelines.

The previously closed issue #2357 is still happening and I have reproduced it after manually installing a git checkout from about 10 min before this bug report.

Current test URL:

Even audio only (though the example was captured at another resolution)

```
streamlink twitch.tv/zisteau audio_only
```

Will display an actual VIDEO ad, also completely ignoring resolution or bandwidth use desires.


Occasionally the mismatch between the ad and the stream also causes the audio to playback in an oddly corrupted way (the streamer sounded down-pitch shifted, and the audio was very studdery, but that was a result of the ad invasion).

```
streamlink --loglevel=debug twitch.tv/zisteau 480p 2>&1 | tee streamlog.txt
[cli][debug] OS:         Linux-4.9.0-8-amd64-x86_64-with-debian-9.8
[cli][debug] Python:     2.7.13
[cli][debug] Streamlink: 1.0.0+46.g42105d8
[cli][debug] Requests(2.21.0), Socks(1.6.7), Websocket(0.55.0)
[cli][info] Found matching plugin twitch for URL twitch.tv/zisteau
[plugin.twitch][debug] Getting live HLS streams for zisteau
[utils.l10n][debug] Language code: en_US
[cli][info] Available streams: audio_only, 160p (worst), 360p, 480p, 720p, 720p60, 900p60 (best)
[cli][info] Opening stream: 480p (hls)
[stream.hls][debug] Reloading playlist
[stream.hls][debug] First Sequence: 0; Last Sequence: 4
[stream.hls][debug] Start offset: 0; Duration: None; Start Sequence: 2; End Sequence: None
[stream.hls][debug] Adding segment 2 to queue
[cli][debug] Pre-buffering 8192 bytes
[stream.hls][debug] Adding segment 3 to queue
[stream.hls][debug] Adding segment 4 to queue
[stream.hls][debug] Download of segment 2 complete
[cli][info] Starting player: /usr/bin/vlc
[cli.output][debug] Opening subprocess: /usr/bin/vlc --input-title-format twitch.tv/zisteau -
[stream.hls][debug] Download of segment 3 complete
[stream.hls][debug] Download of segment 4 complete
[cli][debug] Writing stream to output
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Adding segment 5 to queue
[stream.hls][debug] Adding segment 6 to queue
[stream.hls][debug] Adding segment 7 to queue
[stream.hls][debug] Download of segment 5 complete
[stream.hls][debug] Download of segment 6 complete
[stream.hls][debug] Download of segment 7 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Adding segment 8 to queue
[stream.hls][debug] Adding segment 9 to queue
[stream.hls][debug] Download of segment 8 complete
[stream.hls][debug] Download of segment 9 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Adding segment 10 to queue
[stream.hls][debug] Adding segment 11 to queue
[stream.hls][debug] Adding segment 12 to queue
[stream.hls][debug] Adding segment 13 to queue
[stream.hls][debug] Download of segment 10 complete
[stream.hls][debug] Download of segment 11 complete
[stream.hls][debug] Download of segment 12 complete
[stream.hls][debug] Download of segment 13 complete
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Adding segment 14 to queue
[stream.hls][debug] Adding segment 15 to queue
[stream.hls][debug] Download of segment 14 complete
[stream.hls][debug] Download of segment 15 complete
```

### Additional comments, screenshots, etc.

I also had streamlink dump a --stream-url and ran wget on the the result:

Contents (longer hex strings [REDACTED]):

```
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:5
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-TWITCH-ELAPSED-SECS:28232.000
#EXT-X-TWITCH-TOTAL-SECS:28242.010
#EXT-X-DATERANGE:ID="stitched-ad-[REDACTED]-[REDACTED]",CLASS="twitch-stitched-ad",START-DATE="2019-03-20T05:17:07.821Z",DURATION=15.164,X-TV-TWITCH-AD-ROLL-TYPE="PREROLL",X-TV-TWITCH-AD-POD-LENGTH="2",X-TV-TWITCH-AD-POD-POSITION="0",X-TV-TWITCH-AD-URL="https://www.hersheys.com/icebreakers/en_us/home.html"
#EXT-X-DISCONTINUITY
#EXT-X-SCTE35-OUT:URL="https://www.hersheys.com/icebreakers/en_us/home.html"
#EXT-X-PROGRAM-DATE-TIME:2019-03-20T05:17:07.821Z
#EXTINF:2.002,[REDACTED]-01-02-2019-23.49.14
https://video-edge-7e9300.sea01.abs.hls.ttvnw.net/v1/segment/[REDACTED].ts
#EXT-X-PROGRAM-DATE-TIME:2019-03-20T05:17:09.822999999Z
#EXTINF:2.002,[REDACTED]-01-02-2019-23.49.14
https://video-edge-7e9300.sea01.abs.hls.ttvnw.net/v1/segment/[REDACTED].ts
#EXT-X-PROGRAM-DATE-TIME:2019-03-20T05:17:11.824999998Z
#EXTINF:2.002,[REDACTED]-01-02-2019-23.49.14
https://video-edge-7e9300.sea01.abs.hls.ttvnw.net/v1/segment/[REDACTED].ts
#EXT-X-PROGRAM-DATE-TIME:2019-03-20T05:17:13.826999997Z
#EXTINF:2.002,[REDACTED]-01-02-2019-23.49.14
https://video-edge-7e9300.sea01.abs.hls.ttvnw.net/v1/segment/[REDACTED].ts
#EXT-X-PROGRAM-DATE-TIME:2019-03-20T05:17:15.828999996Z
#EXTINF:2.002,[REDACTED]-01-02-2019-23.49.14
https://video-edge-7e9300.sea01.abs.hls.ttvnw.net/v1/segment/[REDACTED].ts
```

Some of the redacted streams appear likely to be either session or user-login specific, so to err on the safeside I've [REDACTED] them.

Of also note, the EXTINF date-stamp appears to be referencing one that is wildly divergent from current timestamps.


Discussion:
This is happening to me as well and beyond the annoyance of ads that don't respect your resolution or audio settings (always loads 1080p), it absolutely destroys streamlink audio and video settings when the ad ends.

I constantly have to close and re-open the stream, which plays another ad each time, just crossing my fingers that the audio won't be glitched out or the playback speed messed up. I suspect this might also be introducing even more stream delay too, based on comparing timestamps in chat (pulled through Chatty) to when the streamer reacts to the message on screen.

Also the player closes ("stream ended" message) very often when a streamer plays an ad. Both big and small channels.

Glitched audio takes different forms. Sometimes it "stutters": half second of audio and a half second of silence, constantly. Other times it plays at an increased speed, "chipmunk syndrome."
Are you by any chance using VLC?
These are glitches caused by your player while reading the MPEG-TS stream of Streamlink's concatenated HLS segments. Since ads don't necessarily share the same format as the live stream, this can cause issues in certain players like VLC. MPV has been working fine for me, so you might want to try that instead...

In the HLS stream, the start and end of an ad sequence is annotated with the `#EXT-X-DISCONTINUITY` tag. This is meant for telling the player to reset its decoding/rendering state. That does of course only work if a player is reading the HLS stream directly and implements it correctly. I'm not sure how this can be translated into the way Streamlink currently works. Would remuxing the MPEG-TS stream into a different format help, or do you have any other ideas here, @beardypig?

----

Unless somebody finds a way to request HLS streams which are not using their 
"SureStream" stuff or unless someone finds a way to (re-)request the live segments during an ad sequence, I fear that we won't be able to fix this. Twitch has the upper hand here...
Regarding simply ignoring the ad segments, I don't think this is an option unless we're fine with potentially long gaps in the stream output. It would fix the player issues, though.
It is possible to ignore the **pre-roll** ads, but it would delay the start of the stream by a bit - until twitch starts sending the actual stream data. 

I stream ads are more complicated, we can ignore those too - but it might cause the stream to stop if the ad break to too long. 

I'll take a look at filtering our pre-roll ads. 
Also happening for me
**Please stop posting "me too"**, this doesn't help and only bloats up this thread. We are looking for solutions here...

If you want to get notified on an update, you can subscribe to the thread (the button on the right) or watch the entire repository (the button on top).
I've whipped up a fragile kludge that hides the preroll ads for the moment, based on [back-to's work](https://github.com/streamlink/streamlink/issues/1676#issuecomment-430304591).
```
4d3
< import time
174,199d172
<         reee = re.compile('stitched-ad.*,DURATION=([0-9.]+),')
<         m = reee.search(res.text)
<         n = 1
<         t=0.0
<         tee=0.0
<         while m:
<             if tee > 600.0:
<                 log.error("10 minutes of ads is too long. screw this.")
<                 raise StreamError("ad waiting timeout.")
<             elif n > 10:
<                 log.error("10 ads in a row is too much. screw this.")
<                 raise StreamError("ad waiting tolerance exceeded.")
<             else:
<                 t=float(m.group(1))
<                 log.info("waiting out ad playlist #%02d for %.2f seconds" % (n,t))
<                 time.sleep(t)
<                 tee+=t
<                 res = self.session.http.get(self.stream.url,
<                                             exception=StreamError,
<                                             retries=self.playlist_reload_retries,
<                                             **self.reader.request_params)
<                 m = reee.search(res.text)
<                 n+=1
<         if tee > 0.0:
<             n-=1
<             log.info("waited %.2f seconds on %2d ads." % (tee,n))
990c963
< __plugin__ = Twitch
---
> __plugin__ = Twitch
\ No newline at end of file
```

Works in the latest streamlink development version, at least. Make sure to not use any form of player passthrough, so the TwitchM3U8Parser can do it's thing.

Does that work with in-stream ads?
> Are you by any chance using VLC?

Not the OP, but I'm seeing the same behavior with MPV opening a video window despite using `audio_only`. Is this really two separate issues (video opening despite `audio_only`, and ads causing issues with stream playback)?

@spencerhakim when they injects the ads they always have video, I guess this violates the HLS standard - but that's a twitch.tv issue :) 

They inject whatever video ads they like which disrupts the steam with their adverts - if you have chosen audio_only it's the same thing, but you get unexpected video. As a workaround you can set the video output on `mpv` to null (`--vo null`). Alternatively you can test out my fix, but it's slightly more difficult to apply. 
Seeing this too, not just with Streamlink but also with Twitched app on Roku starting yesterday. So I guess they finally started injecting into the actual video stream.

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
@@ -1,6 +1,8 @@
 # coding=utf-8
+import logging
 import re
 import warnings
+from collections import namedtuple
 from random import random
 
 import requests
@@ -13,6 +15,8 @@
 from streamlink.stream import (
     HTTPStream, HLSStream, FLVPlaylist, extract_flv_header_tags
 )
+from streamlink.stream.hls import HLSStreamReader, HLSStreamWriter, HLSStreamWorker
+from streamlink.stream.hls_playlist import M3U8Parser, load as load_hls_playlist
 from streamlink.utils.times import hours_minutes_seconds
 
 try:
@@ -20,6 +24,9 @@
 except ImportError:
     pass
 
+
+log = logging.getLogger(__name__)
+
 QUALITY_WEIGHTS = {
     "source": 1080,
     "1080": 1080,
@@ -128,6 +135,82 @@
 )
 
 
+Segment = namedtuple("Segment", "uri duration title key discontinuity scte35 byterange date map")
+
+
+class TwitchM3U8Parser(M3U8Parser):
+    def parse_tag_ext_x_scte35_out(self, value):
+        self.state["scte35"] = True
+
+    # unsure if this gets used by Twitch
+    def parse_tag_ext_x_scte35_out_cont(self, value):
+        self.state["scte35"] = True
+
+    def parse_tag_ext_x_scte35_in(self, value):
+        self.state["scte35"] = False
+
+    def get_segment(self, uri):
+        byterange = self.state.pop("byterange", None)
+        extinf = self.state.pop("extinf", (0, None))
+        date = self.state.pop("date", None)
+        map_ = self.state.get("map")
+        key = self.state.get("key")
+        discontinuity = self.state.pop("discontinuity", False)
+        scte35 = self.state.pop("scte35", None)
+
+        return Segment(
+            uri,
+            extinf[0],
+            extinf[1],
+            key,
+            discontinuity,
+            scte35,
+            byterange,
+            date,
+            map_
+        )
+
+
+class TwitchHLSStreamWorker(HLSStreamWorker):
+    def _reload_playlist(self, text, url):
+        return load_hls_playlist(text, url, parser=TwitchM3U8Parser)
+
+
+class TwitchHLSStreamWriter(HLSStreamWriter):
+    def __init__(self, *args, **kwargs):
+        HLSStreamWriter.__init__(self, *args, **kwargs)
+        options = self.session.plugins.get("twitch").options
+        self.disable_ads = options.get("disable-ads")
+        if self.disable_ads:
+            log.info("Will skip ad segments")
+
+    def write(self, sequence, *args, **kwargs):
+        if self.disable_ads:
+            if sequence.segment.scte35 is not None:
+                self.reader.ads = sequence.segment.scte35
+                if self.reader.ads:
+                    log.info("Will skip ads beginning with segment {0}".format(sequence.num))
+                else:
+                    log.info("Will stop skipping ads beginning with segment {0}".format(sequence.num))
+            if self.reader.ads:
+                return
+        return HLSStreamWriter.write(self, sequence, *args, **kwargs)
+
+
+class TwitchHLSStreamReader(HLSStreamReader):
+    __worker__ = TwitchHLSStreamWorker
+    __writer__ = TwitchHLSStreamWriter
+    ads = None
+
+
+class TwitchHLSStream(HLSStream):
+    def open(self):
+        reader = TwitchHLSStreamReader(self)
+        reader.open()
+
+        return reader
+
+
 class UsherService(object):
     def __init__(self, session):
         self.session = session
@@ -277,6 +360,13 @@ class Twitch(Plugin):
                        action="store_true",
                        help="""
         Do not open the stream if the target channel is hosting another channel.
+        """
+                       ),
+        PluginArgument("disable-ads",
+                       action="store_true",
+                       help="""
+        Skip embedded advertisement segments at the beginning or during a stream.
+        Will cause these segments to be missing from the stream.
         """
                        ))
 
@@ -640,9 +730,12 @@ def _get_hls_streams(self, stream_type="live"):
         try:
             # If the stream is a VOD that is still being recorded the stream should start at the
             # beginning of the recording
-            streams = HLSStream.parse_variant_playlist(self.session, url,
-                                                       start_offset=time_offset,
-                                                       force_restart=not stream_type == "live")
+            streams = TwitchHLSStream.parse_variant_playlist(
+                self.session,
+                url,
+                start_offset=time_offset,
+                force_restart=not stream_type == "live"
+            )
         except IOError as err:
             err = str(err)
             if "404 Client Error" in err or "Failed to parse playlist" in err:
diff --git a/src/streamlink/stream/hls.py b/src/streamlink/stream/hls.py
--- a/src/streamlink/stream/hls.py
+++ b/src/streamlink/stream/hls.py
@@ -181,6 +181,9 @@ def __init__(self, *args, **kwargs):
                       self.duration_offset_start, self.duration_limit,
                       self.playlist_sequence, self.playlist_end)
 
+    def _reload_playlist(self, text, url):
+        return hls_playlist.load(text, url)
+
     def reload_playlist(self):
         if self.closed:
             return
@@ -192,7 +195,7 @@ def reload_playlist(self):
                                     retries=self.playlist_reload_retries,
                                     **self.reader.request_params)
         try:
-            playlist = hls_playlist.load(res.text, res.url)
+            playlist = self._reload_playlist(res.text, res.url)
         except ValueError as err:
             raise StreamError(err)
 
@@ -472,8 +475,12 @@ def parse_variant_playlist(cls, session_, url, name_key="name",
                                         duration=duration,
                                         **request_params)
             else:
-                stream = HLSStream(session_, playlist.uri, force_restart=force_restart,
-                                   start_offset=start_offset, duration=duration, **request_params)
+                stream = cls(session_,
+                             playlist.uri,
+                             force_restart=force_restart,
+                             start_offset=start_offset,
+                             duration=duration,
+                             **request_params)
             streams[name_prefix + stream_name] = stream
 
         return streams
diff --git a/src/streamlink/stream/hls_playlist.py b/src/streamlink/stream/hls_playlist.py
--- a/src/streamlink/stream/hls_playlist.py
+++ b/src/streamlink/stream/hls_playlist.py
@@ -19,24 +19,20 @@
 Map = namedtuple("Map", "uri byterange")
 
 # EXT-X-MEDIA
-Media = namedtuple("Media", "uri type group_id language name default "
-                            "autoselect forced characteristics")
+Media = namedtuple("Media", "uri type group_id language name default autoselect forced characteristics")
 
 # EXT-X-START
 Start = namedtuple("Start", "time_offset precise")
 
 # EXT-X-STREAM-INF
-StreamInfo = namedtuple("StreamInfo", "bandwidth program_id codecs resolution "
-                                      "audio video subtitles")
+StreamInfo = namedtuple("StreamInfo", "bandwidth program_id codecs resolution audio video subtitles")
 
 # EXT-X-I-FRAME-STREAM-INF
-IFrameStreamInfo = namedtuple("IFrameStreamInfo", "bandwidth program_id "
-                                                  "codecs resolution video")
+IFrameStreamInfo = namedtuple("IFrameStreamInfo", "bandwidth program_id codecs resolution video")
 
 Playlist = namedtuple("Playlist", "uri stream_info media is_iframe")
 Resolution = namedtuple("Resolution", "width height")
-Segment = namedtuple("Segment", "uri duration title key discontinuity "
-                                "byterange date map")
+Segment = namedtuple("Segment", "uri duration title key discontinuity byterange date map")
 
 
 class M3U8(object):
@@ -65,7 +61,7 @@ class M3U8Parser(object):
     _tag_re = re.compile(r"#(?P<tag>[\w-]+)(:(?P<value>.+))?")
     _res_re = re.compile(r"(\d+)x(\d+)")
 
-    def __init__(self, base_uri=None):
+    def __init__(self, base_uri=None, **kwargs):
         self.base_uri = base_uri
 
     def create_stream_info(self, streaminf, cls=None):
@@ -142,97 +138,107 @@ def parse_resolution(self, value):
 
         return Resolution(width, height)
 
-    def parse_tag(self, line, transform=None):
-        tag, value = self.split_tag(line)
-
-        if transform:
-            value = transform(value)
-
-        return value
+    def parse_tag_extinf(self, value):
+        self.state["expect_segment"] = True
+        self.state["extinf"] = self.parse_extinf(value)
+
+    def parse_tag_ext_x_byterange(self, value):
+        self.state["expect_segment"] = True
+        self.state["byterange"] = self.parse_byterange(value)
+
+    def parse_tag_ext_x_targetduration(self, value):
+        self.m3u8.target_duration = int(value)
+
+    def parse_tag_ext_x_media_sequence(self, value):
+        self.m3u8.media_sequence = int(value)
+
+    def parse_tag_ext_x_key(self, value):
+        attr = self.parse_attributes(value)
+        iv = attr.get("IV")
+        if iv:
+            iv = self.parse_hex(iv)
+        self.state["key"] = Key(attr.get("METHOD"),
+                                self.uri(attr.get("URI")),
+                                iv, attr.get("KEYFORMAT"),
+                                attr.get("KEYFORMATVERSIONS"))
+
+    def parse_tag_ext_x_program_date_time(self, value):
+        self.state["date"] = value
+
+    def parse_tag_ext_x_allow_cache(self, value):
+        self.m3u8.allow_cache = self.parse_bool(value)
+
+    def parse_tag_ext_x_stream_inf(self, value):
+        self.state["streaminf"] = self.parse_attributes(value)
+        self.state["expect_playlist"] = True
+
+    def parse_tag_ext_x_playlist_type(self, value):
+        self.m3u8.playlist_type = value
+
+    def parse_tag_ext_x_endlist(self, value):
+        self.m3u8.is_endlist = True
+
+    def parse_tag_ext_x_media(self, value):
+        attr = self.parse_attributes(value)
+        media = Media(
+            self.uri(attr.get("URI")),
+            attr.get("TYPE"),
+            attr.get("GROUP-ID"),
+            attr.get("LANGUAGE"),
+            attr.get("NAME"),
+            self.parse_bool(attr.get("DEFAULT")),
+            self.parse_bool(attr.get("AUTOSELECT")),
+            self.parse_bool(attr.get("FORCED")),
+            attr.get("CHARACTERISTICS")
+        )
+        self.m3u8.media.append(media)
+
+    def parse_tag_ext_x_discontinuity(self, value):
+        self.state["discontinuity"] = True
+        self.state["map"] = None
+
+    def parse_tag_ext_x_discontinuity_sequence(self, value):
+        self.m3u8.discontinuity_sequence = int(value)
+
+    def parse_tag_ext_x_i_frames_only(self, value):
+        self.m3u8.iframes_only = True
+
+    def parse_tag_ext_x_map(self, value):
+        attr = self.parse_attributes(value)
+        byterange = self.parse_byterange(attr.get("BYTERANGE", ""))
+        self.state["map"] = Map(attr.get("URI"), byterange)
+
+    def parse_tag_ext_x_i_frame_stream_inf(self, value):
+        attr = self.parse_attributes(value)
+        streaminf = self.state.pop("streaminf", attr)
+        stream_info = self.create_stream_info(streaminf, IFrameStreamInfo)
+        playlist = Playlist(self.uri(attr.get("URI")), stream_info, [], True)
+        self.m3u8.playlists.append(playlist)
+
+    def parse_tag_ext_x_version(self, value):
+        self.m3u8.version = int(value)
+
+    def parse_tag_ext_x_start(self, value):
+        attr = self.parse_attributes(value)
+        start = Start(attr.get("TIME-OFFSET"),
+                      self.parse_bool(attr.get("PRECISE", "NO")))
+        self.m3u8.start = start
 
     def parse_line(self, line):
-        if not line.startswith("#"):
-            if self.state.pop("expect_segment", None):
-                byterange = self.state.pop("byterange", None)
-                extinf = self.state.pop("extinf", (0, None))
-                date = self.state.pop("date", None)
-                map_ = self.state.get("map")
-                key = self.state.get("key")
-
-                segment = Segment(self.uri(line), extinf[0],
-                                  extinf[1], key,
-                                  self.state.pop("discontinuity", False),
-                                  byterange, date, map_)
-                self.m3u8.segments.append(segment)
-            elif self.state.pop("expect_playlist", None):
-                streaminf = self.state.pop("streaminf", {})
-                stream_info = self.create_stream_info(streaminf)
-                playlist = Playlist(self.uri(line), stream_info, [], False)
-                self.m3u8.playlists.append(playlist)
-        elif line.startswith("#EXTINF"):
-            self.state["expect_segment"] = True
-            self.state["extinf"] = self.parse_tag(line, self.parse_extinf)
-        elif line.startswith("#EXT-X-BYTERANGE"):
-            self.state["expect_segment"] = True
-            self.state["byterange"] = self.parse_tag(line, self.parse_byterange)
-        elif line.startswith("#EXT-X-TARGETDURATION"):
-            self.m3u8.target_duration = self.parse_tag(line, int)
-        elif line.startswith("#EXT-X-MEDIA-SEQUENCE"):
-            self.m3u8.media_sequence = self.parse_tag(line, int)
-        elif line.startswith("#EXT-X-KEY"):
-            attr = self.parse_tag(line, self.parse_attributes)
-            iv = attr.get("IV")
-            if iv:
-                iv = self.parse_hex(iv)
-            self.state["key"] = Key(attr.get("METHOD"),
-                                    self.uri(attr.get("URI")),
-                                    iv, attr.get("KEYFORMAT"),
-                                    attr.get("KEYFORMATVERSIONS"))
-        elif line.startswith("#EXT-X-PROGRAM-DATE-TIME"):
-            self.state["date"] = self.parse_tag(line)
-        elif line.startswith("#EXT-X-ALLOW-CACHE"):
-            self.m3u8.allow_cache = self.parse_tag(line, self.parse_bool)
-        elif line.startswith("#EXT-X-STREAM-INF"):
-            self.state["streaminf"] = self.parse_tag(line, self.parse_attributes)
-            self.state["expect_playlist"] = True
-        elif line.startswith("#EXT-X-PLAYLIST-TYPE"):
-            self.m3u8.playlist_type = self.parse_tag(line)
-        elif line.startswith("#EXT-X-ENDLIST"):
-            self.m3u8.is_endlist = True
-        elif line.startswith("#EXT-X-MEDIA"):
-            attr = self.parse_tag(line, self.parse_attributes)
-            media = Media(self.uri(attr.get("URI")), attr.get("TYPE"),
-                          attr.get("GROUP-ID"), attr.get("LANGUAGE"),
-                          attr.get("NAME"),
-                          self.parse_bool(attr.get("DEFAULT")),
-                          self.parse_bool(attr.get("AUTOSELECT")),
-                          self.parse_bool(attr.get("FORCED")),
-                          attr.get("CHARACTERISTICS"))
-            self.m3u8.media.append(media)
-        elif line.startswith("#EXT-X-DISCONTINUITY"):
-            self.state["discontinuity"] = True
-            self.state["map"] = None
-        elif line.startswith("#EXT-X-DISCONTINUITY-SEQUENCE"):
-            self.m3u8.discontinuity_sequence = self.parse_tag(line, int)
-        elif line.startswith("#EXT-X-I-FRAMES-ONLY"):
-            self.m3u8.iframes_only = True
-        elif line.startswith("#EXT-X-MAP"):
-            attr = self.parse_tag(line, self.parse_attributes)
-            byterange = self.parse_byterange(attr.get("BYTERANGE", ""))
-            self.state["map"] = Map(attr.get("URI"), byterange)
-        elif line.startswith("#EXT-X-I-FRAME-STREAM-INF"):
-            attr = self.parse_tag(line, self.parse_attributes)
-            streaminf = self.state.pop("streaminf", attr)
-            stream_info = self.create_stream_info(streaminf, IFrameStreamInfo)
-            playlist = Playlist(self.uri(attr.get("URI")), stream_info, [], True)
+        if line.startswith("#"):
+            tag, value = self.split_tag(line)
+            if not tag:
+                return
+            method = "parse_tag_" + tag.lower().replace("-", "_")
+            if not hasattr(self, method):
+                return
+            getattr(self, method)(value)
+        elif self.state.pop("expect_segment", None):
+            segment = self.get_segment(self.uri(line))
+            self.m3u8.segments.append(segment)
+        elif self.state.pop("expect_playlist", None):
+            playlist = self.get_playlist(self.uri(line))
             self.m3u8.playlists.append(playlist)
-        elif line.startswith("#EXT-X-VERSION"):
-            self.m3u8.version = self.parse_tag(line, int)
-        elif line.startswith("#EXT-X-START"):
-            attr = self.parse_tag(line, self.parse_attributes)
-            start = Start(attr.get("TIME-OFFSET"),
-                          self.parse_bool(attr.get("PRECISE", "NO")))
-            self.m3u8.start = start
 
     def parse(self, data):
         self.state = {}
@@ -272,8 +278,32 @@ def uri(self, uri):
         else:
             return uri
 
-
-def load(data, base_uri=None, parser=M3U8Parser):
+    def get_segment(self, uri):
+        byterange = self.state.pop("byterange", None)
+        extinf = self.state.pop("extinf", (0, None))
+        date = self.state.pop("date", None)
+        map_ = self.state.get("map")
+        key = self.state.get("key")
+        discontinuity = self.state.pop("discontinuity", False)
+
+        return Segment(
+            uri,
+            extinf[0],
+            extinf[1],
+            key,
+            discontinuity,
+            byterange,
+            date,
+            map_
+        )
+
+    def get_playlist(self, uri):
+        streaminf = self.state.pop("streaminf", {})
+        stream_info = self.create_stream_info(streaminf)
+        return Playlist(uri, stream_info, [], False)
+
+
+def load(data, base_uri=None, parser=M3U8Parser, **kwargs):
     """Attempts to parse a M3U8 playlist from a string of data.
 
     If specified, *base_uri* is the base URI that relative URIs will
@@ -283,4 +313,4 @@ def load(data, base_uri=None, parser=M3U8Parser):
     to parse the data.
 
     """
-    return parser(base_uri).parse(data)
+    return parser(base_uri, **kwargs).parse(data)


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
@@ -1,6 +1,17 @@
+import logging
 import unittest
+from functools import partial
 
-from streamlink.plugins.twitch import Twitch
+from streamlink.plugins.twitch import Twitch, TwitchHLSStream
+
+import requests_mock
+from tests.mock import call, patch
+
+from streamlink.session import Streamlink
+from tests.resources import text
+
+
+log = logging.getLogger(__name__)
 
 
 class TestPluginTwitch(unittest.TestCase):
@@ -21,3 +32,158 @@ def test_can_handle_url_negative(self):
         ]
         for url in should_not_match:
             self.assertFalse(Twitch.can_handle_url(url))
+
+
+class TestTwitchHLSStream(unittest.TestCase):
+    scte35_out = "#EXT-X-DISCONTINUITY\n#EXT-X-SCTE35-OUT\n"
+    scte35_out_cont = "#EXT-X-SCTE35-OUT-CONT\n"
+    scte35_in = "#EXT-X-DISCONTINUITY\n#EXT-X-SCTE35-IN\n"
+    segment = "#EXTINF:1.000,\nstream{0}.ts\n"
+
+    def getMasterPlaylist(self):
+        with text("hls/test_master.m3u8") as pl:
+            return pl.read()
+
+    def getPlaylist(self, media_sequence, items):
+        playlist = """
+#EXTM3U
+#EXT-X-VERSION:5
+#EXT-X-TARGETDURATION:1
+#EXT-X-MEDIA-SEQUENCE:{0}
+""".format(media_sequence)
+
+        for item in items:
+            if type(item) != int:
+                playlist += item
+            else:
+                playlist += self.segment.format(item)
+
+        return playlist
+
+    def start_streamlink(self, kwargs=None):
+        kwargs = kwargs or {}
+        log.info("Executing streamlink")
+        streamlink = Streamlink()
+
+        streamlink.set_option("hls-live-edge", 4)
+        streamlink.plugins.get("twitch").options.set("disable-ads", True)
+
+        masterStream = TwitchHLSStream.parse_variant_playlist(
+            streamlink,
+            "http://mocked/path/master.m3u8",
+            **kwargs
+        )
+        stream = masterStream["1080p (source)"].open()
+        data = b"".join(iter(partial(stream.read, 8192), b""))
+        stream.close()
+        log.info("End of streamlink execution")
+        return data
+
+    def mock(self, mocked, method, url, *args, **kwargs):
+        mocked[url] = method(url, *args, **kwargs)
+
+    def get_result(self, streams, playlists):
+        mocked = {}
+        with requests_mock.Mocker() as mock:
+            self.mock(mocked, mock.get, "http://mocked/path/master.m3u8", text=self.getMasterPlaylist())
+            self.mock(mocked, mock.get, "http://mocked/path/playlist.m3u8", [{"text": p} for p in playlists])
+            for i, stream in enumerate(streams):
+                self.mock(mocked, mock.get, "http://mocked/path/stream{0}.ts".format(i), content=stream)
+            return self.start_streamlink(), mocked
+
+    @patch("streamlink.plugins.twitch.log")
+    def test_hls_scte35_start_with_end(self, mock_logging):
+        streams = ["[{0}]".format(i).encode("ascii") for i in range(12)]
+        playlists = [
+            self.getPlaylist(0, [self.scte35_out, 0, 1, 2, 3]),
+            self.getPlaylist(4, [self.scte35_in, 4, 5, 6, 7]),
+            self.getPlaylist(8, [8, 9, 10, 11]) + "#EXT-X-ENDLIST\n"
+        ]
+        result, mocked = self.get_result(streams, playlists)
+
+        expected = b''.join(streams[4:12])
+        self.assertEqual(expected, result)
+        for i, _ in enumerate(streams):
+            self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
+        mock_logging.info.assert_has_calls([
+            call("Will skip ad segments"),
+            call("Will skip ads beginning with segment 0"),
+            call("Will stop skipping ads beginning with segment 4")
+        ])
+
+    @patch("streamlink.plugins.twitch.log")
+    def test_hls_scte35_no_start(self, mock_logging):
+        streams = ["[{0}]".format(i).encode("ascii") for i in range(8)]
+        playlists = [
+            self.getPlaylist(0, [0, 1, 2, 3]),
+            self.getPlaylist(4, [self.scte35_in, 4, 5, 6, 7]) + "#EXT-X-ENDLIST\n"
+        ]
+        result, mocked = self.get_result(streams, playlists)
+
+        expected = b''.join(streams[0:8])
+        self.assertEqual(expected, result)
+        for i, _ in enumerate(streams):
+            self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
+        mock_logging.info.assert_has_calls([
+            call("Will skip ad segments")
+        ])
+
+    @patch("streamlink.plugins.twitch.log")
+    def test_hls_scte35_no_start_with_cont(self, mock_logging):
+        streams = ["[{0}]".format(i).encode("ascii") for i in range(8)]
+        playlists = [
+            self.getPlaylist(0, [self.scte35_out_cont, 0, 1, 2, 3]),
+            self.getPlaylist(4, [self.scte35_in, 4, 5, 6, 7]) + "#EXT-X-ENDLIST\n"
+        ]
+        result, mocked = self.get_result(streams, playlists)
+
+        expected = b''.join(streams[4:8])
+        self.assertEqual(expected, result)
+        for i, _ in enumerate(streams):
+            self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
+        mock_logging.info.assert_has_calls([
+            call("Will skip ad segments"),
+            call("Will skip ads beginning with segment 0"),
+            call("Will stop skipping ads beginning with segment 4")
+        ])
+
+    @patch("streamlink.plugins.twitch.log")
+    def test_hls_scte35_no_end(self, mock_logging):
+        streams = ["[{0}]".format(i).encode("ascii") for i in range(12)]
+        playlists = [
+            self.getPlaylist(0, [0, 1, 2, 3]),
+            self.getPlaylist(4, [self.scte35_out, 4, 5, 6, 7]),
+            self.getPlaylist(8, [8, 9, 10, 11]) + "#EXT-X-ENDLIST\n"
+        ]
+        result, mocked = self.get_result(streams, playlists)
+
+        expected = b''.join(streams[0:4])
+        self.assertEqual(expected, result)
+        for i, _ in enumerate(streams):
+            self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
+        mock_logging.info.assert_has_calls([
+            call("Will skip ad segments"),
+            call("Will skip ads beginning with segment 4")
+        ])
+
+    @patch("streamlink.plugins.twitch.log")
+    def test_hls_scte35_in_between(self, mock_logging):
+        streams = ["[{0}]".format(i).encode("ascii") for i in range(20)]
+        playlists = [
+            self.getPlaylist(0, [0, 1, 2, 3]),
+            self.getPlaylist(4, [4, 5, self.scte35_out, 6, 7]),
+            self.getPlaylist(8, [8, 9, 10, 11]),
+            self.getPlaylist(12, [12, 13, self.scte35_in, 14, 15]),
+            self.getPlaylist(16, [16, 17, 18, 19]) + "#EXT-X-ENDLIST\n"
+        ]
+        result, mocked = self.get_result(streams, playlists)
+
+        expected = b''.join(streams[0:6]) + b''.join(streams[14:20])
+        self.assertEqual(expected, result)
+        for i, _ in enumerate(streams):
+            self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
+        mock_logging.info.assert_has_calls([
+            call("Will skip ad segments"),
+            call("Will skip ads beginning with segment 6"),
+            call("Will stop skipping ads beginning with segment 14")
+        ])