Repository: streamlink/streamlink

Issue:
MPEG-DASH

### Checklist

- [x] This is a bug report.
- [ ] This is a feature request.
- [ ] This is a plugin (improvement) request.
- [ ] I have read the contribution guidelines.

### Description
MPD link extracted from https://www.atresplayer.com/antena3/series/el-secreto-de-puente-viejo/julieta-y-los-hermanos-ortega/25-06-18_5b2bdd907ed1a8d25b535d21/

```
streamlink.exe https://deswowa3player.akamaized.net/vsg/_definst_/assets3/2018/06/21/C4681643-C9A2-4C50-85AB-70A64228EC98/dash.smil/manifest_mvlist.mpd best --player ffplay -l debug
[console][debug] OS:         Windows 7
[console][debug] Python:     3.5.2
[console][debug] Streamlink: 0.13.0
[console][debug] Requests(2.18.4), Socks(1.6.7), Websocket(0.48.0)
[console][info] Found matching plugin dash for URL https://deswowa3player.akamaized.net/vsg/_definst_/assets3/2018/06/21/C4681643-C9A2-4C50-85AB-70A64228EC98/dash.smil/manifest_mvlist.mpd
[plugin.dash][debug] Parsing MPD URL: https://deswowa3player.akamaized.net/vsg/_definst_/assets3/2018/06/21/C4681643-C9A2-4C50-85AB-70A64228EC98/dash.smil/manifest_mvlist.mpd
[console][info] Available streams: 240p (worst), 360p, 480p, 720p, 1080p (best)
[console][info] Opening stream: 1080p (dash)
[stream.dash][debug] Opening DASH reader for: p0va0br4332748
[stream.dash][debug] Opening DASH reader for: p0aa0br128000
[stream.mp4mux-ffmpeg][debug] ffmpeg command: C:\Program Files (x86)\Streamlink\ffmpeg\ffmpeg.exe -nostats -y -i \\.\pipe\ffmpeg-4340-187 -i \\.\pipe\ffmpeg-434
0-51 -c:v copy -c:a copy -copyts -f matroska pipe:1
[stream.ffmpegmux][debug] Starting copy to pipe: \\.\pipe\ffmpeg-4340-187
[stream.ffmpegmux][debug] Starting copy to pipe: \\.\pipe\ffmpeg-4340-51
[stream.dash][debug] Download of segment: https://deswowa3player.akamaized.net/vsg/_definst_/assets3/2018/06/21/C4681643-C9A2-4C50-85AB-70A64228EC98/dash.smil complete
[console][debug] Pre-buffering 8192 bytes
[stream.dash][debug] Download of segment: https://deswowa3player.akamaized.net/vsg/_definst_/assets3/2018/06/21/C4681643-C9A2-4C50-85AB-70A64228EC98/dash.smil complete
[stream.segmented][debug] Closing worker thread
[stream.segmented][debug] Closing writer thread
[stream.ffmpegmux][debug] Pipe copy complete: \\.\pipe\ffmpeg-4340-187
[stream.ffmpegmux][debug] Closing ffmpeg thread
[stream.segmented][debug] Closing worker thread
[stream.segmented][debug] Closing writer thread
[stream.ffmpegmux][debug] Closed all the substreams
[console][error] Try 1/1: Could not open stream <Stream()> (No data returned from stream)
error: Could not open stream <Stream()>, tried 1 times, exiting
[console][info] Closing currently open stream...
[stream.ffmpegmux][debug] Closing ffmpeg thread
[stream.ffmpegmux][debug] Closed all the substreams
```


Discussion:

Resolving patch:
diff --git a/src/streamlink/stream/dash.py b/src/streamlink/stream/dash.py
--- a/src/streamlink/stream/dash.py
+++ b/src/streamlink/stream/dash.py
@@ -28,6 +28,7 @@ def fetch(self, segment, retries=None):
             return
 
         try:
+            headers = {}
             now = datetime.datetime.now(tz=utc)
             if segment.available_at > now:
                 time_to_wait = (segment.available_at - now).total_seconds()
@@ -35,9 +36,18 @@ def fetch(self, segment, retries=None):
                 log.debug("Waiting for segment: {fname} ({wait:.01f}s)".format(fname=fname, wait=time_to_wait))
                 sleep_until(segment.available_at)
 
+            if segment.range:
+                start, length = segment.range
+                if length:
+                    end = start + length - 1
+                else:
+                    end = ""
+                headers["Range"] = "bytes={0}-{1}".format(start, end)
+
             return self.session.http.get(segment.url,
                                          timeout=self.timeout,
-                                         exception=StreamError)
+                                         exception=StreamError,
+                                         headers=headers)
         except StreamError as err:
             log.error("Failed to open segment {0}: {1}", segment.url, err)
             return self.fetch(segment, retries - 1)
