Skip to content

Commit ebb3f77

Browse files
committed
plugins.twitch: implement disable-ads parameter
- Implement TwitchHLSStream{,Worker,Writer,Reader} and TwitchM3U8Parser - Skip HLS segments between SCTE35-OUT{,-CONT} and SCTE35-IN tags - Add --twitch-disable-ads parameter (plugin specific) - Add tests
1 parent 714ce9f commit ebb3f77

File tree

2 files changed

+223
-4
lines changed

2 files changed

+223
-4
lines changed

src/streamlink/plugins/twitch.py

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# coding=utf-8
2+
import logging
23
import re
34
import warnings
5+
from collections import namedtuple
46
from random import random
57

68
import requests
@@ -13,13 +15,18 @@
1315
from streamlink.stream import (
1416
HTTPStream, HLSStream, FLVPlaylist, extract_flv_header_tags
1517
)
18+
from streamlink.stream.hls import HLSStreamReader, HLSStreamWriter, HLSStreamWorker
19+
from streamlink.stream.hls_playlist import M3U8Parser, load as load_hls_playlist
1620
from streamlink.utils.times import hours_minutes_seconds
1721

1822
try:
1923
from itertools import izip as zip
2024
except ImportError:
2125
pass
2226

27+
28+
log = logging.getLogger(__name__)
29+
2330
QUALITY_WEIGHTS = {
2431
"source": 1080,
2532
"1080": 1080,
@@ -128,6 +135,82 @@
128135
)
129136

130137

138+
Segment = namedtuple("Segment", "uri duration title key discontinuity scte35 byterange date map")
139+
140+
141+
class TwitchM3U8Parser(M3U8Parser):
142+
def parse_tag_ext_x_scte35_out(self, value):
143+
self.state["scte35"] = True
144+
145+
# unsure if this gets used by Twitch
146+
def parse_tag_ext_x_scte35_out_cont(self, value):
147+
self.state["scte35"] = True
148+
149+
def parse_tag_ext_x_scte35_in(self, value):
150+
self.state["scte35"] = False
151+
152+
def get_segment(self, uri):
153+
byterange = self.state.pop("byterange", None)
154+
extinf = self.state.pop("extinf", (0, None))
155+
date = self.state.pop("date", None)
156+
map_ = self.state.get("map")
157+
key = self.state.get("key")
158+
discontinuity = self.state.pop("discontinuity", False)
159+
scte35 = self.state.pop("scte35", None)
160+
161+
return Segment(
162+
uri,
163+
extinf[0],
164+
extinf[1],
165+
key,
166+
discontinuity,
167+
scte35,
168+
byterange,
169+
date,
170+
map_
171+
)
172+
173+
174+
class TwitchHLSStreamWorker(HLSStreamWorker):
175+
scte35 = None
176+
177+
def _reload_playlist(self, text, url):
178+
return load_hls_playlist(text, url, parser=TwitchM3U8Parser)
179+
180+
181+
class TwitchHLSStreamWriter(HLSStreamWriter):
182+
def __init__(self, *args, **kwargs):
183+
HLSStreamWriter.__init__(self, *args, **kwargs)
184+
self.disable_ads = self.stream.options.get("disable-ads")
185+
if self.disable_ads:
186+
log.info("Will skip ad segments")
187+
188+
def fetch(self, sequence, retries=None):
189+
if self.closed or not retries:
190+
return
191+
if sequence.segment.scte35 is not None:
192+
self.reader.worker.scte35 = sequence.segment.scte35
193+
if self.disable_ads and self.reader.worker.scte35 is True:
194+
log.debug("Skipping ad segment {0}".format(sequence.num))
195+
return
196+
return HLSStreamWriter.fetch(self, sequence, retries=retries)
197+
198+
199+
class TwitchHLSStreamReader(HLSStreamReader):
200+
__worker__ = TwitchHLSStreamWorker
201+
__writer__ = TwitchHLSStreamWriter
202+
203+
204+
class TwitchHLSStream(HLSStream):
205+
__shortname__ = "hls-twitch"
206+
207+
def open(self):
208+
reader = TwitchHLSStreamReader(self)
209+
reader.open()
210+
211+
return reader
212+
213+
131214
class UsherService(object):
132215
def __init__(self, session):
133216
self.session = session
@@ -277,6 +360,13 @@ class Twitch(Plugin):
277360
action="store_true",
278361
help="""
279362
Do not open the stream if the target channel is hosting another channel.
363+
"""
364+
),
365+
PluginArgument("disable-ads",
366+
action="store_true",
367+
help="""
368+
Skip embedded advertisement segments at the beginning or during a stream.
369+
Will cause these segments to be missing from the stream.
280370
"""
281371
))
282372

