Repository: streamlink/streamlink

Issue:
stream.dash (or stream.segmented): takes long time to finish dash manifest
### Checklist

- [X] This is a bug report 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 bug reports](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest stable release

### Description

When streamlink processes DASH manifest and makes an output, it takes a very long time in finishing. It took about 5 seconds in my test every time.

I generated and tested the input files as follows.
```
$ mkdir /tmp/test
$ ffmpeg -f lavfi -i testsrc \
-use_template 0 \
-init_seg_name 'init$RepresentationID$.$ext$' \
-media_seg_name 'seg$RepresentationID$-$Number%02d$.$ext$' \
-dash_segment_type mp4 \
-hls_playlist 1 -hls_master_name master.m3u8 \
-f dash -t 30 /tmp/test/manifest.mpd
```
Then ran streamlink.
```
$ streamlink -l trace dash://file:///tmp/test/manifest.mpd best -o /dev/null
$ streamlink -l trace hls://file:///tmp/test/master.m3u8 best -o /dev/null
```
In the following log, please notice these lines for DASH manifest. For HLS playlist, streamlink finishes soon.
```
[00:59:47.924248][stream.dash][debug] Download of segment: file:///tmp/test/seg0-03.m4s complete
[00:59:52.922646][stream.segmented][debug] Closing worker thread
```


### Debug log

```text
# log for DASH manifest
[00:59:47.815443][cli][debug] OS:         macOS 10.12.6
[00:59:47.815670][cli][debug] Python:     3.9.10
[00:59:47.815745][cli][debug] Streamlink: 3.1.0
[00:59:47.815865][cli][debug] Requests(2.27.1), Socks(1.7.1), Websocket(1.2.3)
[00:59:47.816320][cli][debug] Arguments:
[00:59:47.816447][cli][debug]  url=dash://file:///tmp/test/manifest.mpd
[00:59:47.816647][cli][debug]  stream=['best']
[00:59:47.816800][cli][debug]  --loglevel=trace
[00:59:47.816950][cli][debug]  --output=/dev/null
[00:59:47.817352][cli][info] Found matching plugin dash for URL dash://file:///tmp/test/manifest.mpd
[00:59:47.817557][plugins.dash][debug] Parsing MPD URL: file:///tmp/test/manifest.mpd
[00:59:47.915656][utils.l10n][debug] Language code: en_US
[00:59:47.915826][stream.dash][debug] Available languages for DASH audio streams: NONE (using: n/a)
[00:59:47.917304][cli][info] Available streams: 240p (worst, best)
[00:59:47.917486][cli][info] Opening stream: 240p (dash)
[00:59:47.917880][cli][debug] Checking file output
[00:59:47.918306][stream.dash][debug] Opening DASH reader for: 0 (video/mp4)
[00:59:47.919071][cli][debug] Pre-buffering 8192 bytes
[00:59:47.920896][stream.dash][debug] Download of segment: file:///tmp/test/init0.m4s complete
[00:59:47.921416][cli][debug] Writing stream to output
[00:59:47.922346][stream.dash][debug] Download of segment: file:///tmp/test/seg0-01.m4s complete
[00:59:47.923426][stream.dash][debug] Download of segment: file:///tmp/test/seg0-02.m4s complete
[00:59:47.924248][stream.dash][debug] Download of segment: file:///tmp/test/seg0-03.m4s complete
[00:59:52.922646][stream.segmented][debug] Closing worker thread
[00:59:52.923078][stream.segmented][debug] Closing writer thread

[00:59:52.923513][cli][info] Stream ended
[00:59:52.923871][cli][info] Closing currently open stream...

# log for HLS playlist for comparison
[01:00:01.271367][cli][debug] OS:         macOS 10.12.6
[01:00:01.271601][cli][debug] Python:     3.9.10
[01:00:01.271713][cli][debug] Streamlink: 3.1.0
[01:00:01.271866][cli][debug] Requests(2.27.1), Socks(1.7.1), Websocket(1.2.3)
[01:00:01.272300][cli][debug] Arguments:
[01:00:01.272396][cli][debug]  url=hls://file:///tmp/test/master.m3u8
[01:00:01.272525][cli][debug]  stream=['best']
[01:00:01.272673][cli][debug]  --loglevel=trace
[01:00:01.272824][cli][debug]  --output=/dev/null
[01:00:01.273216][cli][info] Found matching plugin hls for URL hls://file:///tmp/test/master.m3u8
[01:00:01.273371][plugins.hls][debug] URL=file:///tmp/test/master.m3u8; params={}
[01:00:01.359532][utils.l10n][debug] Language code: en_US
[01:00:01.365674][cli][info] Available streams: 240p (worst, best)
[01:00:01.365858][cli][info] Opening stream: 240p (hls)
[01:00:01.366116][cli][debug] Checking file output
[01:00:01.367035][stream.hls][debug] Reloading playlist
[01:00:01.367991][cli][debug] Pre-buffering 8192 bytes
[01:00:01.380139][stream.hls][debug] First Sequence: 1; Last Sequence: 3
[01:00:01.380287][stream.hls][debug] Start offset: 0; Duration: None; Start Sequence: 1; End Sequence: 3
[01:00:01.380366][stream.hls][debug] Adding segment 1 to queue
[01:00:01.381053][stream.hls][debug] Adding segment 2 to queue
[01:00:01.381717][stream.hls][debug] Adding segment 3 to queue
[01:00:01.381872][stream.segmented][debug] Closing worker thread
[01:00:01.382355][stream.hls][debug] Segment initialization 1 complete
[01:00:01.383110][cli][debug] Writing stream to output
[01:00:01.384028][stream.hls][debug] Segment 1 complete
[01:00:01.384196][stream.hls][debug] Segment initialization 2 complete
[01:00:01.385400][stream.hls][debug] Segment 2 complete
[01:00:01.385591][stream.hls][debug] Segment initialization 3 complete
[01:00:01.386479][stream.hls][debug] Segment 3 complete
[01:00:01.386975][stream.segmented][debug] Closing writer thread

[01:00:01.387420][cli][info] Stream ended
[01:00:01.387602][cli][info] Closing currently open stream...
```



Discussion:
Duplicate of #3137
This doesn't seem to be a duplicate of that issue. I'm not using Windows and this report is not related to stream.ffmpegmux nor utils.named_pipe.

I ran streamlink with options similar to that issue and had no delay in finishing. But changed stream 480p to worst (because of YouTube throttling) and vlc to mpv. (From the output of `youtube-dl -F`, both v 135 for 480p and v 160 for worst are mp4_dash containers so using worst shouldn't make any difference.)
```
$ streamlink -l trace https://www.youtube.com/watch?v=ET9HeSFYlEs worst --stream-type muxed-stream -p mpv -v --player-no-close
```
### Debug log
There is a gap in the time shown in the log, but when the playback is finished and mpv window is closed, streamlink also finishes immediately. '10:49:47' (start writing stream to output) plus '00:04:13' (duration of the stream) gets '10:54:00'.
And, if I quit mpv with 'q' during playback, streamlink will finish immediately as expected.
```
[10:49:45.823986][cli][debug] OS:         macOS 10.12.6
[10:49:45.824211][cli][debug] Python:     3.9.10
[10:49:45.824286][cli][debug] Streamlink: 3.1.0
[10:49:45.824424][cli][debug] Requests(2.27.1), Socks(1.7.1), Websocket(1.2.3)
[10:49:45.824811][cli][debug] Arguments:
[10:49:45.824898][cli][debug]  url=https://www.youtube.com/watch?v=ET9HeSFYlEs
[10:49:45.825062][cli][debug]  stream=['worst']
[10:49:45.825215][cli][debug]  --loglevel=trace
[10:49:45.825351][cli][debug]  --player=mpv
[10:49:45.825487][cli][debug]  --verbose-player=True
[10:49:45.825620][cli][debug]  --player-no-close=True
[10:49:45.825761][cli][debug]  --stream-types=['muxed-stream']
[10:49:45.826159][cli][info] Found matching plugin youtube for URL https://www.youtube.com/watch?v=ET9HeSFYlEs
[10:49:46.309300][plugins.youtube][trace] videoDetails = ('ET9HeSFYlEs', 'Jump Cuts', 'Comedy', 'Ajith fans vs vijay fans - a conclusion', None)
[10:49:46.309481][plugins.youtube][debug] Using video ID: ET9HeSFYlEs
[10:49:46.313456][plugins.youtube][debug] MuxedStream: v 137 a 251 = 1080p
[10:49:46.313610][plugins.youtube][debug] MuxedStream: v 135 a 251 = 480p
[10:49:46.313710][plugins.youtube][debug] MuxedStream: v 133 a 251 = 240p
[10:49:46.313872][plugins.youtube][debug] MuxedStream: v 160 a 251 = 144p
[10:49:46.315920][cli][info] Available streams: 144p (worst), 240p, 480p, 1080p (best)
[10:49:46.316080][cli][info] Opening stream: 144p (muxed-stream)
[10:49:46.316220][cli][info] Starting player: mpv
[10:49:46.316471][stream.ffmpegmux][debug] Opening http substream
[10:49:46.351239][stream.ffmpegmux][debug] Opening http substream
[10:49:46.401414][utils.named_pipe][info] Creating pipe streamlinkpipe-4405-1-3045
[10:49:46.403148][utils.named_pipe][info] Creating pipe streamlinkpipe-4405-2-3335
[10:49:46.403735][stream.ffmpegmux][debug] ffmpeg command: ffmpeg -nostats -y -i /tmp/streamlinkpipe-4405-1-3045 -i /tmp/streamlinkpipe-4405-2-3335 -c:v copy -c:a copy -map 0 -map 1 -f matroska pipe:1
[10:49:46.404102][stream.ffmpegmux][debug] Starting copy to pipe: /tmp/streamlinkpipe-4405-1-3045
[10:49:46.404495][stream.ffmpegmux][debug] Starting copy to pipe: /tmp/streamlinkpipe-4405-2-3335
[10:49:46.412891][cli][debug] Pre-buffering 8192 bytes
[10:49:46.609740][cli.output][debug] Opening subprocess: mpv --force-media-title=https://www.youtube.com/watch?v=ET9HeSFYlEs -
[file] Reading from stdin...
[10:49:47.119582][cli][debug] Writing stream to output
 (+) Video --vid=1 (*) (h264 256x144 23.976fps)
 (+) Audio --aid=1 --alang=eng (*) (opus 2ch 48000Hz)
[vo/gpu] opengl cocoa backend is deprecated, use vo=libmpv instead
AO: [coreaudio] 48000Hz stereo 2ch floatp
VO: [gpu] 256x144 yuv420p
[10:53:44.688544][stream.ffmpegmux][debug] Pipe copy complete: /tmp/streamlinkpipe-4405-1-3045
[10:53:47.487416][stream.ffmpegmux][debug] Pipe copy complete: /tmp/streamlinkpipe-4405-2-3335
[10:53:54.922372][stream.ffmpegmux][debug] Closing ffmpeg thread
[10:53:54.922803][stream.ffmpegmux][debug] Closed all the substreams
[10:53:54.922967][cli][info] Stream ended
AV: 00:04:13 / 00:04:13 (100%) A-V:  0.000

Exiting... (End of file)
[10:54:00.553670][cli][info] Closing currently open stream...
```

Let me see...

It's possible (or rather likely) that this is caused by the `sleep_until` method call in the `DASHStreamWriter` thread.
- https://github.com/streamlink/streamlink/blame/3.1.1/src/streamlink/stream/dash_manifest.py#L70-L74
- https://github.com/streamlink/streamlink/blame/3.1.1/src/streamlink/stream/dash.py#L36

I haven't been involved with Streamlink's DASH implementation, but I recently rewrote the UStream plugin which was previously using this same method and its own writer thread was stalling because of that.

The writer thread shouldn't call `time.sleep` and should instead wait for a thread-lock to time out which other threads (main thread or worker thread) can set to "cancel" the writer thread. That would solve the issue.
The `DASHStreamWorker` thread has the same issue, which is probably the main reason here, because there are no "Waiting for segment" log messages in your log output. The worker basically does the same, but with the `sleeper` method.

- https://github.com/streamlink/streamlink/blame/3.1.1/src/streamlink/stream/dash_manifest.py#L61-L67
- https://github.com/streamlink/streamlink/blame/3.1.1/src/streamlink/stream/dash.py#L91

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
@@ -2,13 +2,15 @@
 import datetime
 import itertools
 import logging
-import os.path
 from collections import defaultdict
+from contextlib import contextmanager
+from pathlib import Path
+from time import time
 from typing import Dict, Optional
 from urllib.parse import urlparse, urlunparse
 
 from streamlink import PluginError, StreamError
-from streamlink.stream.dash_manifest import MPD, Representation, freeze_timeline, sleep_until, sleeper, utc
+from streamlink.stream.dash_manifest import MPD, Representation, Segment, freeze_timeline, utc
 from streamlink.stream.ffmpegmux import FFMPEGMuxer
 from streamlink.stream.segmented import SegmentedStreamReader, SegmentedStreamWorker, SegmentedStreamWriter
 from streamlink.stream.stream import Stream
@@ -19,6 +21,10 @@
 
 
 class DASHStreamWriter(SegmentedStreamWriter):
+    @staticmethod
+    def _get_segment_name(segment: Segment) -> str:
+        return Path(urlparse(segment.url).path).resolve().name
+
     def fetch(self, segment, retries=None):
         if self.closed or not retries:
             return
@@ -29,9 +35,11 @@ def fetch(self, segment, retries=None):
             now = datetime.datetime.now(tz=utc)
             if segment.available_at > now:
                 time_to_wait = (segment.available_at - now).total_seconds()
-                fname = os.path.basename(urlparse(segment.url).path)
-                log.debug("Waiting for segment: {fname} ({wait:.01f}s)".format(fname=fname, wait=time_to_wait))
-                sleep_until(segment.available_at)
+                fname = self._get_segment_name(segment)
+                log.debug(f"Waiting for segment: {fname} ({time_to_wait:.01f}s)")
+                if not self.wait(time_to_wait):
+                    log.debug(f"Waiting for segment: {fname} aborted")
+                    return
 
             if segment.range:
                 start, length = segment.range
@@ -39,7 +47,7 @@ def fetch(self, segment, retries=None):
                     end = start + length - 1
                 else:
                     end = ""
-                headers["Range"] = "bytes={0}-{1}".format(start, end)
+                headers["Range"] = f"bytes={start}-{end}"
 
             return self.session.http.get(segment.url,
                                          timeout=self.timeout,
@@ -51,14 +59,14 @@ def fetch(self, segment, retries=None):
             return self.fetch(segment, retries - 1)
 
     def write(self, segment, res, chunk_size=8192):
+        name = self._get_segment_name(segment)
         for chunk in res.iter_content(chunk_size):
-            if not self.closed:
-                self.reader.buffer.write(chunk)
-            else:
-                log.warning("Download of segment: {} aborted".format(segment.url))
+            if self.closed:
+                log.warning(f"Download of segment: {name} aborted")
                 return
+            self.reader.buffer.write(chunk)
 
-        log.debug("Download of segment: {} complete".format(segment.url))
+        log.debug(f"Download of segment: {name} complete")
 
 
 class DASHStreamWorker(SegmentedStreamWorker):
@@ -67,6 +75,17 @@ def __init__(self, *args, **kwargs):
         self.mpd = self.stream.mpd
         self.period = self.stream.period
 
+    @contextmanager
+    def sleeper(self, duration):
+        """
+        Do something and then wait for a given duration minus the time it took doing something
+        """
+        s = time()
+        yield
+        time_to_sleep = duration - (time() - s)
+        if time_to_sleep > 0:
+            self.wait(time_to_sleep)
+
     @staticmethod
     def get_representation(mpd, representation_id, mime_type):
         for aset in mpd.periods[0].adaptationSets:
@@ -80,28 +99,35 @@ def iter_segments(self):
         while not self.closed:
             # find the representation by ID
             representation = self.get_representation(self.mpd, self.reader.representation_id, self.reader.mime_type)
-            refresh_wait = max(self.mpd.minimumUpdatePeriod.total_seconds(),
-                               self.mpd.periods[0].duration.total_seconds()) or 5
 
             if self.mpd.type == "static":
                 refresh_wait = 5
+            else:
+                refresh_wait = max(
+                    self.mpd.minimumUpdatePeriod.total_seconds(),
+                    self.mpd.periods[0].duration.total_seconds(),
+                ) or 5
+
+            with self.sleeper(refresh_wait * back_off_factor):
+                if not representation:
+                    continue
+
+                for segment in representation.segments(init=init):
+                    if self.closed:
+                        break
+                    yield segment
+
+                # close worker if type is not dynamic (all segments were put into writer queue)
+                if self.mpd.type != "dynamic":
+                    self.close()
+                    return
+
+                if not self.reload():
+                    back_off_factor = max(back_off_factor * 1.3, 10.0)
+                else:
+                    back_off_factor = 1
 
-            with sleeper(refresh_wait * back_off_factor):
-                if representation:
-                    for segment in representation.segments(init=init):
-                        if self.closed:
-                            break
-                        yield segment
-                        # log.debug(f"Adding segment {segment.url} to queue")
-
-                    if self.mpd.type == "dynamic":
-                        if not self.reload():
-                            back_off_factor = max(back_off_factor * 1.3, 10.0)
-                        else:
-                            back_off_factor = 1
-                    else:
-                        return
-                    init = False
+                init = False
 
     def reload(self):
         if self.closed:
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
@@ -3,7 +3,6 @@
 import logging
 import math
 import re
-import time
 from collections import defaultdict, namedtuple
 from contextlib import contextmanager
 from itertools import count, repeat
@@ -45,22 +44,6 @@ def freeze_timeline(mpd):
     mpd.timelines = timelines
 
 
-@contextmanager
-def sleeper(duration):
-    s = time.time()
-    yield
-    time_to_sleep = duration - (time.time() - s)
-    if time_to_sleep > 0:
-        time.sleep(time_to_sleep)
-
-
-def sleep_until(walltime):
-    c = datetime.datetime.now(tz=utc)
-    time_to_wait = (walltime - c).total_seconds()
-    if time_to_wait > 0:
-        time.sleep(time_to_wait)
-
-
 class MPDParsers:
     @staticmethod
     def bool_str(v):
diff --git a/src/streamlink/stream/segmented.py b/src/streamlink/stream/segmented.py
--- a/src/streamlink/stream/segmented.py
+++ b/src/streamlink/stream/segmented.py
@@ -36,7 +36,20 @@ def shutdown(self, wait=True, cancel_futures=False):  # pragma: no cover
                     t.join()
 
 
-class SegmentedStreamWorker(Thread):
+class AwaitableMixin:
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._wait = Event()
+
+    def wait(self, time: float) -> bool:
+        """
+        Pause the thread for a specified time.
+        Return False if interrupted by another thread and True if the time runs out normally.
+        """
+        return not self._wait.wait(time)
+
+
+class SegmentedStreamWorker(AwaitableMixin, Thread):
     """The general worker thread.
 
     This thread is responsible for queueing up segments in the
@@ -44,16 +57,13 @@ class SegmentedStreamWorker(Thread):
     """
 
     def __init__(self, reader, **kwargs):
+        super().__init__(daemon=True, name=f"Thread-{self.__class__.__name__}")
         self.closed = False
         self.reader = reader
         self.writer = reader.writer
         self.stream = reader.stream
         self.session = reader.session
 
-        self._wait = Event()
-
-        super().__init__(daemon=True, name=f"Thread-{self.__class__.__name__}")
-
     def close(self):
         """Shuts down the thread."""
         if self.closed:  # pragma: no cover
@@ -64,14 +74,6 @@ def close(self):
         self.closed = True
         self._wait.set()
 
-    def wait(self, time):
-        """Pauses the thread for a specified time.
-
-        Returns False if interrupted by another thread and True if the
-        time runs out normally.
-        """
-        return not self._wait.wait(time)
-
     def iter_segments(self):
         """The iterator that generates segments for the worker thread.
 
@@ -91,7 +93,7 @@ def run(self):
         self.close()
 
 
-class SegmentedStreamWriter(Thread):
+class SegmentedStreamWriter(AwaitableMixin, Thread):
     """The writer thread.
 
     This thread is responsible for fetching segments, processing them
@@ -99,6 +101,7 @@ class SegmentedStreamWriter(Thread):
     """
 
     def __init__(self, reader, size=20, retries=None, threads=None, timeout=None):
+        super().__init__(daemon=True, name=f"Thread-{self.__class__.__name__}")
         self.closed = False
         self.reader = reader
         self.stream = reader.stream
@@ -119,8 +122,6 @@ def __init__(self, reader, size=20, retries=None, threads=None, timeout=None):
         self.executor = CompatThreadPoolExecutor(max_workers=self.threads)
         self.futures = queue.Queue(size)
 
-        super().__init__(daemon=True, name=f"Thread-{self.__class__.__name__}")
-
     def close(self):
         """Shuts down the thread, its executor and closes the reader (worker thread and buffer)."""
         if self.closed:  # pragma: no cover
@@ -131,6 +132,7 @@ def close(self):
         self.closed = True
         self.reader.close()
         self.executor.shutdown(wait=True, cancel_futures=True)
+        self._wait.set()
 
     def put(self, segment):
         """Adds a segment to the download pool and write queue."""


Test patch:
diff --git a/tests/stream/test_dash.py b/tests/stream/test_dash.py
--- a/tests/stream/test_dash.py
+++ b/tests/stream/test_dash.py
@@ -1,6 +1,9 @@
 import unittest
+from typing import List
 from unittest.mock import ANY, MagicMock, Mock, call, patch
 
+import pytest
+
 from streamlink import PluginError
 from streamlink.stream.dash import DASHStream, DASHStreamWorker
 from streamlink.stream.dash_manifest import MPD
@@ -300,124 +303,145 @@ def test_parse_manifest_with_duplicated_resolutions_sorted_bandwidth(self, mpdCl
         self.assertEqual(streams["1080p_alt2"].video_representation.bandwidth, 32.0)
 
 
-class TestDASHStreamWorker(unittest.TestCase):
-    @patch("streamlink.stream.dash_manifest.time.sleep")
-    @patch('streamlink.stream.dash.MPD')
-    def test_dynamic_reload(self, mpdClass, sleep):
-        reader = MagicMock()
+class TestDASHStreamWorker:
+    @pytest.fixture
+    def mock_time(self, monkeypatch: pytest.MonkeyPatch) -> Mock:
+        mock = Mock(return_value=1)
+        monkeypatch.setattr("streamlink.stream.dash.time", mock)
+        return mock
+
+    @pytest.fixture(autouse=True)
+    def mock_wait(self, monkeypatch: pytest.MonkeyPatch) -> Mock:
+        mock = Mock(return_value=True)
+        monkeypatch.setattr("streamlink.stream.dash.DASHStreamWorker.wait", mock)
+        return mock
+
+    @pytest.fixture
+    def representation(self) -> Mock:
+        return Mock(id=1, mimeType="video/mp4", height=720)
+
+    @pytest.fixture
+    def segments(self) -> List[Mock]:
+        return [
+            Mock(url="init_segment"),
+            Mock(url="first_segment"),
+            Mock(url="second_segment"),
+        ]
+
+    @pytest.fixture
+    def mpd(self, representation) -> Mock:
+        return Mock(
+            publishTime=1,
+            minimumUpdatePeriod=Mock(total_seconds=Mock(return_value=0)),
+            periods=[
+                Mock(
+                    duration=Mock(total_seconds=Mock(return_value=0)),
+                    adaptationSets=[
+                        Mock(
+                            contentProtection=None,
+                            representations=[representation],
+                        ),
+                    ],
+                ),
+            ],
+        )
+
+    @pytest.fixture
+    def worker(self, mpd):
+        reader = MagicMock(representation_id=1, mime_type="video/mp4")
         worker = DASHStreamWorker(reader)
-        reader.representation_id = 1
-        reader.mime_type = "video/mp4"
-
-        representation = Mock(id=1, mimeType="video/mp4", height=720)
-        segments = [Mock(url="init_segment"), Mock(url="first_segment"), Mock(url="second_segment")]
-        representation.segments.return_value = [segments[0]]
-        mpdClass.return_value = worker.mpd = Mock(dynamic=True,
-                                                  publishTime=1,
-                                                  periods=[
-                                                      Mock(adaptationSets=[
-                                                          Mock(contentProtection=None,
-                                                               representations=[
-                                                                   representation
-                                                               ])
-                                                      ])
-                                                  ])
-        worker.mpd.type = "dynamic"
-        worker.mpd.minimumUpdatePeriod.total_seconds.return_value = 0
-        worker.mpd.periods[0].duration.total_seconds.return_value = 0
+        worker.mpd = mpd
+        return worker
+
+    def test_dynamic_reload(
+        self,
+        monkeypatch: pytest.MonkeyPatch,
+        worker: DASHStreamWorker,
+        representation: Mock,
+        segments: List[Mock],
+        mpd: Mock,
+    ):
+        mpd.dynamic = True
+        mpd.type = "dynamic"
+        monkeypatch.setattr("streamlink.stream.dash.MPD", lambda *args, **kwargs: mpd)
 
         segment_iter = worker.iter_segments()
 
         representation.segments.return_value = segments[:1]
-        self.assertEqual(next(segment_iter), segments[0])
-        representation.segments.assert_called_with(init=True)
+        assert next(segment_iter) is segments[0]
+        assert representation.segments.call_args_list == [call(init=True)]
+        assert not worker._wait.is_set()
 
+        representation.segments.reset_mock()
         representation.segments.return_value = segments[1:]
-        self.assertSequenceEqual([next(segment_iter), next(segment_iter)], segments[1:])
-        representation.segments.assert_called_with(init=False)
-
-    @patch("streamlink.stream.dash_manifest.time.sleep")
-    def test_static(self, sleep):
-        reader = MagicMock()
-        worker = DASHStreamWorker(reader)
-        reader.representation_id = 1
-        reader.mime_type = "video/mp4"
-
-        representation = Mock(id=1, mimeType="video/mp4", height=720)
-        segments = [Mock(url="init_segment"), Mock(url="first_segment"), Mock(url="second_segment")]
-        representation.segments.return_value = [segments[0]]
-        worker.mpd = Mock(dynamic=False,
-                          publishTime=1,
-                          periods=[
-                              Mock(adaptationSets=[
-                                  Mock(contentProtection=None,
-                                       representations=[
-                                           representation
-                                       ])
-                              ])
-                          ])
-        worker.mpd.type = "static"
-        worker.mpd.minimumUpdatePeriod.total_seconds.return_value = 0
-        worker.mpd.periods[0].duration.total_seconds.return_value = 0
+        assert [next(segment_iter), next(segment_iter)] == segments[1:]
+        assert representation.segments.call_args_list == [call(), call(init=False)]
+        assert not worker._wait.is_set()
+
+    def test_static(
+        self,
+        worker: DASHStreamWorker,
+        representation: Mock,
+        segments: List[Mock],
+        mpd: Mock,
+    ):
+        mpd.dynamic = False
+        mpd.type = "static"
 
         representation.segments.return_value = segments
-        self.assertSequenceEqual(list(worker.iter_segments()), segments)
-        representation.segments.assert_called_with(init=True)
-
-    @patch("streamlink.stream.dash_manifest.time.time")
-    @patch("streamlink.stream.dash_manifest.time.sleep")
-    def test_static_refresh_wait(self, sleep, time):
+        assert list(worker.iter_segments()) == segments
+        assert representation.segments.call_args_list == [call(init=True)]
+        assert worker._wait.is_set()
+
+    @pytest.mark.parametrize("duration", [
+        0,
+        204.32,
+    ])
+    def test_static_refresh_wait(
+        self,
+        duration: float,
+        mock_wait: Mock,
+        mock_time: Mock,
+        worker: DASHStreamWorker,
+        representation: Mock,
+        segments: List[Mock],
+        mpd: Mock,
+    ):
         """
             Verify the fix for https://github.com/streamlink/streamlink/issues/2873
         """
-        time.return_value = 1
-        reader = MagicMock()
-        worker = DASHStreamWorker(reader)
-        reader.representation_id = 1
-        reader.mime_type = "video/mp4"
-
-        representation = Mock(id=1, mimeType="video/mp4", height=720)
-        segments = [Mock(url="init_segment"), Mock(url="first_segment"), Mock(url="second_segment")]
-        representation.segments.return_value = [segments[0]]
-        worker.mpd = Mock(dynamic=False,
-                          publishTime=1,
-                          periods=[
-                              Mock(adaptationSets=[
-                                  Mock(contentProtection=None,
-                                       representations=[
-                                           representation
-                                       ])
-                              ])
-                          ])
-        worker.mpd.type = "static"
-        for duration in (0, 204.32):
-            worker.mpd.minimumUpdatePeriod.total_seconds.return_value = 0
-            worker.mpd.periods[0].duration.total_seconds.return_value = duration
-
-            representation.segments.return_value = segments
-            self.assertSequenceEqual(list(worker.iter_segments()), segments)
-            representation.segments.assert_called_with(init=True)
-            sleep.assert_called_with(5)
-
-    @patch("streamlink.stream.dash_manifest.time.sleep")
-    def test_duplicate_rep_id(self, sleep):
+        mpd.dynamic = False
+        mpd.type = "static"
+        mpd.periods[0].duration.total_seconds.return_value = duration
+
+        representation.segments.return_value = segments
+        assert list(worker.iter_segments()) == segments
+        assert representation.segments.call_args_list == [call(init=True)]
+        assert mock_wait.call_args_list == [call(5)]
+        assert worker._wait.is_set()
+
+    def test_duplicate_rep_id(self):
         representation_vid = Mock(id=1, mimeType="video/mp4", height=720)
-        representation_aud = Mock(id=1, mimeType="audio/aac", lang='en')
-
-        mpd = Mock(dynamic=False,
-                   publishTime=1,
-                   periods=[
-                       Mock(adaptationSets=[
-                           Mock(contentProtection=None,
-                                representations=[
-                                    representation_vid
-                                ]),
-                           Mock(contentProtection=None,
-                                representations=[
-                                    representation_aud
-                                ])
-                       ])
-                   ])
-
-        self.assertEqual(representation_vid, DASHStreamWorker.get_representation(mpd, 1, "video/mp4"))
-        self.assertEqual(representation_aud, DASHStreamWorker.get_representation(mpd, 1, "audio/aac"))
+        representation_aud = Mock(id=1, mimeType="audio/aac", lang="en")
+
+        mpd = Mock(
+            dynamic=False,
+            publishTime=1,
+            periods=[
+                Mock(
+                    adaptationSets=[
+                        Mock(
+                            contentProtection=None,
+                            representations=[representation_vid],
+                        ),
+                        Mock(
+                            contentProtection=None,
+                            representations=[representation_aud],
+                        ),
+                    ],
+                ),
+            ],
+        )
+
+        assert DASHStreamWorker.get_representation(mpd, 1, "video/mp4") is representation_vid
+        assert DASHStreamWorker.get_representation(mpd, 1, "audio/aac") is representation_aud