diff --git a/src/streamlink/stream/dash_manifest.py b/src/streamlink/stream/dash_manifest.py
--- a/src/streamlink/stream/dash_manifest.py
+++ b/src/streamlink/stream/dash_manifest.py
@@ -32,12 +32,13 @@ def dst(self, dt):
 
 
 class Segment(object):
-    def __init__(self, url, duration, init=False, content=True, available_at=epoch_start):
+    def __init__(self, url, duration, init=False, content=True, available_at=epoch_start, range=None):
         self.url = url
         self.duration = duration
         self.init = init
         self.content = content
         self.available_at = available_at
+        self.range = range
 
 
 def datetime_to_seconds(dt):
@@ -119,6 +120,15 @@ def _timedelta(seconds):
 
         return _timedelta
 
+    @staticmethod
+    def range(range_spec):
+        r = range_spec.split("-")
+        if len(r) != 2:
+            raise MPDParsingError("invalid byte-range-spec")
+
+        start, end = int(r[0]), r[1] and int(r[1]) or None
+        return start, end and ((end-start) + 1)
+
 
 class MPDParsingError(Exception):
     pass
@@ -322,9 +332,53 @@ class EventStream(MPDNode):
     __tag__ = "EventStream"
 
 
+class Initialization(MPDNode):
+    __tag__ = "Initialization"
+
+    def __init__(self, node, root=None, parent=None, *args, **kwargs):
+        super(Initialization, self).__init__(node, root, parent, *args, **kwargs)
+        self.source_url = self.attr("sourceURL")
+
+
+class SegmentURL(MPDNode):
+    __tag__ = "SegmentURL"
+
+    def __init__(self, node, root=None, parent=None, *args, **kwargs):
+        super(SegmentURL, self).__init__(node, root, parent, *args, **kwargs)
+        self.media = self.attr("media")
+        self.media_range = self.attr("mediaRange", parser=MPDParsers.range)
+
+
 class SegmentList(MPDNode):
     __tag__ = "SegmentList"
 
+    def __init__(self, node, root=None, parent=None, *args, **kwargs):
+        super(SegmentList, self).__init__(node, root, parent, *args, **kwargs)
+
+        self.presentation_time_offset = self.attr("presentationTimeOffset")
+        self.timescale = self.attr("timescale", parser=int)
+        self.duration = self.attr("duration", parser=int)
+        self.start_number = self.attr("startNumber", parser=int, default=1)
+
+        if self.duration:
+            self.duration_seconds = self.duration / float(self.timescale)
+        else:
+            self.duration_seconds = None
+
+
+        self.initialization = self.only_child(Initialization)
+        self.segment_urls = self.children(SegmentURL, minimum=1)
+
+    @property
+    def segments(self):
+        if self.initialization:
+            yield Segment(self.make_url(self.initialization.source_url), 0, init=True, content=False)
+        for n, segment_url in enumerate(self.segment_urls, self.start_number):
+            yield Segment(self.make_url(segment_url.media), self.duration_seconds, range=segment_url.media_range)
+
+    def make_url(self, url):
+        return BaseURL.join(self.base_url, url)
+
 
 class AdaptationSet(MPDNode):
     __tag__ = u"AdaptationSet"
@@ -523,7 +577,7 @@ def __init__(self, node, root=None, parent=None, *args, **kwargs):
         self.baseURLs = self.children(BaseURL)
         self.subRepresentation = self.children(SubRepresentation)
         self.segmentBase = self.only_child(SegmentBase)
-        self.segmentList = self.only_child(SegmentList)
+        self.segmentList = self.children(SegmentList)
         self.segmentTemplate = self.only_child(SegmentTemplate)
 
     def segments(self, **kwargs):