@@ -640,9 +730,13 @@ def _get_hls_streams(self, stream_type="live"):
640730
try:
641731
# If the stream is a VOD that is still being recorded the stream should start at the
642732
# beginning of the recording
643-
streams = HLSStream.parse_variant_playlist(self.session, url,
644-
start_offset=time_offset,
645-
force_restart=not stream_type == "live")
733+
streams = TwitchHLSStream.parse_variant_playlist(
734+
self.session,
735+
url,
736+
start_offset=time_offset,
737+
force_restart=not stream_type == "live",
738+
options=self.options
739+
)
646740
except IOError as err:
647741
err = str(err)
648742
if "404 Client Error" in err or "Failed to parse playlist" in err:

tests/plugins/test_twitch.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import logging
12
import unittest
3+
from functools import partial
24

3-
from streamlink.plugins.twitch import Twitch
5+
from streamlink.plugins.twitch import Twitch, TwitchHLSStream
6+
7+
import requests_mock
8+
9+
from streamlink.session import Streamlink
10+
from tests.resources import text
11+
12+
13+
log = logging.getLogger(__name__)
414

515

616
class TestPluginTwitch(unittest.TestCase):
@@ -21,3 +31,118 @@ def test_can_handle_url_negative(self):
2131
]
2232
for url in should_not_match:
2333
self.assertFalse(Twitch.can_handle_url(url))
34+
35+
36+
class TestTwitchHLSStream(unittest.TestCase):
37+
scte35_out = "#EXT-X-DISCONTINUITY\n#EXT-X-SCTE35-OUT\n"
38+
scte35_out_cont = "#EXT-X-SCTE35-OUT-CONT\n"
39+
scte35_in = "#EXT-X-DISCONTINUITY\n#EXT-X-SCTE35-IN\n"
40+
segment = "#EXTINF:1.000,\nstream{0}.ts\n"
41+
42+
def getMasterPlaylist(self):
43+
with text("hls/test_master.m3u8") as pl:
44+
return pl.read()
45+
46+
def getPlaylist(self, media_sequence, items):
47+
playlist = """
48+
#EXTM3U
49+
#EXT-X-VERSION:5
50+
#EXT-X-TARGETDURATION:1
51+
#EXT-X-MEDIA-SEQUENCE:{0}
52+
""".format(media_sequence)
53+
54+
for item in items:
55+
if type(item) != int:
56+
playlist += item
57+
else:
58+
playlist += self.segment.format(item)
59+
60+
return playlist
61+
62+
def start_streamlink(self, kwargs=None):
63+
kwargs = kwargs or {}
64+
log.info("Executing streamlink")
65+
streamlink = Streamlink()
66+
67+
streamlink.set_option("hls-live-edge", 4)
68+
# streamlink.set_option("twitch-disable-ads", True)
69+
70+
masterStream = TwitchHLSStream.parse_variant_playlist(
71+
streamlink,
72+
"http://mocked/path/master.m3u8",
73+
options={"disable-ads": True},
74+
**kwargs
75+
)
76+
stream = masterStream["1080p (source)"].open()
77+
data = b"".join(iter(partial(stream.read, 8192), b""))
78+
stream.close()
79+
log.info("End of streamlink execution")
80+
return data
81+
82+
def get_result(self, streams, playlists):
83+
with requests_mock.Mocker() as mock:
84+
mock.get("http://mocked/path/master.m3u8", text=self.getMasterPlaylist())
85+
mock.get("http://mocked/path/playlist.m3u8", [{"text": playlist} for playlist in playlists])
86+
for i, stream in enumerate(streams):
87+
mock.get("http://mocked/path/stream{0}.ts".format(i), content=stream)
88+
return self.start_streamlink()
89+
90+
def test_hls_scte35_start_with_end(self):
91+
streams = ["[{0}]".format(i).encode("ascii") for i in range(12)]
92+
playlists = [
93+
self.getPlaylist(0, [self.scte35_out, 0, 1, 2, 3]),
94+
self.getPlaylist(4, [self.scte35_in, 4, 5, 6, 7]),
95+
self.getPlaylist(8, [8, 9, 10, 11]) + "#EXT-X-ENDLIST\n"
96+
]
97+
result = self.get_result(streams, playlists)
98+
99+
expected = b''.join(streams[4:12])
100+
self.assertEqual(expected, result)
101+
102+
def test_hls_scte35_no_start(self):
103+
streams = ["[{0}]".format(i).encode("ascii") for i in range(8)]
104+
playlists = [
105+
self.getPlaylist(0, [0, 1, 2, 3]),
106+
self.getPlaylist(4, [self.scte35_in, 4, 5, 6, 7]) + "#EXT-X-ENDLIST\n"
107+
]
108+
result = self.get_result(streams, playlists)
109+
110+
expected = b''.join(streams[0:8])
111+
self.assertEqual(expected, result)
112+
113+
def test_hls_scte35_no_start_with_cont(self):
114+
streams = ["[{0}]".format(i).encode("ascii") for i in range(8)]
115+
playlists = [
116+
self.getPlaylist(0, [self.scte35_out_cont, 0, 1, 2, 3]),
117+
self.getPlaylist(4, [self.scte35_in, 4, 5, 6, 7]) + "#EXT-X-ENDLIST\n"
118+
]
119+
result = self.get_result(streams, playlists)
120+
121+
expected = b''.join(streams[4:8])
122+
self.assertEqual(expected, result)
123+
124+
def test_hls_scte35_no_end(self):
125+
streams = ["[{0}]".format(i).encode("ascii") for i in range(12)]
126+
playlists = [
127+
self.getPlaylist(0, [0, 1, 2, 3]),
128+
self.getPlaylist(4, [self.scte35_out, 4, 5, 6, 7]),
129+
self.getPlaylist(8, [8, 9, 10, 11]) + "#EXT-X-ENDLIST\n"
130+
]
131+
result = self.get_result(streams, playlists)
132+
133+
expected = b''.join(streams[0:4])
134+
self.assertEqual(expected, result)
135+
136+
def test_hls_scte35_in_between(self):
137+
streams = ["[{0}]".format(i).encode("ascii") for i in range(20)]
138+
playlists = [
139+
self.getPlaylist(0, [0, 1, 2, 3]),
140+
self.getPlaylist(4, [4, 5, self.scte35_out, 6, 7]),
141+
self.getPlaylist(8, [8, 9, 10, 11]),
142+
self.getPlaylist(12, [12, 13, self.scte35_in, 14, 15]),
143+
self.getPlaylist(16, [16, 17, 18, 19]) + "#EXT-X-ENDLIST\n"
144+
]
145+
result = self.get_result(streams, playlists)
146+
147+
expected = b''.join(streams[0:6]) + b''.join(streams[14:20])
148+
self.assertEqual(expected, result)

0 commit comments

Comments
 (0)