Repository: streamlink/streamlink

Issue:
stream.hls: stop stream early when EXT-X-ENDLIST is missing
When a live stream ends, streamlink retries 12 times and then exits. (12 is a strange number and I'm not sure where it comes from. `hls-segment-attempts` is default 3, so maybe the code that uses it is being done 4 times, for a total of 12. That's just stabbing in the dark though.)

I would like to limit that to only 1 retry, because I'm worried about hitting the server too much. I was once IP banned from a server for using an automated download program, and it now makes me worry about making too many attempts-- especially attempts which are clearly automated.

Ideally, when a live stream ends, I'd like to wait 60 seconds, then retry just once. Is there a way to do that?

The message I get when a stream ends is `[stream.hls][warning] Failed to reload playlist: Unable to open URL: ... (403 Client Error: Forbidden for url:..)`, which comes from hls.py.

This is streamlink 0.14.2 on both boxes I used (Ubuntu and Windows).

---

Update: I just tried this on my Windows box, testing by turning the internet off. The first time it made 8 attempts; the second time it made 12. This was while using `--hls-playlist-reload-attempts=1`.

Update2: I actually timed the time it took to finally die and it was 38 seconds. Combined with the dead airtime before the error messages start, that's getting close to 60 seconds. Is `hls-timeout` what I need?

Update3: It looks like the time it takes can be controlled with `hls-timeout`, and these 8-12 attempts I am seeing were actually just a loop of attempts until that time runs out. So it looks like I can prevent _any_ attempts by setting that to a low value, like 15.0. That's better than nothing, although I'd still rather have it pause for a minute, then exit if it gets another 403.

Discussion:

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 13dc2cb5..e6603385 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -32,10 +32,10 @@ jobs:
           - "3.9"
           - "3.10"
           - "3.11"
-#        include:
-#          - python: "3.12-dev"
-#            os: ubuntu-20.04
-#            continue: true
+        include:
+          - python: "3.12-dev"
+            os: ubuntu-latest
+            continue: true
 #          - python: "3.12-dev"
 #            os: windows-latest
 #            continue: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d97f5ba..0f4bf61a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
 # Changelog
 