@@ -538,7 +592,7 @@ def segments(self, **kwargs):
         """
 
         segmentBase = self.segmentBase or self.walk_back_get_attr("segmentBase")
-        segmentList = self.segmentList or self.walk_back_get_attr("segmentList")
+        segmentLists = self.segmentList or self.walk_back_get_attr("segmentList")
         segmentTemplate = self.segmentTemplate or self.walk_back_get_attr("segmentTemplate")
 
         if segmentTemplate:
@@ -549,6 +603,10 @@ def segments(self, **kwargs):
                     yield segment
                 else:
                     yield segment
+        elif segmentLists:
+            for segmentList in segmentLists:
+                for segment in segmentList.segments:
+                    yield segment
         else:
             yield Segment(self.base_url, 0, True, True)
 


Test patch:
diff --git a/tests/resources/dash/test_7.mpd b/tests/resources/dash/test_7.mpd
new file mode 100644
--- /dev/null
+++ b/tests/resources/dash/test_7.mpd
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<MPD xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+     xmlns="urn:mpeg:dash:schema:mpd:2011"
+     xmlns:xlink="http://www.w3.org/1999/xlink"
+     xsi:schemaLocation="urn:mpeg:DASH:schema:MPD:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd"
+     profiles="urn:mpeg:dash:profile:isoff-main:2011"
+     type="static"
+     publishTime="2018-06-25T18:01:58Z"
+     mediaPresentationDuration="PT53M18.059S"
+     minBufferTime="PT1.5S">
+<ProgramInformation>
+    <Title>test/dash.smil</Title>
+</ProgramInformation>
+<Period id="0" start="PT0.0S">
+    <AdaptationSet id="0" group="1" mimeType="video/mp4" maxWidth="1920" maxHeight="1080" par="16:9" frameRate="25" segmentAlignment="true" startWithSAP="1" subsegmentAlignment="true" subsegmentStartsWithSAP="1">
+        <Representation id="p0va0br4332748" codecs="avc1.640028" width="1920" height="1080" sar="1:1" bandwidth="4332748">
+            <SegmentList presentationTimeOffset="0" timescale="90000" duration="900000" startNumber="1">
+                <Initialization sourceURL="chunk_ctvideo_ridp0va0br4332748_cinit_mpd.m4s"/>
+                <SegmentURL media="chunk_ctvideo_ridp0va0br4332748_cn1_mpd.m4s"/>
+                <SegmentURL media="chunk_ctvideo_ridp0va0br4332748_cn2_mpd.m4s"/>
+                <SegmentURL media="chunk_ctvideo_ridp0va0br4332748_cn3_mpd.m4s"/>
+                <SegmentURL media="chunk_ctvideo_ridp0va0br4332748_cn4_mpd.m4s"/>
+                <SegmentURL media="chunk_ctvideo_ridp0va0br4332748_cn5_mpd.m4s"/>
+                <SegmentURL media="chunk_ctvideo_ridp0va0br4332748_cn6_mpd.m4s"/>
+                <SegmentURL media="chunk_ctvideo_ridp0va0br4332748_cn7_mpd.m4s"/>
+                <SegmentURL media="chunk_ctvideo_ridp0va0br4332748_cn8_mpd.m4s"/>
+                <SegmentURL media="chunk_ctvideo_ridp0va0br4332748_cn9_mpd.m4s"/>
+            </SegmentList>
+        </Representation>
+    </AdaptationSet>
+</Period>
+</MPD>
diff --git a/tests/streams/test_dash_parser.py b/tests/streams/test_dash_parser.py
--- a/tests/streams/test_dash_parser.py
+++ b/tests/streams/test_dash_parser.py
@@ -61,6 +61,11 @@ def test_timedelta(self):
         self.assertEqual(MPDParsers.timedelta(10)(100),
                          datetime.timedelta(0, 10.0))
 
+    def test_range(self):
+        self.assertEqual(MPDParsers.range("100-"), (100, None))
+        self.assertEqual(MPDParsers.range("100-199"), (100, 100))
+        self.assertRaises(MPDParsingError, MPDParsers.range, "100")
+
 
 class TestMPDParser(unittest.TestCase):
     maxDiff = None
@@ -149,6 +154,21 @@ def test_segments_static_no_publish_time(self):
                                       'http://test.se/dash/150633-video_eng=194000-4000.dash',
                                       ])
 
+    def test_segments_list(self):
+        with xml("dash/test_7.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)
+            self.assertEqual(init_segment.url, "http://test.se/chunk_ctvideo_ridp0va0br4332748_cinit_mpd.m4s")
+
+            video_segments = [x.url for x in itertools.islice(segments, 3)]
+            self.assertSequenceEqual(video_segments,
+                                     ['http://test.se/chunk_ctvideo_ridp0va0br4332748_cn1_mpd.m4s',
+                                      'http://test.se/chunk_ctvideo_ridp0va0br4332748_cn2_mpd.m4s',
+                                      'http://test.se/chunk_ctvideo_ridp0va0br4332748_cn3_mpd.m4s',
+                                      ])
+
     def test_segments_dynamic_timeline_continue(self):
         with xml("dash/test_6_p1.mpd") as mpd_xml_p1:
             with xml("dash/test_6_p2.mpd") as mpd_xml_p2: