Repository: streamlink/streamlink

Issue:
plugins.dash: partial URL gets matched
### 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

Hello.
First of all, I must say that I'm not a programmer, I'm, at most, tech savy, so forgive if I'm making something stupid or not seing the obvious. I also tried to google the issue, but I got no results.
Recently, my provider changed the url scheme and I'm unable to get the manifests/streams using Streamlink (I would like to note that there's no DRM involved whatsoever).

Previously, my provider had links with the following scheme, that worked great:
`https://hostname/sdash/channel,token=XXXXXXXXXXXXXXXX/index.mpd?device=PC`
But now they have the following scheme:
`https://hostname/sdash/channel/index.mpd,token=XXXXXXXXXXXXXXXX/Manifest?device=PC`

This seems to be some regex problem, because Streamlink gets the link up to .mpd and seems to discard the rest of the link (it gets the "https://hostname/sdash/channel/index.mpd", but discards the ",token=XXXXXXXXXXXXXXXX/Manifest?device=PC"), making it unable to get the manifest/stream.
If I open the same https://hostname/sdash/channel/index.mpd (without the rest of the link) in the browser, I get the same error code 400. Also, I piped the same links (https://hostname/sdash/channel/index.mpd,token=XXXXXXXXXXXXXXXX/Manifest?device=PC), with the same data, through ffmpeg and it's working.
I read the cli documentation and I did't see anything that could help me to solve this, so I'm reporting this possible issue.

Lastly, I would like to thank to all Streamlink contributers, because it's an amazing piece of software.

PS: I had to redact most of the link, because it contains personaly identifiable data. The streams are also geoblocked.

### Debug log

```text
[cli][debug] OS:         Linux-6.3.3-artix1-1-x86_64-with-glibc2.37
[cli][debug] Python:     3.11.3
[cli][debug] Streamlink: 5.5.1
[cli][debug] Dependencies:
[cli][debug]  certifi: 2023.5.7
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.2
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.18.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.28.2
[cli][debug]  urllib3: 1.26.15
[cli][debug]  websocket-client: 1.5.2
[cli][debug] Arguments:
[cli][debug]  url=https://hostname/sdash/channel/index.mpd,token=XXXXXXXXXXXXXXXX/Manifest?device=PC
[cli][debug]  --loglevel=debug
[cli][debug]  --logfile=/home/PC/log.log
[cli][debug]  --player=mpv
[cli][debug]  --http-header=[('User-Agent', 'PC')]
[cli][info] Found matching plugin dash for URL https://hostname/sdash/channel/index.mpd,token=XXXXXXXXXXXXXXXX/Manifest?device=PC
[plugins.dash][debug] URL=https://hostname/sdash/channel/index.mpd; params={}
error: Unable to open URL: https://hostname/sdash/channel/index.mpd (400 Client Error: Bad Request for url: https://hostname/sdash/channel/index.mpd)
```



Discussion:
> This seems to be some regex problem

No, you're just not using your command line shell correctly. It interprets `?` as a special character.
https://streamlink.github.io/cli/tutorial.html#shell-syntax
> > This seems to be some regex problem
> 
> No, you're just not using your command line shell correctly. It interprets `?` as a special character. https://streamlink.github.io/cli/tutorial.html#shell-syntax

I always escape the links using single quotes.
When my provider used the previous scheme, it worked:
https://hostname/sdash/channel,token=XXXXXXXXXXXXXXXX/index.mpd?device=PC
The debug log shows the exact arguments it has received. If you get a 4xx or 5xx HTTP response, then you're missing headers or query parameters, or your access is restricted otherwise.

It's also possible that you need to set the explicit `dash://` prefix to your input URL because of how the DASH plugin receives its (implicit / low-priority) input URL.
https://github.com/streamlink/streamlink/blob/77519b12cffe5142f7b4c9d0f901ce4fac52ea0a/src/streamlink/plugins/dash.py#L13-L18

```sh
streamlink 'dash://https://...' best
```

https://streamlink.github.io/latest/cli/protocols.html#streaming-protocols

Resolving patch:
diff --git a/src/streamlink/plugins/dash.py b/src/streamlink/plugins/dash.py
--- a/src/streamlink/plugins/dash.py
+++ b/src/streamlink/plugins/dash.py
@@ -11,10 +11,10 @@
 
 
 @pluginmatcher(re.compile(
-    r"dash://(?P<url>\S+)(?:\s(?P<params>.+))?",
+    r"dash://(?P<url>\S+)(?:\s(?P<params>.+))?$",
 ))
 @pluginmatcher(priority=LOW_PRIORITY, pattern=re.compile(
-    r"(?P<url>\S+\.mpd(?:\?\S*)?)(?:\s(?P<params>.+))?",
+    r"(?P<url>\S+\.mpd(?:\?\S*)?)(?:\s(?P<params>.+))?$",
 ))
 class MPEGDASH(Plugin):
     @classmethod