+## streamlink 5.5.1 (2023-05-08)
+
+Patch release:
+
+- Fixed: shifting time offset when reloading HLS playlists ([#5321](https://github.com/streamlink/streamlink/pull/5321))
+- Fixed: import of `create_urllib3_context` on `urllib3 <2.0.0` ([#5333](https://github.com/streamlink/streamlink/pull/5333))
+- Fixed: Vimeo plugin ([#5331](https://github.com/streamlink/streamlink/pull/5331))
+
+[Full changelog](https://github.com/streamlink/streamlink/compare/5.5.0...5.5.1)
+
+
 ## streamlink 5.5.0 (2023-05-05)
 
 Release highlights:
diff --git a/script/install-dependencies.sh b/script/install-dependencies.sh
index 1373bfca..4ab141c3 100755
--- a/script/install-dependencies.sh
+++ b/script/install-dependencies.sh
@@ -6,6 +6,14 @@ set -ex
 
 python -m pip install --disable-pip-version-check --upgrade pip setuptools
 python -m pip install --upgrade -r dev-requirements.txt
+
+# install a custom-built lxml wheel for the py312 CI runner until an official one is available
+[[ "${GITHUB_ACTIONS}" && "$(uname)" == Linux && "$(python -V)" =~ 3.12. ]] \
+  && python -m pip install --require-hashes -r /dev/stdin <<EOF
+    https://github.com/streamlink/temp-lxml-wheel-DO-NOT-USE/releases/download/lxml-c6b7e62-1/lxml-5.0.0a0-cp312-cp312-linux_x86_64.whl \
+      --hash=sha256:52c42777484b9ab5dbc604583d007048a7d1e7cb0fd101ac063972da1b8e50fb
+EOF
+
 # https://github.com/streamlink/streamlink/issues/4021
 python -m pip install brotli
 python -m pip install -e .
diff --git a/setup.cfg b/setup.cfg
index 37433f1d..17c7d688 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -42,7 +42,7 @@ install_requires =
   certifi
   importlib-metadata ; python_version<"3.8"
   isodate
-  lxml >=4.6.4,<5.0
+  lxml >=4.6.4,<6
   pycountry
   pycryptodome >=3.4.3,<4
   PySocks !=1.5.7,>=1.5.6
diff --git a/src/streamlink/plugin/api/http_session.py b/src/streamlink/plugin/api/http_session.py
index 965e8994..1c9dd1a7 100644
--- a/src/streamlink/plugin/api/http_session.py
+++ b/src/streamlink/plugin/api/http_session.py
@@ -13,6 +13,13 @@ from streamlink.plugin.api import useragents
 from streamlink.utils.parse import parse_json, parse_xml
 
 
+try:
+    from urllib3.util import create_urllib3_context  # type: ignore[attr-defined]
+except ImportError:  # pragma: no cover
+    # urllib3 <2.0.0 compat import
+    from urllib3.util.ssl_ import create_urllib3_context
+
+
 # urllib3>=2.0.0: enforce_content_length now defaults to True (keep the override for backwards compatibility)
 class _HTTPResponse(urllib3.response.HTTPResponse):
     def __init__(self, *args, **kwargs):
@@ -188,7 +195,7 @@ class HTTPSession(Session):
 
 class TLSNoDHAdapter(HTTPAdapter):
     def init_poolmanager(self, *args, **kwargs):
-        ctx = urllib3.util.create_urllib3_context()
+        ctx = create_urllib3_context()
         ctx.load_default_certs()
         ciphers = ":".join(cipher.get("name") for cipher in ctx.get_ciphers())
         ciphers += ":!DH"
@@ -199,7 +206,7 @@ class TLSNoDHAdapter(HTTPAdapter):
 
 class TLSSecLevel1Adapter(HTTPAdapter):
     def init_poolmanager(self, *args, **kwargs):
-        ctx = urllib3.util.create_urllib3_context()
+        ctx = create_urllib3_context()
         ctx.load_default_certs()
         ctx.set_ciphers("DEFAULT:@SECLEVEL=1")
         kwargs["ssl_context"] = ctx
diff --git a/src/streamlink/plugins/vimeo.py b/src/streamlink/plugins/vimeo.py
index 1b0229fb..a04226e4 100644
--- a/src/streamlink/plugins/vimeo.py
+++ b/src/streamlink/plugins/vimeo.py
@@ -7,8 +7,7 @@ $notes Password protected streams are not supported
 
 import logging
 import re
-from html import unescape as html_unescape
-from urllib.parse import urlparse
+from urllib.parse import urljoin, urlparse
 
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
@@ -16,95 +15,187 @@ from streamlink.stream.dash import DASHStream
 from streamlink.stream.ffmpegmux import MuxedStream
 from streamlink.stream.hls import HLSStream
 from streamlink.stream.http import HTTPStream
+from streamlink.utils.url import update_scheme
 
 
 log = logging.getLogger(__name__)
 
 
-@pluginmatcher(re.compile(
-    r"https?://(player\.vimeo\.com/video/\d+|(www\.)?vimeo\.com/.+)",
-))
+@pluginmatcher(
+    name="default",
+    pattern=re.compile(r"https?://(?:www\.)?vimeo\.com/.+"),
+)
+@pluginmatcher(
+    name="player",
+    pattern=re.compile(r"https?://player\.vimeo\.com/video/\d+"),
+)
 class Vimeo(Plugin):
-    _config_url_re = re.compile(r'(?:"config_url"|\bdata-config-url)\s*[:=]\s*(".+?")')
-    _config_re = re.compile(r"playerConfig\s*=\s*({.+?})\s*var")
-    _config_url_schema = validate.Schema(
-        validate.transform(_config_url_re.search),
-        validate.any(
-            None,
-            validate.Schema(
-                validate.get(1),
-                validate.parse_json(),
-                validate.transform(html_unescape),
-                validate.url(),
-            ),
-        ),
-    )
-    _config_schema = validate.Schema(
-        validate.parse_json(),
-        {
-            "request": {
-                "files": {
-                    validate.optional("dash"): {"cdns": {str: {"url": validate.url()}}},
-                    validate.optional("hls"): {"cdns": {str: {"url": validate.url()}}},
-                    validate.optional("progressive"): validate.all(
-                        [{"url": validate.url(), "quality": str}],
+    VIEWER_URL = "https://vimeo.com/_next/viewer"
+    OEMBED_URL = "https://vimeo.com/api/oembed.json"
+
+    @staticmethod
+    def _schema_config(config):
+        schema_cdns = validate.all(
+            {
+                "cdns": {
+                    str: validate.all(
+                        {"url": validate.url()},
+                        validate.get("url"),
                     ),
                 },
-                validate.optional("text_tracks"): validate.all(
-                    [{"url": str, "lang": str}],
+            },
+            validate.get("cdns"),
+        )
+        schema_config = validate.Schema(
+            {
+                "request": {
+                    "files": {
+                        validate.optional("hls"): schema_cdns,
+                        validate.optional("dash"): schema_cdns,
+                        validate.optional("progressive"): [
+                            validate.all(
+                                {
+                                    "url": validate.url(),
+                                    "quality": str,
+                                },
+                                validate.union_get("quality", "url"),
+                            ),
+                        ],
+                    },
+                    validate.optional("text_tracks"): [
+                        validate.all(
+                            {
+                                "url": str,
+                                "lang": str,
+                            },
+                            validate.union_get("lang", "url"),
+                        ),
+                    ],
+                },
+                validate.optional("video"): validate.none_or_all(
+                    {
+                        "id": int,
+                        "title": str,
+                        "owner": {
+                            "name": str,
+                        },
+                    },
+                    validate.union_get(
+                        "id",
+                        ("owner", "name"),
+                        "title",
+                    ),
                 ),
             },
-        },
-    )
-    _player_schema = validate.Schema(
-        validate.transform(_config_re.search),
-        validate.any(None, validate.Schema(validate.get(1), _config_schema)),
-    )
+            validate.union_get(
+                ("request", "files", "hls"),
+                ("request", "files", "dash"),
+                ("request", "files", "progressive"),
+                ("request", "text_tracks"),
+                "video",
+            ),
+        )
+
+        return schema_config.validate(config)
+
+    def _get_dash_url(self, url):
+        return self.session.http.get(url, schema=validate.Schema(
+            validate.parse_json(),
+            {"url": validate.url()},
+            validate.get("url"),
+        ))
+
+    def _query_player(self):
+        return self.session.http.get(self.url, schema=validate.Schema(
+            re.compile(r"playerConfig\s*=\s*({.+?})\s*var"),
+            validate.none_or_all(
+                validate.get(1),
+                validate.parse_json(),
+                validate.transform(self._schema_config),
+            ),
+        ))
+
+    def _query_api(self):
+        jwt, api_url = self.session.http.get(
+            self.VIEWER_URL,
+            schema=validate.Schema(
+                validate.parse_json(),
+                {
+                    "jwt": str,
+                    "apiUrl": str,
+                },
+                validate.union_get("jwt", "apiUrl"),
+            ),
+        )
+        uri = self.session.http.get(
+            self.OEMBED_URL,
+            params={"url": self.url},
+            schema=validate.Schema(
+                validate.parse_json(),
+                {"uri": str},
+                validate.get("uri"),
+            ),
+        )
+        player_config_url = urljoin(update_scheme("https://", api_url), uri)
+        config_url = self.session.http.get(
+            player_config_url,
+            params={"fields": "config_url"},
+            headers={"Authorization": f"jwt {jwt}"},
+            schema=validate.Schema(
+                validate.parse_json(),
+                {"config_url": validate.url()},
+                validate.get("config_url"),
+            ),
+        )
+
+        return self.session.http.get(config_url, schema=validate.Schema(
+            validate.parse_json(),
+            validate.transform(self._schema_config),
+        ))
 
     def _get_streams(self):
-        if "player.vimeo.com" in self.url:
-            data = self.session.http.get(self.url, schema=self._player_schema)
+        if self.matches["player"]:
+            data = self._query_player()
         else:
-            api_url = self.session.http.get(self.url, schema=self._config_url_schema)
-            if not api_url:
-                return
-            data = self.session.http.get(api_url, schema=self._config_schema)
+            data = self._query_api()
+
+        if not data:
+            return
+
+        hls, dash, progressive, text_tracks, metadata = data
+        if metadata:
+            self.id, self.author, self.title = metadata
 
-        videos = data["request"]["files"]
         streams = []
 
-        for stream_type in ("hls", "dash"):
-            if stream_type not in videos:
+        for url in hls.values():
+            streams.extend(HLSStream.parse_variant_playlist(self.session, url).items())
+            break
+
+        for url in dash.values():
+            p = urlparse(url)
+            if p.path.endswith("dash.mpd"):
+                # LIVE
+                url = self._get_dash_url(url)
+            elif p.path.endswith("master.json"):
+                # VOD
+                url = url.replace("master.json", "master.mpd")
+            else:
+                log.error(f"Unsupported DASH path: {p.path}")
                 continue
-            for _, video_data in videos[stream_type]["cdns"].items():
-                log.trace("{0!r}".format(video_data))
-                url = video_data.get("url")
-                if stream_type == "hls":
-                    streams.extend(HLSStream.parse_variant_playlist(self.session, url).items())
-
-                elif stream_type == "dash":
-                    p = urlparse(url)
-                    if p.path.endswith("dash.mpd"):
-                        # LIVE
-                        url = self.session.http.get(url).json()["url"]
-                    elif p.path.endswith("master.json"):
-                        # VOD
-                        url = url.replace("master.json", "master.mpd")
-                    else:
-                        log.error("Unsupported DASH path: {0}".format(p.path))
-                        continue
-
-                    streams.extend(DASHStream.parse_manifest(self.session, url).items())
+
+            streams.extend(DASHStream.parse_manifest(self.session, url).items())
+            break
 
         streams.extend(
-            (stream["quality"], HTTPStream(self.session, stream["url"]))
-            for stream in videos.get("progressive", [])
+            (quality, HTTPStream(self.session, url))
+            for quality, url in progressive or []
         )
 
-        if self.session.get_option("mux-subtitles") and data["request"].get("text_tracks"):
+        if text_tracks and self.session.get_option("mux-subtitles"):
             substreams = {
-                s["lang"]: HTTPStream(self.session, "https://vimeo.com" + s["url"])
-                for s in data["request"]["text_tracks"]
+                lang: HTTPStream(self.session, urljoin("https://vimeo.com/", url))
+                for lang, url in text_tracks
             }
             for quality, stream in streams:
                 yield quality, MuxedStream(self.session, stream, subtitles=substreams)
diff --git a/src/streamlink/plugins/youtube.py b/src/streamlink/plugins/youtube.py
index 73fdf5e8..efc5c63f 100644
--- a/src/streamlink/plugins/youtube.py
+++ b/src/streamlink/plugins/youtube.py
@@ -135,7 +135,7 @@ class YouTube(Plugin):
         schema = validate.Schema(
             {"playabilityStatus": {
                 "status": str,
-                validate.optional("reason"): str,
+                validate.optional("reason"): validate.any(str, None),
             }},
             validate.get("playabilityStatus"),
             validate.union_get("status", "reason"),
@@ -341,7 +341,8 @@ class YouTube(Plugin):
         if not data:
             return False
         status, reason = self._schema_playabilitystatus(data)
-        if status != "OK":
+        # assume that there's an error if reason is set (status will still be "OK" for some reason)
+        if status != "OK" or reason:
             if errorlog:
                 log.error(f"Could not get video info - {status}: {reason}")
             return False
diff --git a/src/streamlink/stream/dash.py b/src/streamlink/stream/dash.py
index ae98faf9..ca6b186f 100644
--- a/src/streamlink/stream/dash.py
+++ b/src/streamlink/stream/dash.py
@@ -211,7 +211,7 @@ class DASHStream(Stream):
         self.mpd = mpd
         self.video_representation = video_representation
         self.audio_representation = audio_representation
-        self.args = args
+        self.args = session.http.valid_request_args(**args)
 
     def __json__(self):
         json = dict(type=self.shortname())
@@ -264,6 +264,8 @@ class DASHStream(Stream):
         session: Streamlink,
         url_or_manifest: str,
         period: int = 0,
+        with_video_only: bool = False,
+        with_audio_only: bool = False,
         **args,
     ) -> Dict[str, "DASHStream"]:
         """
@@ -272,10 +274,12 @@ class DASHStream(Stream):
         :param session: Streamlink session instance
         :param url_or_manifest: URL of the manifest file or an XML manifest string
         :param period: Which MPD period to use (index number) for finding representations
+        :param with_video_only: Also return video-only streams, otherwise only return muxed streams
+        :param with_audio_only: Also return audio-only streams, otherwise only return muxed streams
         :param args: Additional keyword arguments passed to :meth:`requests.Session.request`
         """
 
-        manifest, mpd_params = cls.fetch_manifest(session, url_or_manifest)
+        manifest, mpd_params = cls.fetch_manifest(session, url_or_manifest, **args)
 
         try:
             mpd = cls.parse_mpd(manifest, mpd_params)
@@ -283,8 +287,8 @@ class DASHStream(Stream):
             raise PluginError(f"Failed to parse MPD manifest: {err}") from err
 
         source = mpd_params.get("url", "MPD manifest")
-        video: List[Optional[Representation]] = []
-        audio: List[Optional[Representation]] = []
+        video: List[Optional[Representation]] = [None] if with_audio_only else []
+        audio: List[Optional[Representation]] = [None] if with_video_only else []
 
         # Search for suitable video and audio representations
         for aset in mpd.periods[period].adaptationSets:
@@ -330,6 +334,9 @@ class DASHStream(Stream):
 
         ret = []
         for vid, aud in itertools.product(video, audio):
+            if not vid and not aud:
+                continue
+
             stream = DASHStream(session, mpd, vid, aud, **args)
             stream_name = []
 
diff --git a/src/streamlink/stream/hls.py b/src/streamlink/stream/hls.py
index 0d980e08..3ee13c7c 100644
--- a/src/streamlink/stream/hls.py
+++ b/src/streamlink/stream/hls.py
@@ -292,13 +292,17 @@ class HLSStreamWorker(SegmentedStreamWorker):
     writer: "HLSStreamWriter"
     stream: "HLSStream"
 
+    SEGMENT_QUEUE_TIMING_THRESHOLD_FACTOR = 2
+
     def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
 
         self.playlist_changed = False
         self.playlist_end: Optional[int] = None
+        self.playlist_targetduration: float = 0
         self.playlist_sequence: int = -1
         self.playlist_sequences: List[Sequence] = []
+        self.playlist_sequences_last: datetime = now()
         self.playlist_reload_last: datetime = now()
         self.playlist_reload_time: float = 6
         self.playlist_reload_time_override = self.session.options.get("hls-playlist-reload-time")
@@ -353,6 +357,7 @@ class HLSStreamWorker(SegmentedStreamWorker):
         sequences = [Sequence(media_sequence + i, s)
                      for i, s in enumerate(playlist.segments)]
 
+        self.playlist_targetduration = playlist.targetduration or 0
         self.playlist_reload_time = self._playlist_reload_time(playlist, sequences)
 
         if sequences:
@@ -365,8 +370,8 @@ class HLSStreamWorker(SegmentedStreamWorker):
             return sum(s.segment.duration for s in sequences[-max(1, self.live_edge - 1):])
         if type(self.playlist_reload_time_override) is float and self.playlist_reload_time_override > 0:
             return self.playlist_reload_time_override
-        if playlist.target_duration:
-            return playlist.target_duration
+        if playlist.targetduration:
+            return playlist.targetduration
         if sequences:
             return sum(s.segment.duration for s in sequences[-max(1, self.live_edge - 1):])
 
@@ -398,6 +403,14 @@ class HLSStreamWorker(SegmentedStreamWorker):
     def valid_sequence(self, sequence: Sequence) -> bool:
         return sequence.num >= self.playlist_sequence
 
+    def _segment_queue_timing_threshold_reached(self) -> bool:
+        threshold = self.playlist_targetduration * self.SEGMENT_QUEUE_TIMING_THRESHOLD_FACTOR
+        if now() <= self.playlist_sequences_last + timedelta(seconds=threshold):
+            return False
+
+        log.warning(f"No new segments in playlist for more than {threshold:.2f}s. Stopping...")
+        return True
+
     @staticmethod
     def duration_to_sequence(duration: float, sequences: List[Sequence]) -> int:
         d = 0.0
@@ -415,7 +428,10 @@ class HLSStreamWorker(SegmentedStreamWorker):
         return default
 
     def iter_segments(self):
-        self.playlist_reload_last = now()
+        self.playlist_reload_last \
+            = self.playlist_sequences_last \
+            = now()
+
         try:
             self.reload_playlist()
         except StreamError as err:
@@ -446,9 +462,12 @@ class HLSStreamWorker(SegmentedStreamWorker):
 
         total_duration = 0
         while not self.closed:
+            queued = False
             for sequence in filter(self.valid_sequence, self.playlist_sequences):
                 log.debug(f"Adding segment {sequence.num} to queue")
                 yield sequence
+                queued = True
+
                 total_duration += sequence.segment.duration
                 if self.duration_limit and total_duration >= self.duration_limit:
                     log.info(f"Stopping stream early after {self.duration_limit}")
@@ -461,6 +480,11 @@ class HLSStreamWorker(SegmentedStreamWorker):
 
                 self.playlist_sequence = sequence.num + 1
 
+            if queued:
+                self.playlist_sequences_last = now()
+            elif self._segment_queue_timing_threshold_reached():
+                return
+
             # Exclude playlist fetch+processing time from the overall playlist reload time
             # and reload playlist in a strict time interval
             time_completed = now()
diff --git a/src/streamlink/stream/hls_playlist.py b/src/streamlink/stream/hls_playlist.py
index 84d5aa0c..999910fb 100644
--- a/src/streamlink/stream/hls_playlist.py
+++ b/src/streamlink/stream/hls_playlist.py
@@ -129,7 +129,7 @@ class M3U8:
         self.iframes_only: Optional[bool] = None  # version >= 4
         self.media_sequence: Optional[int] = None
         self.playlist_type: Optional[str] = None
-        self.target_duration: Optional[int] = None
+        self.targetduration: Optional[float] = None
         self.start: Optional[Start] = None
         self.version: Optional[int] = None
 
@@ -423,7 +423,7 @@ class M3U8Parser:
         EXT-X-TARGETDURATION
         https://datatracker.ietf.org/doc/html/rfc8216#section-4.3.3.1
         """
-        self.m3u8.target_duration = int(value)
+        self.m3u8.targetduration = float(value)
 
     def parse_tag_ext_x_media_sequence(self, value: str) -> None:
         """


Test patch:
diff --git a/tests/cli/utils/test_progress.py b/tests/cli/utils/test_progress.py
index 8717c7fb..a65ae0e0 100644
--- a/tests/cli/utils/test_progress.py
+++ b/tests/cli/utils/test_progress.py
@@ -1,5 +1,6 @@
+import sys
 from io import StringIO
-from pathlib import PurePosixPath, PureWindowsPath
+from pathlib import PurePath, PurePosixPath, PureWindowsPath
 from time import time
 from unittest.mock import Mock, call, patch
 
@@ -100,55 +101,89 @@ class TestProgressFormatter:
     def test_format_time(self, elapsed, expected):
         assert ProgressFormatter.format_time(elapsed) == expected
 
-    _path_posix = PurePosixPath("/foobar/baz/some file name")
-    _path_windows_abs = PureWindowsPath("C:\\foobar\\baz\\some file name")
-    _path_windows_rel = PureWindowsPath("foobar\\baz\\some file name")
-    _path_windows_unc = PureWindowsPath("\\\\?\\foobar\\baz\\some file name")
-
-    @pytest.mark.parametrize(("path", "max_width", "expected"), [
-        pytest.param(_path_posix, 26, "/foobar/baz/some file name", id="posix - full path"),
-        pytest.param(_path_posix, 25, "…oobar/baz/some file name", id="posix - truncated by 1"),
-        pytest.param(_path_posix, 24, "…obar/baz/some file name", id="posix - truncated by 2"),
-        pytest.param(_path_posix, 23, "…bar/baz/some file name", id="posix - truncated by 3"),
-        pytest.param(_path_posix, 22, "…ar/baz/some file name", id="posix - truncated by 4"),
-        pytest.param(_path_posix, 21, "…r/baz/some file name", id="posix - truncated by 5"),
-        pytest.param(_path_posix, 20, "…/baz/some file name", id="posix - truncated by 6"),
-        pytest.param(_path_posix, 19, "…baz/some file name", id="posix - truncated by 7 (cuts off separator)"),
-        pytest.param(_path_posix, 16, "…/some file name", id="posix - truncated (all parts except name)"),
-        pytest.param(_path_posix, 15, "…some file name", id="posix - truncated (name without separator)"),
-        pytest.param(_path_posix, 14, "…ome file name", id="posix - truncated name"),
-        pytest.param(_path_windows_abs, 28, "C:\\foobar\\baz\\some file name", id="windows abs - full path"),
-        pytest.param(_path_windows_abs, 27, "C:…oobar\\baz\\some file name", id="windows abs - truncated by 1"),
-        pytest.param(_path_windows_abs, 26, "C:…obar\\baz\\some file name", id="windows abs - truncated by 2"),
-        pytest.param(_path_windows_abs, 25, "C:…bar\\baz\\some file name", id="windows abs - truncated by 3"),
-        pytest.param(_path_windows_abs, 24, "C:…ar\\baz\\some file name", id="windows abs - truncated by 4"),
-        pytest.param(_path_windows_abs, 23, "C:…r\\baz\\some file name", id="windows abs - truncated by 5"),
-        pytest.param(_path_windows_abs, 22, "C:…\\baz\\some file name", id="windows abs - truncated by 6"),
-        pytest.param(_path_windows_abs, 21, "C:…baz\\some file name", id="windows abs - truncated by 7 (cuts off separator)"),
-        pytest.param(_path_windows_abs, 18, "C:…\\some file name", id="windows abs - truncated (all parts except name)"),
-        pytest.param(_path_windows_abs, 17, "C:…some file name", id="windows abs - truncated (name without separator)"),
-        pytest.param(_path_windows_abs, 16, "C:…ome file name", id="windows abs - truncated name"),
-        pytest.param(_path_windows_rel, 25, "foobar\\baz\\some file name", id="windows rel - full path"),
-        pytest.param(_path_windows_rel, 24, "…obar\\baz\\some file name", id="windows rel - truncated by 1"),
-        pytest.param(_path_windows_rel, 23, "…bar\\baz\\some file name", id="windows rel - truncated by 2"),
-        pytest.param(_path_windows_rel, 22, "…ar\\baz\\some file name", id="windows rel - truncated by 3"),
-        pytest.param(_path_windows_rel, 21, "…r\\baz\\some file name", id="windows rel - truncated by 4"),
-        pytest.param(_path_windows_rel, 20, "…\\baz\\some file name", id="windows rel - truncated by 5"),
-        pytest.param(_path_windows_rel, 19, "…baz\\some file name", id="windows rel - truncated by 6 (cuts off separator)"),
-        pytest.param(_path_windows_rel, 16, "…\\some file name", id="windows rel - truncated (all parts except name)"),
-        pytest.param(_path_windows_rel, 15, "…some file name", id="windows rel - truncated (name without separator)"),
-        pytest.param(_path_windows_rel, 14, "…ome file name", id="windows rel - truncated name"),
-        pytest.param(_path_windows_unc, 29, "\\\\?\\foobar\\baz\\some file name", id="windows UNC - full path"),
-        pytest.param(_path_windows_unc, 28, "\\\\?\\…obar\\baz\\some file name", id="windows UNC - truncated by 1"),
-        pytest.param(_path_windows_unc, 20, "\\\\?\\…\\some file name", id="windows UNC - truncated (all parts except name)"),
-        pytest.param(_path_windows_unc, 19, "\\\\?\\…some file name", id="windows UNC - truncated (name without separator)"),
-        pytest.param(_path_windows_unc, 18, "\\\\?\\…ome file name", id="windows UNC - truncated name"),
-    ])
-    def test_format_path(self, path, max_width, expected):
+
+_PATH_POSIX = PurePosixPath("/foobar/baz/some file name")
+_PATH_WIN_ABS = PureWindowsPath("C:\\foobar\\baz\\some file name")
+_PATH_WIN_REL = PureWindowsPath("foobar\\baz\\some file name")
+_PATH_WIN_UNC = PureWindowsPath("\\\\?\\foobar\\baz\\some file name")
+
+
+class _TestFormatPath:
+    # noinspection PyMethodMayBeStatic
+    def test_format_path(self, path: PurePath, max_width: int, expected: str):
         with patch("os.path.sep", "\\" if type(path) is PureWindowsPath else "/"):
             assert ProgressFormatter.format_path(path, max_width) == expected
 
 
+@pytest.mark.parametrize(("path", "max_width", "expected"), [
+    pytest.param(_PATH_POSIX, 26, "/foobar/baz/some file name", id="full path"),
+    pytest.param(_PATH_POSIX, 25, "…oobar/baz/some file name", id="truncated by 1"),
+    pytest.param(_PATH_POSIX, 24, "…obar/baz/some file name", id="truncated by 2"),
+    pytest.param(_PATH_POSIX, 23, "…bar/baz/some file name", id="truncated by 3"),
+    pytest.param(_PATH_POSIX, 22, "…ar/baz/some file name", id="truncated by 4"),
+    pytest.param(_PATH_POSIX, 21, "…r/baz/some file name", id="truncated by 5"),
+    pytest.param(_PATH_POSIX, 20, "…/baz/some file name", id="truncated by 6"),
+    pytest.param(_PATH_POSIX, 19, "…baz/some file name", id="truncated by 7 (cuts off separator)"),
+    pytest.param(_PATH_POSIX, 16, "…/some file name", id="truncated (all parts except name)"),
+    pytest.param(_PATH_POSIX, 15, "…some file name", id="truncated (name without separator)"),
+    pytest.param(_PATH_POSIX, 14, "…ome file name", id="truncated name"),
+])
+class TestFormatPathPOSIX(_TestFormatPath):
+    pass
+
+
+@pytest.mark.parametrize(("path", "max_width", "expected"), [
+    pytest.param(_PATH_WIN_ABS, 28, "C:\\foobar\\baz\\some file name", id="full path"),
+    pytest.param(_PATH_WIN_ABS, 27, "C:…oobar\\baz\\some file name", id="truncated by 1"),
+    pytest.param(_PATH_WIN_ABS, 26, "C:…obar\\baz\\some file name", id="truncated by 2"),
+    pytest.param(_PATH_WIN_ABS, 25, "C:…bar\\baz\\some file name", id="truncated by 3"),
+    pytest.param(_PATH_WIN_ABS, 24, "C:…ar\\baz\\some file name", id="truncated by 4"),
+    pytest.param(_PATH_WIN_ABS, 23, "C:…r\\baz\\some file name", id="truncated by 5"),
+    pytest.param(_PATH_WIN_ABS, 22, "C:…\\baz\\some file name", id="truncated by 6"),
+    pytest.param(_PATH_WIN_ABS, 21, "C:…baz\\some file name", id="truncated by 7 (cuts off separator)"),
+    pytest.param(_PATH_WIN_ABS, 18, "C:…\\some file name", id="truncated (all parts except name)"),
+    pytest.param(_PATH_WIN_ABS, 17, "C:…some file name", id="truncated (name without separator)"),
+    pytest.param(_PATH_WIN_ABS, 16, "C:…ome file name", id="truncated name"),
+])
+class TestFormatPathWindowsAbsolute(_TestFormatPath):
+    pass
+
+
+@pytest.mark.parametrize(("path", "max_width", "expected"), [
+    pytest.param(_PATH_WIN_REL, 25, "foobar\\baz\\some file name", id="full path"),
+    pytest.param(_PATH_WIN_REL, 24, "…obar\\baz\\some file name", id="truncated by 1"),
+    pytest.param(_PATH_WIN_REL, 23, "…bar\\baz\\some file name", id="truncated by 2"),
+    pytest.param(_PATH_WIN_REL, 22, "…ar\\baz\\some file name", id="truncated by 3"),
+    pytest.param(_PATH_WIN_REL, 21, "…r\\baz\\some file name", id="truncated by 4"),
+    pytest.param(_PATH_WIN_REL, 20, "…\\baz\\some file name", id="truncated by 5"),
+    pytest.param(_PATH_WIN_REL, 19, "…baz\\some file name", id="truncated by 6 (cuts off separator)"),
+    pytest.param(_PATH_WIN_REL, 16, "…\\some file name", id="truncated (all parts except name)"),
+    pytest.param(_PATH_WIN_REL, 15, "…some file name", id="truncated (name without separator)"),
+    pytest.param(_PATH_WIN_REL, 14, "…ome file name", id="truncated name"),
+])
+class TestFormatPathWindowsRelative(_TestFormatPath):
+    pass
+
+
+@pytest.mark.parametrize(("path", "max_width", "expected"), [
+    # <py312: server/host name is not part of the path's drive, so it'll get truncated
+    pytest.param(_PATH_WIN_UNC, 29, "\\\\?\\foobar\\baz\\some file name", id="full path"),
+    pytest.param(_PATH_WIN_UNC, 28, "\\\\?\\…obar\\baz\\some file name", id="truncated by 1"),
+    pytest.param(_PATH_WIN_UNC, 20, "\\\\?\\…\\some file name", id="truncated (all parts except name)"),
+    pytest.param(_PATH_WIN_UNC, 19, "\\\\?\\…some file name", id="truncated (name without separator)"),
+    pytest.param(_PATH_WIN_UNC, 18, "\\\\?\\…ome file name", id="truncated name"),
+] if sys.version_info < (3, 12) else [
+    # >=py312: server/host name is part of the path's drive, so it won't get truncated
+    pytest.param(_PATH_WIN_UNC, 29, "\\\\?\\foobar\\baz\\some file name", id="full path"),
+    pytest.param(_PATH_WIN_UNC, 28, "\\\\?\\foobar…az\\some file name", id="truncated by 1"),
+    pytest.param(_PATH_WIN_UNC, 26, "\\\\?\\foobar…\\some file name", id="truncated (all parts except name)"),
+    pytest.param(_PATH_WIN_UNC, 25, "\\\\?\\foobar…some file name", id="truncated (name without separator)"),
+    pytest.param(_PATH_WIN_UNC, 24, "\\\\?\\foobar…ome file name", id="truncated name"),
+])
+class TestFormatPathWindowsUniversalNamingConvention(_TestFormatPath):
+    pass
+
+
 class TestWidth:
     @pytest.mark.parametrize(("chars", "expected"), [
         ("ABCDEFGHIJ", 10),
diff --git a/tests/cli/utils/test_versioncheck.py b/tests/cli/utils/test_versioncheck.py
index 452236c5..d4b065a5 100644
--- a/tests/cli/utils/test_versioncheck.py
+++ b/tests/cli/utils/test_versioncheck.py
@@ -17,12 +17,14 @@ def test_logger_name():
 
 class TestGetLatest:
     @pytest.fixture()
-    def pypi(self, request, requests_mock: rm.Mocker):
-        invalid = requests_mock.register_uri(rm.ANY, rm.ANY, exc=rm.exceptions.InvalidRequest("Invalid request"))
-        response = requests_mock.register_uri("GET", "https://pypi.python.org/pypi/streamlink/json", **(request.param or {}))
+    def pypi(self, request: pytest.FixtureRequest, requests_mock: rm.Mocker):
+        response = requests_mock.register_uri(
+            "GET",
+            "https://pypi.python.org/pypi/streamlink/json",
+            **getattr(request, "param", {}),
+        )
         yield response
-        assert not invalid.called  # type: ignore[attr-defined]
-        assert response.called_once  # type: ignore[attr-defined]
+        assert response.call_count == 1
 
     @pytest.mark.parametrize(("pypi", "error"), [
         (
@@ -61,13 +63,13 @@ class TestVersionCheck:
         return mock_get_latest
 
     @pytest.fixture()
-    def cache(self, request, monkeypatch: pytest.MonkeyPatch):
-        Cache = Mock()
-        cache = Cache("cli.json")
-        cache.get.side_effect = request.param.get
-        monkeypatch.setattr("streamlink_cli.utils.versioncheck.Cache", Cache)
-        yield cache
-        assert cache.called_once
+    def cache(self, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch):
+        mock_cache = Mock()
+        mock_cache.get.side_effect = getattr(request, "param", {}).get
+        MockCache = Mock(return_value=mock_cache)
+        monkeypatch.setattr("streamlink_cli.utils.versioncheck.Cache", MockCache)
+        yield mock_cache
+        assert MockCache.call_args_list == [call(filename="cli.json")]
 
     @pytest.mark.parametrize("cache", [{}], indirect=True)
     def test_auto_uncached_outdated(self, caplog: pytest.LogCaptureFixture, cache: Mock, latest: Mock):
diff --git a/tests/conftest.py b/tests/conftest.py
index 87d0b162..48d438bb 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,6 +4,7 @@ from typing import Dict, List, Tuple
 from unittest.mock import patch
 
 import pytest
+import requests_mock as rm
 
 from streamlink.session import Streamlink
 
@@ -71,3 +72,12 @@ def session(request: pytest.FixtureRequest) -> Streamlink:
         for key, value in getattr(request, "param", {}).items():
             session.set_option(key, value)
         return session
+
+
+@pytest.fixture()
+def requests_mock(requests_mock: rm.Mocker) -> rm.Mocker:  # noqa: PT004
+    """
+    Override of the default `requests_mock` fixture, with `InvalidRequest` raised on unknown requests
+    """
+    requests_mock.register_uri(rm.ANY, rm.ANY, exc=rm.exceptions.InvalidRequest)
+    return requests_mock
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
index 6dd884ea..98be40ac 100644
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
 from unittest.mock import MagicMock, call, patch
 
 import pytest
-import requests_mock
+import requests_mock as rm
 
 from streamlink import Streamlink
 from streamlink.exceptions import NoStreamsError
@@ -98,16 +98,16 @@ class _TwitchHLSStream(TwitchHLSStream):
     __reader__ = _TwitchHLSStreamReader
 
 
-def test_stream_weight():
+def test_stream_weight(requests_mock: rm.Mocker):
     session = Streamlink()
     plugin = Twitch(session, "http://twitch.tv/foo")
 
     with text("hls/test_master_twitch_vod.m3u8") as fh:
         playlist = fh.read()
-    with requests_mock.Mocker() as mocker:
-        mocker.register_uri(requests_mock.ANY, requests_mock.ANY, exc=requests_mock.exceptions.InvalidRequest)
-        mocker.request(method="GET", url="http://mocked/master.m3u8", text=playlist)
-        streams = TwitchHLSStream.parse_variant_playlist(session, "http://mocked/master.m3u8")
+
+    requests_mock.request(method="GET", url="http://mocked/master.m3u8", text=playlist)
+    streams = TwitchHLSStream.parse_variant_playlist(session, "http://mocked/master.m3u8")
+
     with patch.object(plugin, "_get_streams", return_value=streams):
         data = plugin.streams()
 
@@ -397,7 +397,7 @@ class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
 
 class TestTwitchAPIAccessToken:
     @pytest.fixture()
-    def plugin(self, request):
+    def plugin(self, request: pytest.FixtureRequest):
         session = Streamlink()
         for param in getattr(request, "param", {}):
             session.set_plugin_option("twitch", *param)
@@ -405,17 +405,10 @@ class TestTwitchAPIAccessToken:
         Twitch.options.clear()
 
     @pytest.fixture()
-    def mocker(self):
-        # The built-in requests_mock fixture is bad when trying to reference the following constants or classes
-        with requests_mock.Mocker() as mocker:
-            mocker.register_uri(requests_mock.ANY, requests_mock.ANY, exc=requests_mock.exceptions.InvalidRequest)
-            yield mocker
-
-    @pytest.fixture()
-    def mock(self, request, mocker: requests_mock.Mocker):
-        mock = mocker.post("https://gql.twitch.tv/gql", **getattr(request, "param", {"json": {}}))
+    def mock(self, request: pytest.FixtureRequest, requests_mock: rm.Mocker):
+        mock = requests_mock.post("https://gql.twitch.tv/gql", **getattr(request, "param", {"json": {}}))
         yield mock
-        assert mock.called_once
+        assert mock.call_count == 1
         payload = mock.last_request.json()  # type: ignore[union-attr]
         assert tuple(sorted(payload.keys())) == ("extensions", "operationName", "variables")
         assert payload.get("operationName") == "PlaybackAccessToken"
@@ -427,7 +420,7 @@ class TestTwitchAPIAccessToken:
         }
 
     @pytest.fixture()
-    def _assert_live(self, mock):
+    def _assert_live(self, mock: rm.Mocker):
         yield
         assert mock.last_request.json().get("variables") == {  # type: ignore[union-attr]
             "isLive": True,
@@ -438,7 +431,7 @@ class TestTwitchAPIAccessToken:
         }
 
     @pytest.fixture()
-    def _assert_vod(self, mock):
+    def _assert_vod(self, mock: rm.Mocker):
         yield
         assert mock.last_request.json().get("variables") == {  # type: ignore[union-attr]
             "isLive": False,
@@ -485,7 +478,7 @@ class TestTwitchAPIAccessToken:
             },
         ),
     ], indirect=["plugin"])
-    def test_plugin_options(self, plugin: Twitch, mock: requests_mock.Mocker, exp_headers: dict, exp_variables: dict):
+    def test_plugin_options(self, plugin: Twitch, mock: rm.Mocker, exp_headers: dict, exp_variables: dict):
         with pytest.raises(NoStreamsError):
             plugin._access_token(True, "channelname")
         requestheaders = dict(mock.last_request._request.headers)  # type: ignore[union-attr]
@@ -500,7 +493,7 @@ class TestTwitchAPIAccessToken:
     @pytest.mark.parametrize("mock", [{
         "json": {"data": {"streamPlaybackAccessToken": {"value": '{"channel":"foo"}', "signature": "sig"}}},
     }], indirect=True)
-    def test_live_success(self, plugin: Twitch, mock: requests_mock.Mocker):
+    def test_live_success(self, plugin: Twitch, mock: rm.Mocker):
         data = plugin._access_token(True, "channelname")
         assert data == ("sig", '{"channel":"foo"}', [])
 
@@ -508,7 +501,7 @@ class TestTwitchAPIAccessToken:
     @pytest.mark.parametrize("mock", [{
         "json": {"data": {"streamPlaybackAccessToken": None}},
     }], indirect=True)
-    def test_live_failure(self, plugin: Twitch, mock: requests_mock.Mocker):
+    def test_live_failure(self, plugin: Twitch, mock: rm.Mocker):
         with pytest.raises(NoStreamsError):
             plugin._access_token(True, "channelname")
 
@@ -516,7 +509,7 @@ class TestTwitchAPIAccessToken:
     @pytest.mark.parametrize("mock", [{
         "json": {"data": {"videoPlaybackAccessToken": {"value": '{"channel":"foo"}', "signature": "sig"}}},
     }], indirect=True)
-    def test_vod_success(self, plugin: Twitch, mock: requests_mock.Mocker):
+    def test_vod_success(self, plugin: Twitch, mock: rm.Mocker):
         data = plugin._access_token(False, "vodid")
         assert data == ("sig", '{"channel":"foo"}', [])
 
@@ -524,7 +517,7 @@ class TestTwitchAPIAccessToken:
     @pytest.mark.parametrize("mock", [{
         "json": {"data": {"videoPlaybackAccessToken": None}},
     }], indirect=True)
-    def test_vod_failure(self, plugin: Twitch, mock: requests_mock.Mocker):
+    def test_vod_failure(self, plugin: Twitch, mock: rm.Mocker):
         with pytest.raises(NoStreamsError):
             plugin._access_token(False, "vodid")
 
@@ -538,7 +531,7 @@ class TestTwitchAPIAccessToken:
             },
         ),
     ], indirect=True)
-    def test_auth_failure(self, caplog: pytest.LogCaptureFixture, plugin: Twitch, mock: requests_mock.Mocker):
+    def test_auth_failure(self, caplog: pytest.LogCaptureFixture, plugin: Twitch, mock: rm.Mocker):
         with pytest.raises(NoStreamsError):
             plugin._access_token(True, "channelname")
         assert mock.last_request._request.headers["Authorization"] == "OAuth invalid-token"  # type: ignore[union-attr]
@@ -549,8 +542,8 @@ class TestTwitchAPIAccessToken:
 
 class TestTwitchMetadata(unittest.TestCase):
     def setUp(self):
-        self.mock = requests_mock.Mocker()
-        self.mock.register_uri(requests_mock.ANY, requests_mock.ANY, exc=requests_mock.exceptions.InvalidRequest)
+        self.mock = rm.Mocker()
+        self.mock.register_uri(rm.ANY, rm.ANY, exc=rm.exceptions.InvalidRequest)
         self.mock.start()
 
     def tearDown(self):
diff --git a/tests/plugins/test_vimeo.py b/tests/plugins/test_vimeo.py
index e8cbf5df..dcfedeef 100644
--- a/tests/plugins/test_vimeo.py
+++ b/tests/plugins/test_vimeo.py
@@ -6,12 +6,13 @@ class TestPluginCanHandleUrlVimeo(PluginCanHandleUrl):
     __plugin__ = Vimeo
 
     should_match = [
-        "https://vimeo.com/237163735",
-        "https://vimeo.com/channels/music/176894130",
-        "https://vimeo.com/album/3706071/video/148903960",
-        "https://vimeo.com/ondemand/surveyopenspace/92630739",
-        "https://vimeo.com/ondemand/100footsurfingdays",
-        "https://player.vimeo.com/video/176894130",
+        ("default", "https://vimeo.com/783455878"),
+        ("default", "https://vimeo.com/channels/music/176894130"),
+        ("default", "https://vimeo.com/album/3706071/video/148903960"),
+        ("default", "https://vimeo.com/ondemand/worldoftomorrow3/467204924"),
+        ("default", "https://vimeo.com/ondemand/100footsurfingdays"),
+        ("default", "https://vimeo.com/771745400/840d05200c"),
+        ("player", "https://player.vimeo.com/video/176894130"),
     ]
 
     should_not_match = [
diff --git a/tests/plugins/test_vk.py b/tests/plugins/test_vk.py
index b4ad0990..a4ca6309 100644
--- a/tests/plugins/test_vk.py
+++ b/tests/plugins/test_vk.py
@@ -86,7 +86,6 @@ def test_url_redirect(url: str, newurl: str, raises: nullcontext, requests_mock:
     session = Streamlink()
     # noinspection PyTypeChecker
     plugin: VK = VK(session, url)
-    requests_mock.register_uri(rm.ANY, rm.ANY, exc=rm.exceptions.InvalidRequest)
     requests_mock.get(url, text=f"""<!DOCTYPE html><html><head><meta property="og:url" content="{newurl}"/></head></html>""")
     with raises:
         plugin.follow_vk_redirect()
diff --git a/tests/stream/test_dash.py b/tests/stream/test_dash.py
index 0ba92017..9deabeb0 100644
--- a/tests/stream/test_dash.py
+++ b/tests/stream/test_dash.py
@@ -24,11 +24,9 @@ def timestamp():
 class TestDASHStreamParseManifest:
     @pytest.fixture(autouse=True)
     def _response(self, request: pytest.FixtureRequest, requests_mock: rm.Mocker):
-        invalid = requests_mock.register_uri(rm.ANY, rm.ANY, exc=rm.exceptions.InvalidRequest("Invalid request"))
         response = requests_mock.register_uri("GET", "http://test/manifest.mpd", **getattr(request, "param", {}))
         called_once = "nomockedhttprequest" not in request.keywords
         yield
-        assert not invalid.called
         assert response.called_once is called_once
 
     @pytest.fixture()
@@ -83,6 +81,60 @@ class TestDASHStreamParseManifest:
         assert mpd.call_args_list == [call(ANY, url="http://test/manifest.mpd", base_url="http://test")]
         assert sorted(streams.keys()) == sorted(["a128k", "a256k"])
 
+    @pytest.mark.parametrize(("with_video_only", "with_audio_only", "expected"), [
+        pytest.param(
+            False,
+            False,
+            ["720p+a128k", "720p+a256k", "1080p+a128k", "1080p+a256k"],
+            id="Only muxed streams",
+        ),
+        pytest.param(
+            True,
+            False,
+            ["720p", "720p+a128k", "720p+a256k", "1080p", "1080p+a128k", "1080p+a256k"],
+            id="With video-only streams",
+        ),
+        pytest.param(
+            False,
+            True,
+            ["a128k", "a256k", "720p+a128k", "720p+a256k", "1080p+a128k", "1080p+a256k"],
+            id="With audio-only streams",
+        ),
+        pytest.param(
+            True,
+            True,
+            ["a128k", "a256k", "720p", "720p+a128k", "720p+a256k", "1080p", "1080p+a128k", "1080p+a256k"],
+            id="With video-only and audio-only streams",
+        ),
+    ])
+    def test_with_videoaudio_only(
+        self,
+        session: Streamlink,
+        mpd: Mock,
+        with_video_only: bool,
+        with_audio_only: bool,
+        expected: List[str],
+    ):
+        adaptationset = Mock(
+            contentProtections=None,
+            representations=[
+                Mock(id="1", contentProtections=None, mimeType="video/mp4", height=720),
+                Mock(id="2", contentProtections=None, mimeType="video/mp4", height=1080),
+                Mock(id="3", contentProtections=None, mimeType="audio/mp4", bandwidth=128.0, lang="en"),
+                Mock(id="4", contentProtections=None, mimeType="audio/mp4", bandwidth=256.0, lang="en"),
+            ],
+        )
+        mpd.return_value = Mock(periods=[Mock(adaptationSets=[adaptationset])])
+
+        streams = DASHStream.parse_manifest(
+            session,
+            "http://test/manifest.mpd",
+            with_video_only=with_video_only,
+            with_audio_only=with_audio_only,
+        )
+        assert mpd.call_args_list == [call(ANY, url="http://test/manifest.mpd", base_url="http://test")]
+        assert list(streams.keys()) == expected
+
     def test_audio_single(self, session: Streamlink, mpd: Mock):
         adaptationset = Mock(
             contentProtections=None,
@@ -279,23 +331,25 @@ class TestDASHStreamOpen:
         stream.open()
 
         assert reader.call_args_list == [call(stream, rep_video, timestamp)]
-        reader_video = reader(stream, rep_video, timestamp)
-        assert reader_video.open.called_once
+        assert reader().open.call_count == 1
         assert muxer.call_args_list == []
 
     def test_stream_open_video_audio(self, session: Streamlink, timestamp: datetime, muxer: Mock, reader: Mock):
         rep_video = Mock(ident=(None, None, "1"), mimeType="video/mp4")
         rep_audio = Mock(ident=(None, None, "2"), mimeType="audio/mp3", lang="en")
+
+        mock_reader_video = Mock()
+        mock_reader_audio = Mock()
+        readers = {rep_video: mock_reader_video, rep_audio: mock_reader_audio}
+        reader.side_effect = lambda _stream, _representation, _timestamp: readers[_representation]
+
         stream = DASHStream(session, Mock(), rep_video, rep_audio)
         stream.open()
 
         assert reader.call_args_list == [call(stream, rep_video, timestamp), call(stream, rep_audio, timestamp)]
-        reader_video = reader(stream, rep_video, timestamp)
-        reader_audio = reader(stream, rep_audio, timestamp)
-        assert reader_video.open.called_once
-        assert reader_audio.open.called_once
-        assert muxer.call_args_list == [call(session, reader_video, reader_audio, copyts=True)]
-        assert reader_video.timestamp is reader_audio.timestamp
+        assert mock_reader_video.open.call_count == 1
+        assert mock_reader_audio.open.call_count == 1
+        assert muxer.call_args_list == [call(session, mock_reader_video, mock_reader_audio, copyts=True)]
 
 
 class TestDASHStreamWorker:
diff --git a/tests/stream/test_ffmpegmux.py b/tests/stream/test_ffmpegmux.py
index 8daa2c63..06c8a6aa 100644
--- a/tests/stream/test_ffmpegmux.py
+++ b/tests/stream/test_ffmpegmux.py
@@ -425,7 +425,7 @@ class TestOpen:
             assert popen.call_args_list[0][1]["stderr"] is mock_stderr
 
             streamio.close()
-            assert not mock_stderr.close.called
+            assert mock_stderr.close.call_count == 0
 
     @pytest.mark.parametrize(("options", "side_effect"), [
         pytest.param({"ffmpeg-verbose-path": "foo"}, None, id="verbose-path"),
@@ -441,7 +441,7 @@ class TestOpen:
 
             streamio.open()
             assert popen.call_args_list[0][1]["stderr"] is file
-            assert not file.close.called
+            assert file.close.call_count == 0
 
             streamio.close()
-            assert file.close.called_once
+            assert file.close.call_count == 1
diff --git a/tests/stream/test_hls.py b/tests/stream/test_hls.py
index 97fb06a6..8cc379c9 100644
--- a/tests/stream/test_hls.py
+++ b/tests/stream/test_hls.py
@@ -202,6 +202,77 @@ class TestHLSStreamWorker(TestMixinStreamHLS, unittest.TestCase):
     def get_session(self, options=None, *args, **kwargs):
         return super().get_session({**self.OPTIONS, **(options or {})}, *args, **kwargs)
 
+    def test_segment_queue_timing_threshold_reached(self) -> None:
+        thread, segments = self.subject(
+            start=False,
+            playlists=[
+                Playlist(0, targetduration=5, segments=[Segment(0)]),
+                # no EXT-X-ENDLIST, last mocked playlist response will be repreated forever
+                Playlist(0, targetduration=5, segments=[Segment(0), Segment(1)]),
+            ],
+        )
+        worker: EventedHLSStreamWorker = thread.reader.worker
+        targetduration = ONE_SECOND * 5
+
+        with freezegun.freeze_time(EPOCH) as frozen_time, \
+             patch("streamlink.stream.hls.log") as mock_log:
+            self.start()
+
+            assert worker.handshake_reload.wait_ready(1), "Loads playlist for the first time"
+            assert worker.playlist_sequence == -1, "Initial sequence number"
+            assert worker.playlist_sequences_last == EPOCH, "Sets the initial last queue time"
+
+            # first playlist reload has taken one second
+            frozen_time.tick(ONE_SECOND)
+            self.await_playlist_reload(1)
+
+            assert worker.handshake_wait.wait_ready(1), "Arrives at first wait() call"
+            assert worker.playlist_sequence == 1, "Updates the sequence number"
+            assert worker.playlist_sequences_last == EPOCH + ONE_SECOND, "Updates the last queue time"
+            assert worker.playlist_targetduration == 5.0
+
+            # trigger next reload when the target duration has passed
+            frozen_time.tick(targetduration)
+            self.await_playlist_wait(1)
+            self.await_playlist_reload(1)
+
+            assert worker.handshake_wait.wait_ready(1), "Arrives at second wait() call"
+            assert worker.playlist_sequence == 2, "Updates the sequence number again"
+            assert worker.playlist_sequences_last == EPOCH + ONE_SECOND + targetduration, "Updates the last queue time again"
+            assert worker.playlist_targetduration == 5.0
+
+            # trigger next reload when the target duration has passed
+            frozen_time.tick(targetduration)
+            self.await_playlist_wait(1)
+            self.await_playlist_reload(1)
+
+            assert worker.handshake_wait.wait_ready(1), "Arrives at third wait() call"
+            assert worker.playlist_sequence == 2, "Sequence number is unchanged"
+            assert worker.playlist_sequences_last == EPOCH + ONE_SECOND + targetduration, "Last queue time is unchanged"
+            assert worker.playlist_targetduration == 5.0
+
+            # trigger next reload when the target duration has passed
+            frozen_time.tick(targetduration)
+            self.await_playlist_wait(1)
+            self.await_playlist_reload(1)
+
+            assert worker.handshake_wait.wait_ready(1), "Arrives at fourth wait() call"
+            assert worker.playlist_sequence == 2, "Sequence number is unchanged"
+            assert worker.playlist_sequences_last == EPOCH + ONE_SECOND + targetduration, "Last queue time is unchanged"
+            assert worker.playlist_targetduration == 5.0
+
+            assert mock_log.warning.call_args_list == []
+
+            # trigger next reload when the target duration has passed
+            frozen_time.tick(targetduration)
+            self.await_playlist_wait(1)
+            self.await_playlist_reload(1)
+
+            self.await_read(read_all=True)
+            self.await_close(1)
+
+            assert mock_log.warning.call_args_list == [call("No new segments in playlist for more than 10.00s. Stopping...")]
+
     def test_playlist_reload_offset(self) -> None:
         thread, segments = self.subject(
             start=False,
@@ -689,6 +760,7 @@ class TestHlsPlaylistReloadTime(TestMixinStreamHLS, unittest.TestCase):
 
 @patch("streamlink.stream.hls.log")
 @patch("streamlink.stream.hls.HLSStreamWorker.wait", Mock(return_value=True))
+@patch("streamlink.stream.hls.HLSStreamWorker._segment_queue_timing_threshold_reached", Mock(return_value=False))
 class TestHlsPlaylistParseErrors(TestMixinStreamHLS, unittest.TestCase):
     __stream__ = EventedWriterHLSStream
 
diff --git a/tests/stream/test_hls_playlist.py b/tests/stream/test_hls_playlist.py
index 676f9005..f33b51cd 100644
--- a/tests/stream/test_hls_playlist.py
+++ b/tests/stream/test_hls_playlist.py
@@ -381,7 +381,7 @@ class TestHLSPlaylist(unittest.TestCase):
         delta_30 = timedelta(seconds=30, milliseconds=500)
         delta_60 = timedelta(seconds=60)
 
-        assert playlist.target_duration == 120
+        assert playlist.targetduration == 120
 
         assert list(playlist.dateranges) == [
             DateRange(