Skip to content

Commit 94edeb0

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 8f93831 commit 94edeb0

File tree

2 files changed

+263
-4
lines changed

2 files changed

+263
-4
lines changed

src/streamlink/plugins/twitch.py

Lines changed: 96 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+
def _reload_playlist(self, text, url):
176+
return load_hls_playlist(text, url, parser=TwitchM3U8Parser)
177+
178+
179+
class TwitchHLSStreamWriter(HLSStreamWriter):
180+
def __init__(self, *args, **kwargs):
181+
HLSStreamWriter.__init__(self, *args, **kwargs)
182+
options = self.session.plugins.get("twitch").options
183+
self.disable_ads = options.get("disable-ads")
184+
if self.disable_ads:
185+
log.info("Will skip ad segments")
186+
187+
def write(self, sequence, *args, **kwargs):
188+
if self.disable_ads:
189+
if sequence.segment.scte35 is not None:
190+
self.reader.ads = sequence.segment.scte35
191+
if self.reader.ads:
192+
log.info("Will skip ads beginning with segment {0}".format(sequence.num))
193+
else:
194+
log.info("Will stop skipping ads beginning with segment {0}".format(sequence.num))
195+
if self.reader.ads:
196+
return
197+
return HLSStreamWriter.write(self, sequence, *args, **kwargs)
198+
199+
200+
class TwitchHLSStreamReader(HLSStreamReader):
201+
__worker__ = TwitchHLSStreamWorker
202+
__writer__ = TwitchHLSStreamWriter
203+
ads = None
204+
205+
206+
class TwitchHLSStream(HLSStream):
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,12 @@ 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+
)
646739
except IOError as err:
647740
err = str(err)
648741
if "404 Client Error" in err or "Failed to parse playlist" in err:

tests/plugins/test_twitch.py

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
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+
from tests.mock import call, patch
9+
10+
from streamlink.session import Streamlink
11+
from tests.resources import text
12+
13+
14+
log = logging.getLogger(__name__)
415

516