diff --git a/src/streamlink/plugins/hls.py b/src/streamlink/plugins/hls.py
--- a/src/streamlink/plugins/hls.py
+++ b/src/streamlink/plugins/hls.py
@@ -11,10 +11,10 @@
 
 
 @pluginmatcher(re.compile(
-    r"hls(?:variant)?://(?P<url>\S+)(?:\s(?P<params>.+))?",
+    r"hls(?:variant)?://(?P<url>\S+)(?:\s(?P<params>.+))?$",
 ))
 @pluginmatcher(priority=LOW_PRIORITY, pattern=re.compile(
-    r"(?P<url>\S+\.m3u8(?:\?\S*)?)(?:\s(?P<params>.+))?",
+    r"(?P<url>\S+\.m3u8(?:\?\S*)?)(?:\s(?P<params>.+))?$",
 ))
 class HLSPlugin(Plugin):
     def _get_streams(self):
diff --git a/src/streamlink/plugins/http.py b/src/streamlink/plugins/http.py
--- a/src/streamlink/plugins/http.py
+++ b/src/streamlink/plugins/http.py
@@ -11,7 +11,7 @@
 
 
 @pluginmatcher(re.compile(
-    r"httpstream://(?P<url>\S+)(?:\s(?P<params>.+))?",
+    r"httpstream://(?P<url>\S+)(?:\s(?P<params>.+))?$",
 ))
 class HTTPStreamPlugin(Plugin):
     def _get_streams(self):


Test patch:
diff --git a/tests/plugins/test_dash.py b/tests/plugins/test_dash.py
--- a/tests/plugins/test_dash.py
+++ b/tests/plugins/test_dash.py
@@ -11,13 +11,34 @@
 class TestPluginCanHandleUrlMPEGDASH(PluginCanHandleUrl):
     __plugin__ = MPEGDASH
 
