streamlink/tests/stream/test_hls.py

693 lines
29 KiB
Python

import os
import typing
import unittest
from threading import Event
from unittest.mock import Mock, call, patch
import pytest
import requests_mock
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from streamlink.session import Streamlink
from streamlink.stream.hls import HLSStream, HLSStreamReader
from streamlink.stream.hls_playlist import M3U8Parser
from tests.mixins.stream_hls import EventedHLSStreamWriter, Playlist, Segment, Tag, TestMixinStreamHLS
from tests.resources import text
class EncryptedBase:
content: bytes
content_plain: bytes
def __init__(self, num, key, iv, *args, content=None, padding=b"", append=b"", **kwargs):
super().__init__(num, *args, **kwargs)
aesCipher = AES.new(key, AES.MODE_CBC, iv)
content = self.content if content is None else content
padded = content + padding if padding else pad(content, AES.block_size, style="pkcs7")
self.content_plain = content
self.content = aesCipher.encrypt(padded) + append
class TagMap(Tag):
def __init__(self, num, namespace, attrs=None):
self.path = f"map{num}"
self.content = f"[map{num}]".encode("ascii")
super().__init__("EXT-X-MAP", {
"URI": self.val_quoted_string(self.url(namespace)),
**(attrs or {}),
})
class TagMapEnc(EncryptedBase, TagMap):
pass
class TagKey(Tag):
path = "encryption.key"
def __init__(self, method="NONE", uri=None, iv=None, keyformat=None, keyformatversions=None):
attrs = {"METHOD": method}
if uri is not False: # pragma: no branch
attrs["URI"] = lambda tag, namespace: tag.val_quoted_string(tag.url(namespace))
if iv is not None: # pragma: no branch
attrs["IV"] = self.val_hex(iv)
if keyformat is not None: # pragma: no branch
attrs["KEYFORMAT"] = self.val_quoted_string(keyformat)
if keyformatversions is not None: # pragma: no branch
attrs["KEYFORMATVERSIONS"] = self.val_quoted_string(keyformatversions)
super().__init__("EXT-X-KEY", attrs)
self.uri = uri
def url(self, namespace):
return self.uri.format(namespace=namespace) if self.uri else super().url(namespace)
class SegmentEnc(EncryptedBase, Segment):
pass
class TestHLSStreamRepr(unittest.TestCase):
def test_repr(self):
session = Streamlink()
stream = HLSStream(session, "https://foo.bar/playlist.m3u8")
assert repr(stream) == "<HLSStream ['hls', 'https://foo.bar/playlist.m3u8']>"
stream = HLSStream(session, "https://foo.bar/playlist.m3u8", "https://foo.bar/master.m3u8")
assert repr(stream) == "<HLSStream ['hls', 'https://foo.bar/playlist.m3u8', 'https://foo.bar/master.m3u8']>"
class TestHLSVariantPlaylist(unittest.TestCase):
@classmethod
def get_master_playlist(cls, playlist):
with text(playlist) as pl:
return pl.read()
def subject(self, playlist, options=None):
with requests_mock.Mocker() as mock:
url = f"http://mocked/{self.id()}/master.m3u8"
content = self.get_master_playlist(playlist)
mock.get(url, text=content)
session = Streamlink(options)
return HLSStream.parse_variant_playlist(session, url)
def test_variant_playlist(self):
streams = self.subject("hls/test_master.m3u8")
assert list(streams.keys()) == ["720p", "720p_alt", "480p", "360p", "160p", "1080p (source)", "90k"]
assert all(isinstance(stream, HLSStream) for stream in streams.values())
assert all(stream.multivariant is not None and stream.multivariant.is_master for stream in streams.values())
base = f"http://mocked/{self.id()}"
stream = next(iter(streams.values()))
assert repr(stream) == f"<HLSStream ['hls', '{base}/720p.m3u8', '{base}/master.m3u8']>"
assert stream.multivariant is not None
assert stream.multivariant.uri == f"{base}/master.m3u8"
assert stream.url_master == f"{base}/master.m3u8"
def test_url_master(self):
session = Streamlink()
stream = HLSStream(session, "http://mocked/foo", url_master="http://mocked/master.m3u8")
assert stream.multivariant is None
assert stream.url == "http://mocked/foo"
assert stream.url_master == "http://mocked/master.m3u8"
class EventedHLSReader(HLSStreamReader):
__writer__ = EventedHLSStreamWriter
class EventedHLSStream(HLSStream):
__reader__ = EventedHLSReader
@patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
class TestHLSStream(TestMixinStreamHLS, unittest.TestCase):
def get_session(self, options=None, *args, **kwargs):
session = super().get_session(options)
session.set_option("hls-live-edge", 3)
return session
def test_offset_and_duration(self):
thread, segments = self.subject([
Playlist(1234, [Segment(0), Segment(1, duration=0.5), Segment(2, duration=0.5), Segment(3)], end=True),
], streamoptions={"start_offset": 1, "duration": 1})
data = self.await_read(read_all=True)
assert data == self.content(segments, cond=lambda s: 0 < s.num < 3), "Respects the offset and duration"
assert all(self.called(s) for s in segments.values() if 0 < s.num < 3), "Downloads second and third segment"
assert not any(self.called(s) for s in segments.values() if 0 > s.num > 3), "Skips other segments"
def test_map(self):
discontinuity = Tag("EXT-X-DISCONTINUITY")
map1 = TagMap(1, self.id())
map2 = TagMap(2, self.id())
self.mock("GET", self.url(map1), content=map1.content)
self.mock("GET", self.url(map2), content=map2.content)
thread, segments = self.subject([
Playlist(0, [map1, Segment(0), Segment(1), Segment(2), Segment(3)]),
Playlist(4, [map1, Segment(4), map2, Segment(5), Segment(6), discontinuity, Segment(7)], end=True),
])
data = self.await_read(read_all=True, timeout=None)
assert data == self.content([
map1, segments[1], map1, segments[2], map1, segments[3],
map1, segments[4], map2, segments[5], map2, segments[6], segments[7],
])
assert self.called(map1, once=True), "Downloads first map only once"
assert self.called(map2, once=True), "Downloads second map only once"
@patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
class TestHLSStreamByterange(TestMixinStreamHLS, unittest.TestCase):
__stream__ = EventedHLSStream
# The dummy segments in the error tests are required because the writer's run loop would otherwise continue forever
# due to the segment's future result being None (no requests result), and we can't await the end of the stream
# without waiting for the stream's timeout error. The dummy segments ensure that we can call await_write for these
# successful segments, so we can close the stream afterwards and safely make the test assertions.
# The EventedHLSStreamWriter could also implement await_fetch, but this is unnecessarily more complex than it already is.
@patch("streamlink.stream.hls.log")
def test_unknown_offset(self, mock_log: Mock):
thread, _ = self.subject([
Playlist(0, [
Tag("EXT-X-BYTERANGE", "3"), Segment(0),
Segment(1),
], end=True),
])
self.await_write(2 - 1)
self.thread.close()
assert mock_log.error.call_args_list == [
call("Failed to fetch segment 0: Missing BYTERANGE offset"),
]
assert not self.called(Segment(0))
@patch("streamlink.stream.hls.log")
def test_unknown_offset_map(self, mock_log: Mock):
map1 = TagMap(1, self.id(), {"BYTERANGE": "\"1234\""})
self.mock("GET", self.url(map1), content=map1.content)
thread, _ = self.subject([
Playlist(0, [
Segment(0),
map1,
Segment(1),
], end=True),
])
self.await_write(3 - 1)
self.thread.close()
assert mock_log.error.call_args_list == [
call("Failed to fetch map for segment 1: Missing BYTERANGE offset"),
]
assert not self.called(map1)
@patch("streamlink.stream.hls.log")
def test_invalid_offset_reference(self, mock_log: Mock):
thread, _ = self.subject([
Playlist(0, [
Tag("EXT-X-BYTERANGE", "3@0"), Segment(0),
Segment(1),
Tag("EXT-X-BYTERANGE", "5"), Segment(2),
Segment(3),
], end=True),
])
self.await_write(4 - 1)
self.thread.close()
assert mock_log.error.call_args_list == [
call("Failed to fetch segment 2: Missing BYTERANGE offset"),
]
assert self.mocks[self.url(Segment(0))].last_request._request.headers["Range"] == "bytes=0-2"
assert not self.called(Segment(2))
def test_offsets(self):
map1 = TagMap(1, self.id(), {"BYTERANGE": "\"1234@0\""})
map2 = TagMap(2, self.id(), {"BYTERANGE": "\"42@1337\""})
self.mock("GET", self.url(map1), content=map1.content)
self.mock("GET", self.url(map2), content=map2.content)
s1, s2, s3, s4, s5 = Segment(0), Segment(1), Segment(2), Segment(3), Segment(4)
self.subject([
Playlist(0, [
map1,
Tag("EXT-X-BYTERANGE", "5@3"), s1,
Tag("EXT-X-BYTERANGE", "7"), s2,
map2,
Tag("EXT-X-BYTERANGE", "11"), s3,
Tag("EXT-X-BYTERANGE", "17@13"), s4,
Tag("EXT-X-BYTERANGE", "19"), s5,
], end=True),
])
self.await_write(5 * 2)
self.await_read(read_all=True)
assert self.mocks[self.url(map1)].last_request._request.headers["Range"] == "bytes=0-1233"
assert self.mocks[self.url(map2)].last_request._request.headers["Range"] == "bytes=1337-1378"
assert self.mocks[self.url(s1)].last_request._request.headers["Range"] == "bytes=3-7"
assert self.mocks[self.url(s2)].last_request._request.headers["Range"] == "bytes=8-14"
assert self.mocks[self.url(s3)].last_request._request.headers["Range"] == "bytes=15-25"
assert self.mocks[self.url(s4)].last_request._request.headers["Range"] == "bytes=13-29"
assert self.mocks[self.url(s5)].last_request._request.headers["Range"] == "bytes=30-48"
@patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
class TestHLSStreamEncrypted(TestMixinStreamHLS, unittest.TestCase):
__stream__ = EventedHLSStream
def get_session(self, options=None, *args, **kwargs):
session = super().get_session(options)
session.set_option("hls-live-edge", 3)
session.set_option("http-headers", {"X-FOO": "BAR"})
return session
def gen_key(self, aes_key=None, aes_iv=None, method="AES-128", uri=None, keyformat="identity", keyformatversions=1):
aes_key = aes_key or os.urandom(16)
aes_iv = aes_iv or os.urandom(16)
key = TagKey(method=method, uri=uri, iv=aes_iv, keyformat=keyformat, keyformatversions=keyformatversions)
self.mock("GET", key.url(self.id()), content=aes_key)
return aes_key, aes_iv, key
@patch("streamlink.stream.hls.log")
def test_hls_encrypted_invalid_method(self, mock_log: Mock):
aesKey, aesIv, key = self.gen_key(method="INVALID")
self.subject([
Playlist(0, [key, SegmentEnc(1, aesKey, aesIv)], end=True),
])
self.await_write()
assert self.thread.reader.writer.closed
assert b"".join(self.thread.data) == b""
assert mock_log.error.mock_calls == [
call("Failed to create decryptor: Unable to decrypt cipher INVALID"),
]
@patch("streamlink.stream.hls.log")
def test_hls_encrypted_missing_uri(self, mock_log: Mock):
aesKey, aesIv, key = self.gen_key(uri=False)
self.subject([
Playlist(0, [key, SegmentEnc(1, aesKey, aesIv)], end=True),
])
self.await_write()
assert self.thread.reader.writer.closed
assert b"".join(self.thread.data) == b""
assert mock_log.error.mock_calls == [
call("Failed to create decryptor: Missing URI for decryption key"),
]
def test_hls_encrypted_aes128(self):
aesKey, aesIv, key = self.gen_key()
long = b"Test cipher block chaining mode by using a long bytes string"
# noinspection PyTypeChecker
thread, segments = self.subject([
Playlist(0, [key] + [SegmentEnc(num, aesKey, aesIv) for num in range(0, 4)]),
Playlist(4, [key] + [SegmentEnc(num, aesKey, aesIv, content=long) for num in range(4, 8)], end=True),
])
self.await_write(3 + 4)
data = self.await_read(read_all=True)
expected = self.content(segments, prop="content_plain", cond=lambda s: s.num >= 1)
assert data == expected, "Decrypts the AES-128 identity stream"
assert self.called(key, once=True), "Downloads encryption key only once"
assert self.get_mock(key).last_request._request.headers.get("X-FOO") == "BAR"
assert not any(self.called(s) for s in segments.values() if s.num < 1), "Skips first segment"
assert all(self.called(s) for s in segments.values() if s.num >= 1), "Downloads all remaining segments"
assert self.get_mock(segments[1]).last_request._request.headers.get("X-FOO") == "BAR"
def test_hls_encrypted_aes128_with_map(self):
aesKey, aesIv, key = self.gen_key()
map1 = TagMapEnc(1, namespace=self.id(), key=aesKey, iv=aesIv)
map2 = TagMapEnc(2, namespace=self.id(), key=aesKey, iv=aesIv)
self.mock("GET", self.url(map1), content=map1.content)
self.mock("GET", self.url(map2), content=map2.content)
# noinspection PyTypeChecker
thread, segments = self.subject([
Playlist(0, [key, map1] + [SegmentEnc(num, aesKey, aesIv) for num in range(0, 2)]),
Playlist(2, [key, map2] + [SegmentEnc(num, aesKey, aesIv) for num in range(2, 4)], end=True),
])
self.await_write(2 * 2 + 2 * 2)
data = self.await_read(read_all=True)
assert data == self.content([
map1, segments[0], map1, segments[1], map2, segments[2], map2, segments[3],
], prop="content_plain")
def test_hls_encrypted_aes128_key_uri_override(self):
aesKey, aesIv, key = self.gen_key(uri="http://real-mocked/{namespace}/encryption.key?foo=bar")
aesKeyInvalid = bytes(ord(aesKey[i:i + 1]) ^ 0xFF for i in range(16))
_, __, key_invalid = self.gen_key(aesKeyInvalid, aesIv, uri="http://mocked/{namespace}/encryption.key?foo=bar")
# noinspection PyTypeChecker
thread, segments = self.subject([
Playlist(0, [key_invalid] + [SegmentEnc(num, aesKey, aesIv) for num in range(0, 4)]),
Playlist(4, [key_invalid] + [SegmentEnc(num, aesKey, aesIv) for num in range(4, 8)], end=True),
], options={"hls-segment-key-uri": "{scheme}://real-{netloc}{path}?{query}"})
self.await_write(3 + 4)
data = self.await_read(read_all=True)
expected = self.content(segments, prop="content_plain", cond=lambda s: s.num >= 1)
assert data == expected, "Decrypts stream from custom key"
assert not self.called(key_invalid), "Skips encryption key"
assert self.called(key, once=True), "Downloads custom encryption key"
assert self.get_mock(key).last_request._request.headers.get("X-FOO") == "BAR"
@patch("streamlink.stream.hls.log")
def test_hls_encrypted_aes128_incorrect_block_length(self, mock_log: Mock):
aesKey, aesIv, key = self.gen_key()
thread, segments = self.subject([
Playlist(0, [
key,
SegmentEnc(0, aesKey, aesIv, append=b"?"),
SegmentEnc(1, aesKey, aesIv),
], end=True),
])
self.await_write()
assert self.thread.reader.writer.closed is not True
self.await_write()
data = self.await_read(read_all=True)
assert data == self.content([segments[1]], prop="content_plain")
assert mock_log.error.mock_calls == [
call("Error while decrypting segment 0: Data must be padded to 16 byte boundary in CBC mode"),
]
@patch("streamlink.stream.hls.log")
def test_hls_encrypted_aes128_incorrect_padding_length(self, mock_log: Mock):
aesKey, aesIv, key = self.gen_key()
padding = b"\x00" * (AES.block_size - len(b"[0]"))
thread, segments = self.subject([
Playlist(0, [
key,
SegmentEnc(0, aesKey, aesIv, padding=padding),
SegmentEnc(1, aesKey, aesIv),
], end=True),
])
self.await_write()
assert self.thread.reader.writer.closed is not True
self.await_write()
data = self.await_read(read_all=True)
assert data == self.content([segments[1]], prop="content_plain")
assert mock_log.error.mock_calls == [call("Error while decrypting segment 0: Padding is incorrect.")]
@patch("streamlink.stream.hls.log")
def test_hls_encrypted_aes128_incorrect_padding_content(self, mock_log: Mock):
aesKey, aesIv, key = self.gen_key()
padding = (b"\x00" * (AES.block_size - len(b"[0]") - 1)) + bytes([AES.block_size])
thread, segments = self.subject([
Playlist(0, [
key,
SegmentEnc(0, aesKey, aesIv, padding=padding),
SegmentEnc(1, aesKey, aesIv),
], end=True),
])
self.await_write()
assert self.thread.reader.writer.closed is not True
self.await_write()
data = self.await_read(read_all=True)
assert data == self.content([segments[1]], prop="content_plain")
assert mock_log.error.mock_calls == [call("Error while decrypting segment 0: PKCS#7 padding is incorrect.")]
@patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
class TestHlsPlaylistReloadTime(TestMixinStreamHLS, unittest.TestCase):
segments = [
Segment(0, duration=11),
Segment(1, duration=7),
Segment(2, duration=5),
Segment(3, duration=3),
]
def get_session(self, options=None, reload_time=None, *args, **kwargs):
return super().get_session(dict(options or {}, **{
"hls-live-edge": 3,
"hls-playlist-reload-time": reload_time,
}))
def subject(self, *args, **kwargs):
thread, segments = super().subject(*args, start=False, **kwargs)
# mock the worker thread's _playlist_reload_time method, so that the main thread can wait on its call
playlist_reload_time_called = Event()
orig_playlist_reload_time = thread.reader.worker._playlist_reload_time
def mocked_playlist_reload_time(*args, **kwargs):
playlist_reload_time_called.set()
return orig_playlist_reload_time(*args, **kwargs)
# immediately kill the writer thread as we don't need it and don't want to wait for its queue polling to end
def mocked_futures_get():
return None, None
with patch.object(thread.reader.worker, "_playlist_reload_time", side_effect=mocked_playlist_reload_time), \
patch.object(thread.reader.writer, "_futures_get", side_effect=mocked_futures_get):
self.start()
if not playlist_reload_time_called.wait(timeout=5): # pragma: no cover
raise RuntimeError("Missing _playlist_reload_time() call")
# wait for the worker thread to terminate, so that deterministic assertions can be done about the reload time
thread.reader.worker.join()
return thread.reader.worker.playlist_reload_time
def test_hls_playlist_reload_time_default(self):
time = self.subject([Playlist(0, self.segments, end=True, targetduration=4)], reload_time="default")
assert time == 4, "default sets the reload time to the playlist's target duration"
def test_hls_playlist_reload_time_segment(self):
time = self.subject([Playlist(0, self.segments, end=True, targetduration=4)], reload_time="segment")
assert time == 3, "segment sets the reload time to the playlist's last segment"
def test_hls_playlist_reload_time_segment_no_segments(self):
time = self.subject([Playlist(0, [], end=True, targetduration=4)], reload_time="segment")
assert time == 4, "segment sets the reload time to the targetduration if no segments are available"
def test_hls_playlist_reload_time_segment_no_segments_no_targetduration(self):
time = self.subject([Playlist(0, [], end=True, targetduration=0)], reload_time="segment")
assert time == 6, "sets reload time to 6 seconds when no segments and no targetduration are available"
def test_hls_playlist_reload_time_live_edge(self):
time = self.subject([Playlist(0, self.segments, end=True, targetduration=4)], reload_time="live-edge")
assert time == 8, "live-edge sets the reload time to the sum of the number of segments of the live-edge"
def test_hls_playlist_reload_time_live_edge_no_segments(self):
time = self.subject([Playlist(0, [], end=True, targetduration=4)], reload_time="live-edge")
assert time == 4, "live-edge sets the reload time to the targetduration if no segments are available"
def test_hls_playlist_reload_time_live_edge_no_segments_no_targetduration(self):
time = self.subject([Playlist(0, [], end=True, targetduration=0)], reload_time="live-edge")
assert time == 6, "sets reload time to 6 seconds when no segments and no targetduration are available"
def test_hls_playlist_reload_time_number(self):
time = self.subject([Playlist(0, self.segments, end=True, targetduration=4)], reload_time="2")
assert time == 2, "number values override the reload time"
def test_hls_playlist_reload_time_number_invalid(self):
time = self.subject([Playlist(0, self.segments, end=True, targetduration=4)], reload_time="0")
assert time == 4, "invalid number values set the reload time to the playlist's targetduration"
def test_hls_playlist_reload_time_no_target_duration(self):
time = self.subject([Playlist(0, self.segments, end=True, targetduration=0)], reload_time="default")
assert time == 8, "uses the live-edge sum if the playlist is missing the targetduration data"
def test_hls_playlist_reload_time_no_data(self):
time = self.subject([Playlist(0, [], end=True, targetduration=0)], reload_time="default")
assert time == 6, "sets reload time to 6 seconds when no data is available"
@patch("streamlink.stream.hls.log")
@patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
class TestHlsPlaylistParseErrors(TestMixinStreamHLS, unittest.TestCase):
__stream__ = EventedHLSStream
class FakePlaylist(typing.NamedTuple):
is_master: bool = False
iframes_only: bool = False
class InvalidPlaylist(Playlist):
def build(self, *args, **kwargs):
return "invalid"
def test_generic(self, mock_log):
self.subject([self.InvalidPlaylist()])
assert self.await_read(read_all=True) == b""
self.await_close()
assert self.thread.reader.buffer.closed, "Closes the stream on initial playlist parsing error"
assert mock_log.debug.mock_calls == [call("Reloading playlist")]
assert mock_log.error.mock_calls == [call("Missing #EXTM3U header")]
def test_reload(self, mock_log):
thread, segments = self.subject([
Playlist(1, [Segment(0)]),
self.InvalidPlaylist(),
self.InvalidPlaylist(),
Playlist(2, [Segment(2)], end=True),
])
self.await_write(2)
data = self.await_read(read_all=True)
assert data == self.content(segments)
self.close()
self.await_close()
assert mock_log.warning.mock_calls == [
call("Failed to reload playlist: Missing #EXTM3U header"),
call("Failed to reload playlist: Missing #EXTM3U header"),
]
@patch("streamlink.stream.hls.HLSStreamWorker._reload_playlist", Mock(return_value=FakePlaylist(is_master=True)))
def test_is_master(self, mock_log):
self.subject([Playlist()])
assert self.await_read(read_all=True) == b""
self.await_close()
assert self.thread.reader.buffer.closed, "Closes the stream on initial playlist parsing error"
assert mock_log.debug.mock_calls == [call("Reloading playlist")]
assert mock_log.error.mock_calls == [
call(f"Attempted to play a variant playlist, use 'hls://{self.stream.url}' instead"),
]
@patch("streamlink.stream.hls.HLSStreamWorker._reload_playlist", Mock(return_value=FakePlaylist(iframes_only=True)))
def test_iframes_only(self, mock_log):
self.subject([Playlist()])
assert self.await_read(read_all=True) == b""
self.await_close()
assert self.thread.reader.buffer.closed, "Closes the stream on initial playlist parsing error"
assert mock_log.debug.mock_calls == [call("Reloading playlist")]
assert mock_log.error.mock_calls == [call("Streams containing I-frames only are not playable")]
@patch("streamlink.stream.hls.FFMPEGMuxer.is_usable", Mock(return_value=True))
class TestHlsExtAudio(unittest.TestCase):
@property
def playlist(self):
with text("hls/test_2.m3u8") as pl:
return pl.read()
def run_streamlink(self, playlist, audio_select=None):
streamlink = Streamlink()
if audio_select:
streamlink.set_option("hls-audio-select", audio_select)
return HLSStream.parse_variant_playlist(streamlink, playlist)
def test_hls_ext_audio_not_selected(self):
master_url = "http://mocked/path/master.m3u8"
with requests_mock.Mocker() as mock:
mock.get(master_url, text=self.playlist)
master_stream = self.run_streamlink(master_url)["video"]
with pytest.raises(AttributeError):
master_stream.substreams
assert master_stream.url == "http://mocked/path/playlist.m3u8"
def test_hls_ext_audio_en(self):
"""
m3u8 with ext audio but no options should not download additional streams
:return:
"""
master_url = "http://mocked/path/master.m3u8"
expected = ["http://mocked/path/playlist.m3u8", "http://mocked/path/en.m3u8"]
with requests_mock.Mocker() as mock:
mock.get(master_url, text=self.playlist)
master_stream = self.run_streamlink(master_url, "en")
substreams = master_stream["video"].substreams
result = [x.url for x in substreams]
# Check result
assert result == expected
def test_hls_ext_audio_es(self):
"""
m3u8 with ext audio but no options should not download additional streams
:return:
"""
master_url = "http://mocked/path/master.m3u8"
expected = ["http://mocked/path/playlist.m3u8", "http://mocked/path/es.m3u8"]
with requests_mock.Mocker() as mock:
mock.get(master_url, text=self.playlist)
master_stream = self.run_streamlink(master_url, "es")
substreams = master_stream["video"].substreams
result = [x.url for x in substreams]
# Check result
assert result == expected
def test_hls_ext_audio_all(self):
"""
m3u8 with ext audio but no options should not download additional streams
:return:
"""
master_url = "http://mocked/path/master.m3u8"
expected = ["http://mocked/path/playlist.m3u8", "http://mocked/path/en.m3u8", "http://mocked/path/es.m3u8"]
with requests_mock.Mocker() as mock:
mock.get(master_url, text=self.playlist)
master_stream = self.run_streamlink(master_url, "en,es")
substreams = master_stream["video"].substreams
result = [x.url for x in substreams]
# Check result
assert result == expected
def test_hls_ext_audio_wildcard(self):
master_url = "http://mocked/path/master.m3u8"
expected = ["http://mocked/path/playlist.m3u8", "http://mocked/path/en.m3u8", "http://mocked/path/es.m3u8"]
with requests_mock.Mocker() as mock:
mock.get(master_url, text=self.playlist)
master_stream = self.run_streamlink(master_url, "*")
substreams = master_stream["video"].substreams
result = [x.url for x in substreams]
# Check result
assert result == expected
class TestM3U8ParserLogging:
@pytest.mark.parametrize(("loglevel", "has_logs"), [("trace", False), ("all", True)])
def test_log(self, caplog: pytest.LogCaptureFixture, loglevel: str, has_logs: bool):
caplog.set_level(loglevel, "streamlink")
parser = M3U8Parser()
with text("hls/test_1.m3u8") as pl:
data = pl.read()
parser.parse(data)
assert bool(caplog.records) is has_logs