mirror of https://github.com/streamlink/streamlink
457 lines
21 KiB
Python
457 lines
21 KiB
Python
import datetime
|
|
import itertools
|
|
import unittest
|
|
from operator import attrgetter
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from freezegun import freeze_time
|
|
|
|
from streamlink.stream.dash_manifest import MPD, MPDParsers, MPDParsingError, Representation, Segment
|
|
from tests.resources import xml
|
|
|
|
|
|
UTC = datetime.timezone.utc
|
|
|
|
|
|
class TestSegment:
|
|
@pytest.mark.parametrize(("segmentdata", "expected"), [
|
|
({"url": "https://foo/bar", "number": 123, "init": True, "content": False}, "initialization"),
|
|
({"url": "https://foo/bar", "number": 123, "init": True, "content": True}, "123"),
|
|
({"url": "https://foo/bar", "number": None, "init": True, "content": True}, "bar"),
|
|
({"url": "https://foo/bar", "number": 123}, "123"),
|
|
({"url": "https://foo/bar"}, "bar"),
|
|
({"url": "https://foo/bar/"}, "bar"),
|
|
({"url": "https://foo/bar/baz.qux"}, "baz.qux"),
|
|
({"url": "https://foo/bar/baz.qux"}, "baz.qux"),
|
|
({"url": "https://foo/bar/baz.qux?asdf"}, "baz.qux"),
|
|
])
|
|
def test_name(self, segmentdata: dict, expected: str):
|
|
segment = Segment(**segmentdata)
|
|
assert segment.name == expected
|
|
|
|
@pytest.mark.parametrize(("available_at", "expected"), [
|
|
(datetime.datetime(2000, 1, 2, 3, 4, 5, 123456, tzinfo=UTC), 1 * 86400 + 3 * 3600 + 4 * 60 + 5 + 0.123456),
|
|
(datetime.datetime(2000, 1, 1, 0, 0, 0, 0, tzinfo=UTC), 0.0),
|
|
(datetime.datetime(1999, 12, 31, 23, 59, 59, 999999, tzinfo=UTC), 0.0),
|
|
])
|
|
def test_available_in(self, available_at: datetime.datetime, expected: float):
|
|
segment = Segment(url="foo", available_at=available_at)
|
|
with freeze_time("2000-01-01T00:00:00Z"):
|
|
assert segment.available_in == pytest.approx(expected)
|
|
|
|
def test_availability(self):
|
|
segment = Segment(url="foo", available_at=datetime.datetime(2000, 1, 2, 3, 4, 5, 123456, tzinfo=UTC))
|
|
with freeze_time("2000-01-01T00:00:00Z"):
|
|
assert segment.availability == "2000-01-02T03:04:05.123456Z / 2000-01-01T00:00:00.000000Z"
|
|
|
|
|
|
class TestMPDParsers(unittest.TestCase):
|
|
def test_bool_str(self):
|
|
assert MPDParsers.bool_str("true")
|
|
assert MPDParsers.bool_str("TRUE")
|
|
assert MPDParsers.bool_str("True")
|
|
|
|
assert not MPDParsers.bool_str("0")
|
|
assert not MPDParsers.bool_str("False")
|
|
assert not MPDParsers.bool_str("false")
|
|
assert not MPDParsers.bool_str("FALSE")
|
|
|
|
def test_type(self):
|
|
assert MPDParsers.type("dynamic") == "dynamic"
|
|
assert MPDParsers.type("static") == "static"
|
|
with pytest.raises(MPDParsingError):
|
|
MPDParsers.type("other")
|
|
|
|
def test_duration(self):
|
|
assert MPDParsers.duration("PT1S") == datetime.timedelta(0, 1)
|
|
|
|
def test_datetime(self):
|
|
assert MPDParsers.datetime("2018-01-01T00:00:00Z") == datetime.datetime(2018, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
|
|
def test_segment_template(self):
|
|
assert MPDParsers.segment_template("$Time$-$Number$-$Other$")(Time=1, Number=2, Other=3) == "1-2-3"
|
|
assert MPDParsers.segment_template("$Number%05d$")(Number=123) == "00123"
|
|
assert MPDParsers.segment_template("$Time%0.02f$")(Time=100.234) == "100.23"
|
|
|
|
def test_frame_rate(self):
|
|
assert MPDParsers.frame_rate("1/25") == pytest.approx(1.0 / 25.0)
|
|
assert MPDParsers.frame_rate("0.2") == pytest.approx(0.2)
|
|
|
|
def test_timedelta(self):
|
|
assert MPDParsers.timedelta(1)(100) == datetime.timedelta(0, 100.0)
|
|
assert MPDParsers.timedelta(10)(100) == datetime.timedelta(0, 10.0)
|
|
|
|
def test_range(self):
|
|
assert MPDParsers.range("100-") == (100, None)
|
|
assert MPDParsers.range("100-199") == (100, 100)
|
|
with pytest.raises(MPDParsingError):
|
|
MPDParsers.range("100")
|
|
|
|
|
|
class TestMPDParser(unittest.TestCase):
|
|
maxDiff = None
|
|
|
|
def test_no_segment_list_or_template(self):
|
|
with xml("dash/test_no_segment_list_or_template.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
|
|
segments = [
|
|
{
|
|
"ident": representation.ident,
|
|
"mimeType": representation.mimeType,
|
|
"segments": [
|
|
(segment.url, segment.number, segment.duration, segment.available_at, segment.init, segment.content)
|
|
for segment in itertools.islice(representation.segments(), 100)
|
|
],
|
|
}
|
|
for adaptationset in mpd.periods[0].adaptationSets for representation in adaptationset.representations
|
|
if representation.id in ("1", "5", "6")
|
|
]
|
|
availability = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
|
|
assert segments == [
|
|
{
|
|
"ident": (None, None, "1"),
|
|
"mimeType": "audio/mp4",
|
|
"segments": [("http://cdn1.example.com/7657412348.mp4", None, 3256.0, availability, True, True)],
|
|
},
|
|
{
|
|
"ident": (None, None, "5"),
|
|
"mimeType": "application/ttml+xml",
|
|
"segments": [("http://cdn1.example.com/796735657.xml", None, 3256.0, availability, True, True)],
|
|
},
|
|
{
|
|
"ident": (None, None, "6"),
|
|
"mimeType": "video/mp4",
|
|
"segments": [("http://cdn1.example.com/8563456473.mp4", None, 3256.0, availability, True, True)],
|
|
},
|
|
]
|
|
|
|
def test_segments_number_time(self):
|
|
with xml("dash/test_1.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
|
|
|
|
segments = mpd.periods[0].adaptationSets[0].representations[0].segments()
|
|
init_segment = next(segments)
|
|
assert init_segment.url == "http://test.se/tracks-v3/init-1526842800.g_m4v"
|
|
|
|
video_segments = list(map(attrgetter("url"), (itertools.islice(segments, 5))))
|
|
# suggested delay is 11 seconds, each segment is 5 seconds long - so there should be 3
|
|
assert video_segments == [
|
|
"http://test.se/tracks-v3/dvr-1526842800-698.g_m4v?t=3403000",
|
|
"http://test.se/tracks-v3/dvr-1526842800-699.g_m4v?t=3408000",
|
|
"http://test.se/tracks-v3/dvr-1526842800-700.g_m4v?t=3413000",
|
|
]
|
|
|
|
def test_segments_static_number(self):
|
|
with xml("dash/test_2.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
|
|
|
|
segments = mpd.periods[0].adaptationSets[3].representations[0].segments()
|
|
init_segment = next(segments)
|
|
assert init_segment.url == "http://test.se/video/250kbit/init.mp4"
|
|
|
|
video_segments = list(map(attrgetter("url"), (itertools.islice(segments, 100000))))
|
|
assert len(video_segments) == 444
|
|
assert video_segments[:5] == [
|
|
"http://test.se/video/250kbit/segment_1.m4s",
|
|
"http://test.se/video/250kbit/segment_2.m4s",
|
|
"http://test.se/video/250kbit/segment_3.m4s",
|
|
"http://test.se/video/250kbit/segment_4.m4s",
|
|
"http://test.se/video/250kbit/segment_5.m4s",
|
|
]
|
|
|
|
def test_segments_dynamic_time(self):
|
|
with xml("dash/test_3.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
|
|
|
|
segments = mpd.periods[0].adaptationSets[0].representations[0].segments()
|
|
init_segment = next(segments)
|
|
assert init_segment.url == "http://test.se/video-2800000-0.mp4?z32="
|
|
|
|
video_segments = list(map(attrgetter("url"), (itertools.islice(segments, 3))))
|
|
# default suggested delay is 3 seconds, each segment is 4 seconds long - so there should be 1 segment
|
|
assert video_segments == [
|
|
"http://test.se/video-time=1525450872000-2800000-0.m4s?z32=",
|
|
]
|
|
|
|
def test_segments_dynamic_number(self):
|
|
# access manifest one hour after its availabilityStartTime
|
|
with xml("dash/test_segments_dynamic_number.mpd") as mpd_xml, \
|
|
freeze_time("2000-01-01T01:00:00Z"):
|
|
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
|
|
stream_urls = [
|
|
(segment.url, segment.available_at)
|
|
for segment in itertools.islice(mpd.periods[0].adaptationSets[0].representations[0].segments(), 4)
|
|
]
|
|
|
|
assert stream_urls == [
|
|
(
|
|
"http://test/hd-5-init.mp4",
|
|
datetime.datetime(2000, 1, 1, 0, 1, 30, tzinfo=UTC),
|
|
),
|
|
(
|
|
"http://test/hd-5_000000793.mp4",
|
|
datetime.datetime(2000, 1, 1, 0, 59, 15, tzinfo=UTC),
|
|
),
|
|
(
|
|
"http://test/hd-5_000000794.mp4",
|
|
datetime.datetime(2000, 1, 1, 0, 59, 20, tzinfo=UTC),
|
|
),
|
|
(
|
|
"http://test/hd-5_000000795.mp4",
|
|
datetime.datetime(2000, 1, 1, 0, 59, 25, tzinfo=UTC),
|
|
),
|
|
]
|
|
|
|
def test_static_no_publish_time(self):
|
|
with xml("dash/test_static_no_publish_time.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
|
|
|
|
segments = mpd.periods[0].adaptationSets[1].representations[0].segments()
|
|
segment_urls = [(segment.url, segment.available_at) for segment in itertools.islice(segments, 4)]
|
|
# ignores period start time in static manifests
|
|
expected_availability = datetime.datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
|
|
assert segment_urls == [
|
|
("http://test/dash/150633-video_eng=194000.dash", expected_availability),
|
|
("http://test/dash/150633-video_eng=194000-0.dash", expected_availability),
|
|
("http://test/dash/150633-video_eng=194000-2000.dash", expected_availability),
|
|
("http://test/dash/150633-video_eng=194000-4000.dash", expected_availability),
|
|
]
|
|
|
|
def test_segment_list(self):
|
|
with xml("dash/test_segment_list.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
|
|
|
|
segments = mpd.periods[0].adaptationSets[0].representations[0].segments()
|
|
segment_urls = [(segment.url, segment.available_at) for segment in itertools.islice(segments, 4)]
|
|
# ignores period start time in static manifests
|
|
expected_availability = datetime.datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
|
|
assert segment_urls == [
|
|
("http://test/chunk_ctvideo_ridp0va0br4332748_cinit_mpd.m4s", expected_availability),
|
|
("http://test/chunk_ctvideo_ridp0va0br4332748_cn1_mpd.m4s", expected_availability),
|
|
("http://test/chunk_ctvideo_ridp0va0br4332748_cn2_mpd.m4s", expected_availability),
|
|
("http://test/chunk_ctvideo_ridp0va0br4332748_cn3_mpd.m4s", expected_availability),
|
|
]
|
|
|
|
def test_dynamic_timeline_continued(self):
|
|
with xml("dash/test_dynamic_timeline_continued_p1.mpd") as mpd_xml_p1:
|
|
mpd_p1 = MPD(mpd_xml_p1, base_url="http://test/", url="http://test/manifest.mpd")
|
|
iter_segment_p1 = mpd_p1.periods[0].adaptationSets[0].representations[0].segments()
|
|
segments_p1 = [
|
|
(segment.url, segment.number, segment.available_at)
|
|
for segment in itertools.islice(iter_segment_p1, 100)
|
|
]
|
|
|
|
assert segments_p1 == [
|
|
("http://test/video/init.mp4", None, datetime.datetime(2018, 1, 1, 1, 0, 0, tzinfo=UTC)),
|
|
("http://test/video/1006000.mp4", 7, datetime.datetime(2018, 1, 1, 12, 59, 56, tzinfo=UTC)),
|
|
("http://test/video/1007000.mp4", 8, datetime.datetime(2018, 1, 1, 12, 59, 57, tzinfo=UTC)),
|
|
("http://test/video/1008000.mp4", 9, datetime.datetime(2018, 1, 1, 12, 59, 58, tzinfo=UTC)),
|
|
("http://test/video/1009000.mp4", 10, datetime.datetime(2018, 1, 1, 12, 59, 59, tzinfo=UTC)),
|
|
("http://test/video/1010000.mp4", 11, datetime.datetime(2018, 1, 1, 13, 0, 0, tzinfo=UTC)),
|
|
]
|
|
|
|
# continue with the next manifest
|
|
with xml("dash/test_dynamic_timeline_continued_p2.mpd") as mpd_xml_p2:
|
|
mpd_p2 = MPD(mpd_xml_p2, base_url=mpd_p1.base_url, url=mpd_p1.url, timelines=mpd_p1.timelines)
|
|
iter_segment_p2 = mpd_p2.periods[0].adaptationSets[0].representations[0].segments(init=False)
|
|
segments_p2 = [
|
|
(segment.url, segment.number, segment.available_at)
|
|
for segment in itertools.islice(iter_segment_p2, 100)
|
|
]
|
|
|
|
assert segments_p2 == [
|
|
("http://test/video/1011000.mp4", 7, datetime.datetime(2018, 1, 1, 13, 0, 1, tzinfo=UTC)),
|
|
("http://test/video/1012000.mp4", 8, datetime.datetime(2018, 1, 1, 13, 0, 2, tzinfo=UTC)),
|
|
("http://test/video/1013000.mp4", 9, datetime.datetime(2018, 1, 1, 13, 0, 3, tzinfo=UTC)),
|
|
("http://test/video/1014000.mp4", 10, datetime.datetime(2018, 1, 1, 13, 0, 4, tzinfo=UTC)),
|
|
("http://test/video/1015000.mp4", 11, datetime.datetime(2018, 1, 1, 13, 0, 5, tzinfo=UTC)),
|
|
]
|
|
|
|
def test_tsegment_t_is_none_1895(self):
|
|
"""
|
|
Verify the fix for https://github.com/streamlink/streamlink/issues/1895
|
|
"""
|
|
with xml("dash/test_8.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
|
|
|
|
segments = mpd.periods[0].adaptationSets[0].representations[0].segments()
|
|
init_segment = next(segments)
|
|
assert init_segment.url == "http://test.se/video-2799000-0.mp4?z32=CENSORED_SESSION"
|
|
|
|
video_segments = [x.url for x in itertools.islice(segments, 3)]
|
|
assert video_segments == [
|
|
"http://test.se/video-time=0-2799000-0.m4s?z32=CENSORED_SESSION",
|
|
"http://test.se/video-time=4000-2799000-0.m4s?z32=CENSORED_SESSION",
|
|
"http://test.se/video-time=8000-2799000-0.m4s?z32=CENSORED_SESSION",
|
|
]
|
|
|
|
def test_bitrate_rounded(self):
|
|
def mock_rep(bandwidth):
|
|
node = Mock(
|
|
tag="Representation",
|
|
attrib={
|
|
"id": "test",
|
|
"bandwidth": bandwidth,
|
|
"mimeType": "video/mp4",
|
|
},
|
|
)
|
|
node.findall.return_value = []
|
|
|
|
root = Mock()
|
|
root.parent = None
|
|
period = Mock()
|
|
period.parent = root
|
|
aset = Mock()
|
|
aset.parent = period
|
|
|
|
return Representation(node, root=root, parent=aset, period=period)
|
|
|
|
assert mock_rep(1.2 * 1000.0).bandwidth_rounded == pytest.approx(1.2)
|
|
assert mock_rep(45.6 * 1000.0).bandwidth_rounded == pytest.approx(46.0)
|
|
assert mock_rep(134.0 * 1000.0).bandwidth_rounded == pytest.approx(130.0)
|
|
assert mock_rep(1324.0 * 1000.0).bandwidth_rounded == pytest.approx(1300.0)
|
|
|
|
def test_duplicated_resolutions(self):
|
|
"""
|
|
Verify the fix for https://github.com/streamlink/streamlink/issues/3365
|
|
"""
|
|
with xml("dash/test_10.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
|
|
|
|
representations_0 = mpd.periods[0].adaptationSets[0].representations[0]
|
|
assert representations_0.height == 804
|
|
assert representations_0.bandwidth == pytest.approx(10000.0)
|
|
representations_1 = mpd.periods[0].adaptationSets[0].representations[1]
|
|
assert representations_1.height == 804
|
|
assert representations_1.bandwidth == pytest.approx(8000.0)
|
|
|
|
def test_segments_static_periods_duration(self):
|
|
"""
|
|
Verify the fix for https://github.com/streamlink/streamlink/issues/2873
|
|
"""
|
|
with xml("dash/test_11_static.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
|
|
duration = mpd.periods[0].duration.total_seconds()
|
|
assert duration == pytest.approx(204.32)
|
|
|
|
def test_segments_byterange(self):
|
|
with xml("dash/test_segments_byterange.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
|
|
|
|
segment_urls = [
|
|
[
|
|
(seg.url, seg.init, seg.byterange)
|
|
for seg in adaptationset.representations[0].segments()
|
|
]
|
|
for adaptationset in mpd.periods[0].adaptationSets
|
|
]
|
|
|
|
assert segment_urls == [
|
|
[
|
|
("http://test/video-frag.mp4", True, (36, 711)),
|
|
("http://test/video-frag.mp4", False, (747, 875371)),
|
|
("http://test/video-frag.mp4", False, (876118, 590796)),
|
|
("http://test/video-frag.mp4", False, (1466914, 487041)),
|
|
("http://test/video-frag.mp4", False, (1953955, 40698)),
|
|
],
|
|
[
|
|
("http://test/audio-frag.mp4", True, (32, 592)),
|
|
("http://test/audio-frag.mp4", False, (624, 123576)),
|
|
("http://test/audio-frag.mp4", False, (124200, 126104)),
|
|
("http://test/audio-frag.mp4", False, (250304, 124062)),
|
|
("http://test/audio-frag.mp4", False, (374366, 471)),
|
|
],
|
|
]
|
|
|
|
def test_nested_baseurls(self):
|
|
with xml("dash/test_nested_baseurls.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="https://foo/", url="https://test/manifest.mpd")
|
|
|
|
segment_urls = [
|
|
[(segment.url, segment.available_at) for segment in itertools.islice(representation.segments(), 2)]
|
|
for adaptationset in mpd.periods[0].adaptationSets for representation in adaptationset.representations
|
|
]
|
|
# ignores period start time in static manifests
|
|
expected_availability = datetime.datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
|
|
assert segment_urls == [
|
|
[
|
|
("https://hostname/period/init_video_5000kbps.m4s", expected_availability),
|
|
("https://hostname/period/media_video_5000kbps-1.m4s", expected_availability),
|
|
],
|
|
[
|
|
("https://hostname/period/representation/init_video_9000kbps.m4s", expected_availability),
|
|
("https://hostname/period/representation/media_video_9000kbps-1.m4s", expected_availability),
|
|
],
|
|
[
|
|
("https://hostname/period/adaptationset/init_audio_128kbps.m4s", expected_availability),
|
|
("https://hostname/period/adaptationset/media_audio_128kbps-1.m4s", expected_availability),
|
|
],
|
|
[
|
|
("https://hostname/period/adaptationset/representation/init_audio_256kbps.m4s", expected_availability),
|
|
("https://hostname/period/adaptationset/representation/media_audio_256kbps-1.m4s", expected_availability),
|
|
],
|
|
[
|
|
("https://other/init_audio_320kbps.m4s", expected_availability),
|
|
("https://other/media_audio_320kbps-1.m4s", expected_availability),
|
|
],
|
|
]
|
|
|
|
def test_timeline_ids(self):
|
|
with xml("dash/test_timeline_ids.mpd") as mpd_xml, \
|
|
freeze_time("2000-01-01T00:00:00Z"):
|
|
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
|
|
segment_urls = [
|
|
[
|
|
segment.url
|
|
for segment in itertools.islice(representation.segments(), 3)
|
|
]
|
|
for adaptationset in mpd.periods[0].adaptationSets for representation in adaptationset.representations
|
|
]
|
|
assert segment_urls == [
|
|
[
|
|
"http://test/audio1/init.mp4",
|
|
"http://test/audio1/t0.m4s",
|
|
"http://test/audio1/t1.m4s",
|
|
],
|
|
[
|
|
"http://test/audio2/init.mp4",
|
|
"http://test/audio2/t0.m4s",
|
|
"http://test/audio2/t1.m4s",
|
|
],
|
|
[
|
|
"http://test/video1/init.mp4",
|
|
"http://test/video1/t0.m4s",
|
|
"http://test/video1/t1.m4s",
|
|
],
|
|
[
|
|
"http://test/video2/init.mp4",
|
|
"http://test/video2/t0.m4s",
|
|
"http://test/video2/t1.m4s",
|
|
],
|
|
]
|
|
assert list(mpd.timelines.keys()) == [
|
|
("period-0", "0", "audio1"),
|
|
("period-0", "0", "audio2"),
|
|
("period-0", None, "video1"),
|
|
("period-0", None, "video2"),
|
|
]
|
|
|
|
def test_get_representation(self):
|
|
with xml("dash/test_timeline_ids.mpd") as mpd_xml:
|
|
mpd = MPD(mpd_xml, base_url="http://test/", url="http://test/manifest.mpd")
|
|
|
|
assert mpd.get_representation((None, None, "unknown")) is None
|
|
assert mpd.get_representation((None, None, "audio1")) is None
|
|
assert mpd.get_representation((None, "0", "audio1")) is None
|
|
assert mpd.get_representation(("period-0", None, "audio1")) is None
|
|
|
|
assert getattr(mpd.get_representation(("period-0", "0", "audio1")), "mimeType", None) == "audio/mp4"
|
|
assert getattr(mpd.get_representation(("period-0", "0", "audio2")), "mimeType", None) == "audio/mp4"
|
|
assert getattr(mpd.get_representation(("period-0", None, "video1")), "mimeType", None) == "video/mp4"
|
|
assert getattr(mpd.get_representation(("period-0", None, "video2")), "mimeType", None) == "video/mp4"
|