-    should_match = [
-        "example.com/foo.mpd",
-        "http://example.com/foo.mpd",
-        "https://example.com/foo.mpd",
-        "dash://example.com/foo",
-        "dash://http://example.com/foo",
-        "dash://https://example.com/foo",
+    should_match_groups = [
+        # implicit DASH URLs
+        ("example.com/foo.mpd", {"url": "example.com/foo.mpd"}),
+        ("example.com/foo.mpd?bar", {"url": "example.com/foo.mpd?bar"}),
+        ("http://example.com/foo.mpd", {"url": "http://example.com/foo.mpd"}),
+        ("http://example.com/foo.mpd?bar", {"url": "http://example.com/foo.mpd?bar"}),
+        ("https://example.com/foo.mpd", {"url": "https://example.com/foo.mpd"}),
+        ("https://example.com/foo.mpd?bar", {"url": "https://example.com/foo.mpd?bar"}),
+        # explicit DASH URLs with protocol prefix
+        ("dash://example.com/foo?bar", {"url": "example.com/foo?bar"}),
+        ("dash://http://example.com/foo?bar", {"url": "http://example.com/foo?bar"}),
+        ("dash://https://example.com/foo?bar", {"url": "https://example.com/foo?bar"}),
+        # optional parameters
+        ("example.com/foo.mpd?bar abc=def", {"url": "example.com/foo.mpd?bar", "params": "abc=def"}),
+        ("http://example.com/foo.mpd?bar abc=def", {"url": "http://example.com/foo.mpd?bar", "params": "abc=def"}),
+        ("https://example.com/foo.mpd?bar abc=def", {"url": "https://example.com/foo.mpd?bar", "params": "abc=def"}),
+        ("dash://https://example.com/foo?bar abc=def", {"url": "https://example.com/foo?bar", "params": "abc=def"}),
+    ]
+
+    should_not_match = [
+        # implicit DASH URLs must have their path end with ".mpd"
+        "example.com/mpd",
+        "example.com/mpd abc=def",
+        "example.com/foo.mpd,bar",
+        "example.com/foo.mpd,bar abc=def",
+        # missing parameters
+        "example.com/foo.mpd ",
+        "dash://example.com/foo ",
     ]
 
 
diff --git a/tests/plugins/test_hls.py b/tests/plugins/test_hls.py
--- a/tests/plugins/test_hls.py
+++ b/tests/plugins/test_hls.py
@@ -12,16 +12,39 @@
 class TestPluginCanHandleUrlHLSPlugin(PluginCanHandleUrl):
     __plugin__ = HLSPlugin
 
-    should_match = [
-        "example.com/foo.m3u8",
-        "http://example.com/foo.m3u8",
-        "https://example.com/foo.m3u8",
-        "hls://example.com/foo",
-        "hls://http://example.com/foo",
-        "hls://https://example.com/foo",
-        "hlsvariant://example.com/foo",
-        "hlsvariant://http://example.com/foo",
-        "hlsvariant://https://example.com/foo",
+    should_match_groups = [
+        # implicit HLS URLs
+        ("example.com/foo.m3u8", {"url": "example.com/foo.m3u8"}),
+        ("example.com/foo.m3u8?bar", {"url": "example.com/foo.m3u8?bar"}),
+        ("http://example.com/foo.m3u8", {"url": "http://example.com/foo.m3u8"}),
+        ("http://example.com/foo.m3u8?bar", {"url": "http://example.com/foo.m3u8?bar"}),
+        ("https://example.com/foo.m3u8", {"url": "https://example.com/foo.m3u8"}),
+        ("https://example.com/foo.m3u8?bar", {"url": "https://example.com/foo.m3u8?bar"}),
+        # explicit HLS URLs with protocol prefix
+        ("hls://example.com/foo?bar", {"url": "example.com/foo?bar"}),
+        ("hls://http://example.com/foo?bar", {"url": "http://example.com/foo?bar"}),
+        ("hls://https://example.com/foo?bar", {"url": "https://example.com/foo?bar"}),
+        ("hlsvariant://example.com/foo?bar", {"url": "example.com/foo?bar"}),
+        ("hlsvariant://http://example.com/foo?bar", {"url": "http://example.com/foo?bar"}),
+        ("hlsvariant://https://example.com/foo?bar", {"url": "https://example.com/foo?bar"}),
+        # optional parameters
+        ("example.com/foo.m3u8?bar abc=def", {"url": "example.com/foo.m3u8?bar", "params": "abc=def"}),
+        ("http://example.com/foo.m3u8?bar abc=def", {"url": "http://example.com/foo.m3u8?bar", "params": "abc=def"}),
+        ("https://example.com/foo.m3u8?bar abc=def", {"url": "https://example.com/foo.m3u8?bar", "params": "abc=def"}),
+        ("hls://https://example.com/foo?bar abc=def", {"url": "https://example.com/foo?bar", "params": "abc=def"}),
+        ("hlsvariant://https://example.com/foo?bar abc=def", {"url": "https://example.com/foo?bar", "params": "abc=def"}),
+    ]
+
+    should_not_match = [
+        # implicit HLS URLs must have their path end with ".m3u8"
+        "example.com/m3u8",
+        "example.com/m3u8 abc=def",
+        "example.com/foo.m3u8,bar",
+        "example.com/foo.m3u8,bar abc=def",
+        # missing parameters
+        "example.com/foo.m3u8 ",
+        "hls://example.com/foo ",
+        "hlsvariant://example.com/foo ",
     ]
 
 
diff --git a/tests/plugins/test_http.py b/tests/plugins/test_http.py
--- a/tests/plugins/test_http.py
+++ b/tests/plugins/test_http.py
@@ -11,10 +11,20 @@
 class TestPluginCanHandleUrlHTTPStreamPlugin(PluginCanHandleUrl):
     __plugin__ = HTTPStreamPlugin
 
-    should_match = [
-        "httpstream://example.com/foo",
-        "httpstream://http://example.com/foo",
-        "httpstream://https://example.com/foo",
+    should_match_groups = [
+        # explicit HTTPStream URLs
+        ("httpstream://example.com/foo", {"url": "example.com/foo"}),
+        ("httpstream://http://example.com/foo", {"url": "http://example.com/foo"}),
+        ("httpstream://https://example.com/foo", {"url": "https://example.com/foo"}),
+        # optional parameters
+        ("httpstream://example.com/foo abc=def", {"url": "example.com/foo", "params": "abc=def"}),
+        ("httpstream://http://example.com/foo abc=def", {"url": "http://example.com/foo", "params": "abc=def"}),
+        ("httpstream://https://example.com/foo abc=def", {"url": "https://example.com/foo", "params": "abc=def"}),
+    ]
+
+    should_not_match = [
+        # missing parameters
+        "httpstream://example.com/foo ",
     ]