617
class TestPluginTwitch(unittest.TestCase):
@@ -21,3 +32,158 @@ def test_can_handle_url_negative(self):
2132
]
2233
for url in should_not_match:
2334
self.assertFalse(Twitch.can_handle_url(url))
35+
36+
37+
class TestTwitchHLSStream(unittest.TestCase):
38+
scte35_out = "#EXT-X-DISCONTINUITY\n#EXT-X-SCTE35-OUT\n"
39+
scte35_out_cont = "#EXT-X-SCTE35-OUT-CONT\n"
40+
scte35_in = "#EXT-X-DISCONTINUITY\n#EXT-X-SCTE35-IN\n"
41+
segment = "#EXTINF:1.000,\nstream{0}.ts\n"
42+
43+
def getMasterPlaylist(self):
44+
with text("hls/test_master.m3u8") as pl:
45+
return pl.read()
46+
47+
def getPlaylist(self, media_sequence, items):
48+
playlist = """
49+
#EXTM3U
50+
#EXT-X-VERSION:5
51+
#EXT-X-TARGETDURATION:1
52+
#EXT-X-MEDIA-SEQUENCE:{0}
53+
""".format(media_sequence)
54+
55+
for item in items:
56+
if type(item) != int:
57+
playlist += item
58+
else:
59+
playlist += self.segment.format(item)
60+
61+
return playlist
62+
63+
def start_streamlink(self, kwargs=None):
64+
kwargs = kwargs or {}
65+
log.info("Executing streamlink")
66+
streamlink = Streamlink()
67+
68+
streamlink.set_option("hls-live-edge", 4)
69+
streamlink.plugins.get("twitch").options.set("disable-ads", True)
70+
71+
masterStream = TwitchHLSStream.parse_variant_playlist(
72+
streamlink,
73+
"http://mocked/path/master.m3u8",
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 mock(self, mocked, method, url, *args, **kwargs):
83+
mocked[url] = method(url, *args, **kwargs)
84+
85+
def get_result(self, streams, playlists):
86+
mocked = {}
87+
with requests_mock.Mocker() as mock:
88+
self.mock(mocked, mock.get, "http://mocked/path/master.m3u8", text=self.getMasterPlaylist())
89+
self.mock(mocked, mock.get, "http://mocked/path/playlist.m3u8", [{"text": p} for p in playlists])
90+
for i, stream in enumerate(streams):
91+
self.mock(mocked, mock.get, "http://mocked/path/stream{0}.ts".format(i), content=stream)
92+
return self.start_streamlink(), mocked
93+
94+
@patch("streamlink.plugins.twitch.log")
95+
def test_hls_scte35_start_with_end(self, mock_logging):
96+
streams = ["[{0}]".format(i).encode("ascii") for i in range(12)]
97+
playlists = [
98+
self.getPlaylist(0, [self.scte35_out, 0, 1, 2, 3]),
99+
self.getPlaylist(4, [self.scte35_in, 4, 5, 6, 7]),
100+
self.getPlaylist(8, [8, 9, 10, 11]) + "#EXT-X-ENDLIST\n"
101+
]
102+
result, mocked = self.get_result(streams, playlists)
103+
104+
expected = b''.join(streams[4:12])
105+
self.assertEqual(expected, result)
106+
for i, _ in enumerate(streams):
107+
self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
108+
mock_logging.info.assert_has_calls([
109+
call("Will skip ad segments"),
110+
call("Will skip ads beginning with segment 0"),
111+
call("Will stop skipping ads beginning with segment 4")
112+
])
113+
114+
@patch("streamlink.plugins.twitch.log")
115+
def test_hls_scte35_no_start(self, mock_logging):
116+
streams = ["[{0}]".format(i).encode("ascii") for i in range(8)]
117+
playlists = [
118+
self.getPlaylist(0, [0, 1, 2, 3]),
119+
self.getPlaylist(4, [self.scte35_in, 4, 5, 6, 7]) + "#EXT-X-ENDLIST\n"
120+
]
121+
result, mocked = self.get_result(streams, playlists)
122+
123+
expected = b''.join(streams[0:8])
124+
self.assertEqual(expected, result)
125+
for i, _ in enumerate(streams):
126+
self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
127+
mock_logging.info.assert_has_calls([
128+
call("Will skip ad segments")
129+
])
130+
131+
@patch("streamlink.plugins.twitch.log")
132+
def test_hls_scte35_no_start_with_cont(self, mock_logging):
133+
streams = ["[{0}]".format(i).encode("ascii") for i in range(8)]
134+
playlists = [
135+
self.getPlaylist(0, [self.scte35_out_cont, 0, 1, 2, 3]),
136+
self.getPlaylist(4, [self.scte35_in, 4, 5, 6, 7]) + "#EXT-X-ENDLIST\n"
137+
]
138+
result, mocked = self.get_result(streams, playlists)
139+
140+
expected = b''.join(streams[4:8])
141+
self.assertEqual(expected, result)
142+
for i, _ in enumerate(streams):
143+
self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
144+
mock_logging.info.assert_has_calls([
145+
call("Will skip ad segments"),
146+
call("Will skip ads beginning with segment 0"),
147+
call("Will stop skipping ads beginning with segment 4")
148+
])
149+
150+
@patch("streamlink.plugins.twitch.log")
151+
def test_hls_scte35_no_end(self, mock_logging):
152+
streams = ["[{0}]".format(i).encode("ascii") for i in range(12)]
153+
playlists = [
154+
self.getPlaylist(0, [0, 1, 2, 3]),
155+
self.getPlaylist(4, [self.scte35_out, 4, 5, 6, 7]),
156+
self.getPlaylist(8, [8, 9, 10, 11]) + "#EXT-X-ENDLIST\n"
157+
]
158+
result, mocked = self.get_result(streams, playlists)
159+
160+
expected = b''.join(streams[0:4])
161+
self.assertEqual(expected, result)
162+
for i, _ in enumerate(streams):
163+
self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
164+
mock_logging.info.assert_has_calls([
165+
call("Will skip ad segments"),
166+
call("Will skip ads beginning with segment 4")
167+
])
168+
169+
@patch("streamlink.plugins.twitch.log")
170+
def test_hls_scte35_in_between(self, mock_logging):
171+
streams = ["[{0}]".format(i).encode("ascii") for i in range(20)]
172+
playlists = [
173+
self.getPlaylist(0, [0, 1, 2, 3]),
174+
self.getPlaylist(4, [4, 5, self.scte35_out, 6, 7]),
175+
self.getPlaylist(8, [8, 9, 10, 11]),
176+
self.getPlaylist(12, [12, 13, self.scte35_in, 14, 15]),
177+
self.getPlaylist(16, [16, 17, 18, 19]) + "#EXT-X-ENDLIST\n"
178+
]
179+
result, mocked = self.get_result(streams, playlists)
180+
181+
expected = b''.join(streams[0:6]) + b''.join(streams[14:20])
182+
self.assertEqual(expected, result)
183+
for i, _ in enumerate(streams):
184+
self.assertTrue(mocked["http://mocked/path/stream{0}.ts".format(i)].called)
185+
mock_logging.info.assert_has_calls([
186+
call("Will skip ad segments"),
187+
call("Will skip ads beginning with segment 6"),
188+
call("Will stop skipping ads beginning with segment 14")
189+
])

0 commit comments

Comments
 (0)