Repository: streamlink/streamlink

Issue:
stream.dash: nested BaseURL resolving
### Checklist

- [X] This is a plugin issue and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest build from the master branch

### Description

This specific URL works with mpv, totem and ffplay, but doesn't with vlc and streamlink:

``https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/manifest.mpd``

### Debug log

```text
[cli][debug] OS:         Linux-5.10.0-15-amd64-x86_64-with-glibc2.31
[cli][debug] Python:     3.9.2
[cli][debug] Streamlink: 4.1.0+12.g0dd52932
[cli][debug] Dependencies:
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.8.0
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.14.1
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.27.1
[cli][debug]  websocket-client: 1.3.2
[cli][debug] Arguments:
[cli][debug]  url=https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/manifest.mpd
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player=totem
[cli][info] Found matching plugin dash for URL https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/manifest.mpd
[plugins.dash][debug] URL=https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/manifest.mpd; params={}
[utils.l10n][debug] Language code: en_US
[stream.dash][debug] Available languages for DASH audio streams: eng (using: eng)
[cli][info] Available streams: 240p (worst), 360p, 480p, 720p, 1080p (best)
[cli][info] Opening stream: 1080p (dash)
[cli][info] Starting player: totem
[stream.dash][debug] Opening DASH reader for: video_3311kbps (video/mp4)
[stream.dash][debug] Opening DASH reader for: audio_1 (audio/mp4)
[stream.dash_manifest][debug] Generating segment numbers for dynamic playlist (id=0)
[cli][error] Try 1/1: Could not open stream <DASHStream ['dash', 'https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/manifest.mpd']> (Could not open stream: cannot use FFMPEG)
[stream.ffmpegmux][debug] Closing ffmpeg thread
error: Could not open stream <DASHStream ['dash', 'https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/manifest.mpd']>, tried 1 times, exiting
[stream.dash_manifest][debug] Generating segment numbers for dynamic playlist (id=1)
Exception in thread Thread-DASHStreamWorker:
Traceback (most recent call last):
  File "/usr/lib/python3.9/threading.py", line 954, in _bootstrap_inner
    self.run()
  File "/usr/local/lib/python3.9/dist-packages/streamlink/stream/segmented.py", line 87, in run
    self.writer.put(segment)
  File "/usr/local/lib/python3.9/dist-packages/streamlink/stream/segmented.py", line 143, in put
    future = self.executor.submit(self.fetch, segment, retries=self.retries)
  File "/usr/lib/python3.9/concurrent/futures/thread.py", line 163, in submit
    raise RuntimeError('cannot schedule new futures after '
RuntimeError: cannot schedule new futures after interpreter shutdown
[stream.dash][error] Failed to open segment https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/init_video_3311kbps.m4s: Unable to open URL: https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/init_video_3311kbps.m4s (404 Client Error: Not Found for url: https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/init_video_3311kbps.m4s)
```



Discussion:
> `https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/init_video_3311kbps.m4s`

Looks like the `Representation`'s `BaseURL` gets ignored. The URL of the initialization should be
`https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/RFE-TVMC05_0240/12wxxnb2/init_video_238kbps.m4s`

```xml
...
<BaseURL>https://rfe-ingest.akamaized.net/dash/live/2033030/tvmc05/</BaseURL>
<Period id="0" start="PT0.0S">
    <AdaptationSet id="0" mimeType="video/mp4" maxWidth="1920" maxHeight="1080" par="16:9" frameRate="25" segmentAlignment="true" startWithSAP="1" subsegmentAlignment="true" subsegmentStartsWithSAP="1">
        <SegmentTemplate presentationTimeOffset="0" timescale="90000" duration="540000" startNumber="1" media="media_$RepresentationID$-$Number$.m4s" initialization="init_$RepresentationID$.m4s"/>
        <Representation id="video_238kbps" codecs="avc1.42c015" width="426" height="240" sar="1:1" bandwidth="238000">
            <BaseURL>RFE-TVMC05_0240/12wxxnb2/</BaseURL>
        </Representation>
...
```
The issue is caused by the DASH parser not resolving nested BaseURLs on the
1. document level
2. MPD level
3. Period level
4. AdaptationSet level
5. Representation level

This either needs to be fixed in `MPDNode.base_url` where it currently only takes the current level's `BaseURL`  into consideration
https://github.com/streamlink/streamlink/blob/07bd742ddff22dd95ef2c90a64bedfdb0a1bd082/src/streamlink/stream/dash_manifest.py#L177-L182
or better, in the `BaseURL(MPDNode)` class, where it only has to resolve it once from its parent/ancestor nodes
https://github.com/streamlink/streamlink/blob/07bd742ddff22dd95ef2c90a64bedfdb0a1bd082/src/streamlink/stream/dash_manifest.py#L270-L293

Resolving patch:
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
@@ -613,9 +613,9 @@ def __init__(self, node, root=None, parent=None, *args, **kwargs):
         # children
         self.segmentTimeline = self.only_child(SegmentTimeline)
 
-    def segments(self, **kwargs) -> Iterator[Segment]:
+    def segments(self, base_url: str, **kwargs) -> Iterator[Segment]:
         if kwargs.pop("init", True):
-            init_url = self.format_initialization(**kwargs)
+            init_url = self.format_initialization(base_url, **kwargs)
             if init_url:
                 yield Segment(
                     url=init_url,
@@ -623,7 +623,7 @@ def segments(self, **kwargs) -> Iterator[Segment]:
                     init=True,
                     content=False,
                 )
-        for media_url, available_at in self.format_media(**kwargs):
+        for media_url, available_at in self.format_media(base_url, **kwargs):
             yield Segment(
                 url=media_url,
                 duration=self.duration_seconds,
@@ -632,18 +632,13 @@ def segments(self, **kwargs) -> Iterator[Segment]:
                 available_at=available_at,
             )
 
-    def make_url(self, url: str) -> str:
-        """
-        Join the URL with the base URL, unless it's an absolute URL
-        :param url: maybe relative URL
-        :return: joined URL
-        """
-
-        return BaseURL.join(self.base_url, url)
+    @staticmethod
+    def make_url(base_url: str, url: str) -> str:
+        return BaseURL.join(base_url, url)
 
-    def format_initialization(self, **kwargs) -> Optional[str]:
+    def format_initialization(self, base_url: str, **kwargs) -> Optional[str]:
         if self.initialization:
-            return self.make_url(self.initialization(**kwargs))
+            return self.make_url(base_url, self.initialization(**kwargs))
 
     def segment_numbers(self) -> Iterator[Tuple[int, datetime.datetime]]:
         """
@@ -698,10 +693,10 @@ def segment_numbers(self) -> Iterator[Tuple[int, datetime.datetime]]:
 
         yield from zip(number_iter, available_iter)
 
-    def format_media(self, **kwargs) -> Iterator[Tuple[str, datetime.datetime]]:
+    def format_media(self, base_url: str, **kwargs) -> Iterator[Tuple[str, datetime.datetime]]:
         if not self.segmentTimeline:
             for number, available_at in self.segment_numbers():
-                url = self.make_url(self.media(Number=number, **kwargs))
+                url = self.make_url(base_url, self.media(Number=number, **kwargs))
                 yield url, available_at
             return
 
@@ -714,7 +709,7 @@ def format_media(self, **kwargs) -> Iterator[Tuple[str, datetime.datetime]]:
 
         if self.root.type == "static":
             for segment, n in zip(self.segmentTimeline.segments, count(self.startNumber)):
-                url = self.make_url(self.media(Time=segment.t, Number=n, **kwargs))
+                url = self.make_url(base_url, self.media(Time=segment.t, Number=n, **kwargs))
                 available_at = datetime.datetime.now(tz=UTC)  # TODO: replace with EPOCH_START ?!
                 yield url, available_at
             return
@@ -731,7 +726,7 @@ def format_media(self, **kwargs) -> Iterator[Tuple[str, datetime.datetime]]:
             # the last segment in the timeline is the most recent one
             # so, work backwards and calculate when each of the segments was
             # available, based on the durations relative to the publish-time
-            url = self.make_url(self.media(Time=segment.t, Number=n, **kwargs))
+            url = self.make_url(base_url, self.media(Time=segment.t, Number=n, **kwargs))
             duration = datetime.timedelta(seconds=segment.d / self.timescale)
 
             # once the suggested_delay is reach stop
@@ -830,6 +825,7 @@ def segments(self, **kwargs) -> Iterator[Segment]:
 
         if segmentTemplate:
             yield from segmentTemplate.segments(
+                self.base_url,
                 RepresentationID=self.id,
                 Bandwidth=int(self.bandwidth * 1000),
                 **kwargs,


Test patch:
diff --git a/tests/resources/dash/test_nested_baseurls.mpd b/tests/resources/dash/test_nested_baseurls.mpd
new file mode 100644
--- /dev/null
+++ b/tests/resources/dash/test_nested_baseurls.mpd
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<MPD
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xmlns:xlink="http://www.w3.org/1999/xlink"
+  xmlns="urn:mpeg:dash:schema:mpd:2011"
+  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-live:2011"
+  type="static"
+  mediaPresentationDuration="PT0H0M6.00S"
+  minBufferTime="PT6.0S"
+>
+  <BaseURL>https://hostname/</BaseURL>
+  <Period id="0" start="PT0.0S">
+    <BaseURL>period/</BaseURL>
+    <AdaptationSet
+      id="0"
+      mimeType="video/mp4"
+      maxWidth="1920"
+      maxHeight="1080"
+      par="16:9"
+      frameRate="60"
+      segmentAlignment="true"
+      startWithSAP="1"
+      subsegmentAlignment="true"
+      subsegmentStartsWithSAP="1"
+    >
+      <SegmentTemplate
+        presentationTimeOffset="0"
+        timescale="90000"
+        duration="540000"
+        startNumber="1"
+        media="media_$RepresentationID$-$Number$.m4s"
+        initialization="init_$RepresentationID$.m4s"
+      />
+      <Representation id="video_5000kbps" codecs="avc1.640028" width="1920" height="1080" sar="1:1" bandwidth="5000000"/>
+      <Representation id="video_9000kbps" codecs="avc1.640028" width="1920" height="1080" sar="1:1" bandwidth="9000000">
+        <BaseURL>representation/</BaseURL>
+      </Representation>
+    </AdaptationSet>
+    <AdaptationSet
+      id="1"
+      mimeType="audio/mp4"
+      lang="eng"
+      segmentAlignment="true"
+      startWithSAP="1"
+      subsegmentAlignment="true"
+      subsegmentStartsWithSAP="1"
+    >
+      <BaseURL>adaptationset/</BaseURL>
+      <SegmentTemplate
+        presentationTimeOffset="0"
+        timescale="44100"
+        duration="264600"
+        startNumber="1"
+        media="media_$RepresentationID$-$Number$.m4s"
+        initialization="init_$RepresentationID$.m4s"
+      />
+      <Representation id="audio_128kbps" codecs="mp4a.40.2" audioSamplingRate="44100" sar="1:1" bandwidth="128000"/>
+      <Representation id="audio_256kbps" codecs="mp4a.40.2" audioSamplingRate="44100" sar="1:1" bandwidth="256000">
+        <BaseURL>representation/</BaseURL>
+      </Representation>
+      <Representation id="audio_320kbps" codecs="mp4a.40.2" audioSamplingRate="44100" sar="1:1" bandwidth="320000">
+        <BaseURL>https://other/</BaseURL>
+      </Representation>
+    </AdaptationSet>
+  </Period>
+</MPD>
diff --git a/tests/stream/test_dash_parser.py b/tests/stream/test_dash_parser.py
--- a/tests/stream/test_dash_parser.py
+++ b/tests/stream/test_dash_parser.py
@@ -287,3 +287,33 @@ def test_segments_byterange(self):
                 ("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 = [
+            [seg.url for seg in itertools.islice(representation.segments(), 2)]
+            for adaptationset in mpd.periods[0].adaptationSets for representation in adaptationset.representations
+        ]
+        assert segment_urls == [
+            [
+                "https://hostname/period/init_video_5000kbps.m4s",
+                "https://hostname/period/media_video_5000kbps-1.m4s",
+            ],
+            [
+                "https://hostname/period/representation/init_video_9000kbps.m4s",
+                "https://hostname/period/representation/media_video_9000kbps-1.m4s",
+            ],
+            [
+                "https://hostname/period/adaptationset/init_audio_128kbps.m4s",
+                "https://hostname/period/adaptationset/media_audio_128kbps-1.m4s",
+            ],
+            [
+                "https://hostname/period/adaptationset/representation/init_audio_256kbps.m4s",
+                "https://hostname/period/adaptationset/representation/media_audio_256kbps-1.m4s",
+            ],
+            [
+                "https://other/init_audio_320kbps.m4s",
+                "https://other/media_audio_320kbps-1.m4s",
+            ],
+        ]