Repository: streamlink/streamlink
Pull request #3310 (2020-10-31 09:00:02)

plugins.abweb: fixed login issues
closes https://github.com/streamlink/streamlink/issues/3281

NeySlim: The login issue is indeed fixed.
But iframe_url is not found:

```
[cli][debug] OS:         Linux-5.1.12-mainline-rev1-x86_64-with-Ubuntu-18.04-bionic
[cli][debug] Python:     3.6.9
[cli][debug] Streamlink: 1.7.0
[cli][debug] Requests(2.24.0), Socks(1.7.1), Websocket(0.57.0)
[cli][info] Found matching plugin abweb for URL https://www.abweb.com/BIS-TV-Online/bistvo-tele-universal.aspx?chn=ab1
[cli][debug] Plugin specific arguments:
[cli][debug]  --abweb-username=******** (username)
[cli][debug]  --abweb-password=******** (password)
[plugin.abweb][debug] Attempting to login.
[plugin.abweb][debug] Saved cookies: .abportail1, ASP.NET_SessionId
[plugin.abweb][debug] get iframe_url
error: No iframe_url found.
```
back-to: could you remove your `$HOME/.cache/streamlink/plugin-cache.json` file, might be some old cached cookies

> error: No iframe_url found.

this should be originaly fixed with the changed cookie domain

https://github.com/streamlink/streamlink/blob/ba5aeb0e248e9577530344feb6f391fcfbe35995/src/streamlink/plugins/abweb.py#L78-L79

---

also if it works, please try it with https://streamlink.github.io/cli.html#cmdoption-abweb-purge-credentials
NeySlim: > could you remove your `$HOME/.cache/streamlink/plugin-cache.json` file, might be some old cached cookies
> 
> > error: No iframe_url found.
> 
> this should be originaly fixed with the changed cookie domain
> 
> https://github.com/streamlink/streamlink/blob/ba5aeb0e248e9577530344feb6f391fcfbe35995/src/streamlink/plugins/abweb.py#L78-L79
> 
> also if it works, please try it with https://streamlink.github.io/cli.html#cmdoption-abweb-purge-credentials

You're right, it works by removing cache, or adding cli purge option.
Thank you.
back-to: @NeySlim 

could you try that it will work with `Attempting to login.` and `Attempting to authenticate using cached cookies` for you

basically **one is the first login** and **2nd are all other logins** at the same day

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 46228f5b..815052f8 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -83,7 +83,7 @@ jobs:
       - name: Install dependencies
         run: |
           ./script/install-dependencies.sh
-          python -m pip install pynsist
+          python -m pip install git+https://github.com/takluyver/pynsist.git@ba9d161370c1b04dec82f72d30a28851c5eef884
           sudo apt update
           sudo apt install -y nsis imagemagick inkscape
       - name: Installer file name
diff --git a/script/makeinstaller.sh b/script/makeinstaller.sh
index d17afd37..6fd15a8f 100755
--- a/script/makeinstaller.sh
+++ b/script/makeinstaller.sh
@@ -33,7 +33,7 @@ cd "${ROOT}"
 STREAMLINK_VERSION=$(python setup.py --version)
 STREAMLINK_VERSION_PLAIN="${STREAMLINK_VERSION%%+*}"
 STREAMLINK_INSTALLER="${1:-"streamlink-${STREAMLINK_VERSION/\+/_}"}"
-STREAMLINK_PYTHON_VERSION=3.6.6
+STREAMLINK_PYTHON_VERSION=3.9.0
 STREAMLINK_ASSETS_REPO="${STREAMLINK_ASSETS_REPO:-streamlink/streamlink-assets}"
 STREAMLINK_ASSETS_RELEASE="${STREAMLINK_ASSETS_RELEASE:-latest}"
 
@@ -118,7 +118,7 @@ packages=pkg_resources
          socks
          sockshandler
          isodate
-pypi_wheels=pycryptodome==3.6.4
+pypi_wheels=pycryptodome==3.9.9
 
 files=${ROOT}/win32/THIRD-PARTY.txt > \$INSTDIR
       ${ROOT}/build/lib/streamlink > \$INSTDIR\pkgs
diff --git a/src/streamlink/plugin/api/useragents.py b/src/streamlink/plugin/api/useragents.py
index ddfee8b5..a598ffce 100644
--- a/src/streamlink/plugin/api/useragents.py
+++ b/src/streamlink/plugin/api/useragents.py
@@ -1,10 +1,10 @@
 ANDROID = ("Mozilla/5.0 (Linux; Android 7.1.1; SM-J510FN Build/NMF26X) "
            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Mobile Safari/537.36")
-CHROME = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36"
+CHROME = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36"
 CHROME_OS = ("Mozilla/5.0 (X11; CrOS armv7l 10718.34.0) "
              "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.40 Safari/537.36")
 EDGE = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36 Edg/80.0.361.111"
-FIREFOX = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:81.0) Gecko/20100101 Firefox/81.0"
+FIREFOX = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:82.0) Gecko/20100101 Firefox/82.0"
 FIREFOX_MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:81.0) Gecko/20100101 Firefox/81.0"
 IE_6 = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; WOW64; Trident/4.0; SLCC1)"
 IE_7 = "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; WOW64; Trident/4.0; SLCC1)"
diff --git a/src/streamlink/plugins/abweb.py b/src/streamlink/plugins/abweb.py
index 8e25da24..5cb52b78 100644
--- a/src/streamlink/plugins/abweb.py
+++ b/src/streamlink/plugins/abweb.py
@@ -1,39 +1,27 @@
 import logging
 import re
-import time
 
-from streamlink.cache import Cache
 from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin, PluginArgument, PluginArguments
-from streamlink.plugin.api import useragents
+from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream
 from streamlink.utils import update_scheme
+from streamlink.utils.url import url_concat
 
 log = logging.getLogger(__name__)
 
 
 class ABweb(Plugin):
-    '''BIS Livestreams of french AB Groupe
-       http://www.abweb.com/BIS-TV-Online/
-    '''
-
-    login_url = 'http://www.abweb.com/BIS-TV-Online/Default.aspx'
+    url_l = 'https://www.abweb.com/BIS-TV-Online/identification.aspx?ReturnUrl=%2fBIS-TV-Online%2fbistvo-tele-universal.aspx'
 
     _url_re = re.compile(r'https?://(?:www\.)?abweb\.com/BIS-TV-Online/bistvo-tele-universal.aspx', re.IGNORECASE)
     _hls_re = re.compile(r'''["']file["']:\s?["'](?P<url>[^"']+\.m3u8[^"']+)["']''')
-    _iframe_re = re.compile(r'''<iframe[^>]+src=["'](?P<url>[^"']+)["']''')
-
-    _input_re = re.compile(r'''(<input[^>]+>)''')
-    _name_re = re.compile(r'''name=["']([^"']*)["']''')
-    _value_re = re.compile(r'''value=["']([^"']*)["']''')
-
-    expires_time = 3600 * 24
 
     arguments = PluginArguments(
         PluginArgument(
             "username",
-            required=True,
             requires=["password"],
+            sensitive=True,
             metavar="USERNAME",
             help="""
             The username associated with your ABweb account, required to access any
@@ -59,78 +47,38 @@ class ABweb(Plugin):
 
     def __init__(self, url):
         super().__init__(url)
-        self._session_attributes = Cache(filename='plugin-cache.json', key_prefix='abweb:attributes')
-        self._authed = self._session_attributes.get('ASP.NET_SessionId') and self._session_attributes.get('.abportail1')
-        self._expires = self._session_attributes.get('expires', time.time() + self.expires_time)
+        self._authed = (self.session.http.cookies.get('ASP.NET_SessionId', domain='.abweb.com')
+                        and self.session.http.cookies.get('.abportail1', domain='.abweb.com'))
 
     @classmethod
     def can_handle_url(cls, url):
         return cls._url_re.match(url) is not None
 
-    def set_expires_time_cache(self):
-        expires = time.time() + self.expires_time
-        self._session_attributes.set('expires', expires, expires=self.expires_time)
-
-    def get_iframe_url(self):
-        log.debug('search for an iframe')
-        res = self.session.http.get(self.url)
-        m = self._iframe_re.search(res.text)
-        if not m:
-            raise PluginError('No iframe found.')
-
-        iframe_url = m.group('url')
-        iframe_url = update_scheme('http://', iframe_url)
-        log.debug('IFRAME URL={0}'.format(iframe_url))
-        return iframe_url
+    def _login(self, username, password):
+        log.debug('Attempting to login.')
 
-    def get_hls_url(self, iframe_url):
-        log.debug('search for hls url')
-        res = self.session.http.get(iframe_url)
-        m = self._hls_re.search(res.text)
-        if not m:
-            raise PluginError('No playlist found.')
+        data = {}
+        for i in itertags(self.session.http.get(self.url_l).text, 'input'):
+            data[i.attributes.get('name')] = i.attributes.get('value', '')
 
-        return m and m.group('url')
+        if not data:
+            raise PluginError('Missing input data on login website.')
 
-    def _login(self, username, password):
-        '''login and update cached cookies'''
-        log.debug('login ...')
+        data.update({
+            'ctl00$ContentPlaceHolder1$Login1$UserName': username,
+            'ctl00$ContentPlaceHolder1$Login1$Password': password,
+            'ctl00$ContentPlaceHolder1$Login1$LoginButton.x': '0',
+            'ctl00$ContentPlaceHolder1$Login1$LoginButton.y': '0',
+            'ctl00$ContentPlaceHolder1$Login1$RememberMe': 'on',
+        })
 
-        res = self.session.http.get(self.login_url)
-        input_list = self._input_re.findall(res.text)
-        if not input_list:
-            raise PluginError('Missing input data on login website.')
+        self.session.http.post(self.url_l, data=data)
+        if (self.session.http.cookies.get('ASP.NET_SessionId') and self.session.http.cookies.get('.abportail1')):
+            for cookie in self.session.http.cookies:
+                # remove www from cookie domain
+                cookie.domain = '.abweb.com'
 
-        data = {}
-        for _input_data in input_list:
-            try:
-                _input_name = self._name_re.search(_input_data).group(1)
-            except AttributeError:
-                continue
-
-            try:
-                _input_value = self._value_re.search(_input_data).group(1)
-            except AttributeError:
-                _input_value = ''
-
-            data[_input_name] = _input_value
-
-        login_data = {
-            'ctl00$Login1$UserName': username,
-            'ctl00$Login1$Password': password,
-            'ctl00$Login1$LoginButton.x': '0',
-            'ctl00$Login1$LoginButton.y': '0'
-        }
-        data.update(login_data)
-
-        res = self.session.http.post(self.login_url, data=data)
-
-        for cookie in self.session.http.cookies:
-            self._session_attributes.set(cookie.name, cookie.value, expires=3600 * 24)
-
-        if self._session_attributes.get('ASP.NET_SessionId') and self._session_attributes.get('.abportail1'):
-            log.debug('New session data')
-            self.set_expires_time_cache()
+            self.save_cookies(default_expires=3600 * 24)
             return True
         else:
             log.error('Failed to login, check your username/password')
@@ -138,7 +86,6 @@ class ABweb(Plugin):
 
     def _get_streams(self):
         self.session.http.headers.update({
-            'User-Agent': useragents.CHROME,
             'Referer': 'http://www.abweb.com/BIS-TV-Online/bistvo-tele-universal.aspx'
         })
 
@@ -146,39 +93,41 @@ class ABweb(Plugin):
         login_password = self.get_option('password')
 
         if self.options.get('purge_credentials'):
-            self._session_attributes.set('ASP.NET_SessionId', None, expires=0)
-            self._session_attributes.set('.abportail1', None, expires=0)
+            self.clear_cookies()
             self._authed = False
             log.info('All credentials were successfully removed.')
 
-        if not self._authed and not (login_username and login_password):
+        if self._authed:
+            log.info('Attempting to authenticate using cached cookies')
+        elif not self._authed and not (login_username and login_password):
             log.error('A login for ABweb is required, use --abweb-username USERNAME --abweb-password PASSWORD')
             return
+        elif not self._authed and not self._login(login_username, login_password):
+            return
 
-        if self._authed:
-            if self._expires < time.time():
-                log.debug('get new cached cookies')
-                # login after 24h
-                self.set_expires_time_cache()
-                self._authed = False
+        log.debug('get iframe_url')
+        res = self.session.http.get(self.url)
+        for iframe in itertags(res.text, 'iframe'):
+            iframe_url = iframe.attributes.get('src')
+            if iframe_url.startswith('/'):
+                iframe_url = url_concat('https://www.abweb.com', iframe_url)
             else:
-                log.info('Attempting to authenticate using cached cookies')
-                self.session.http.cookies.set('ASP.NET_SessionId', self._session_attributes.get('ASP.NET_SessionId'))
-                self.session.http.cookies.set('.abportail1', self._session_attributes.get('.abportail1'))
-
-        if not self._authed and not self._login(login_username, login_password):
-            return
+                iframe_url = update_scheme('https://', iframe_url)
+            log.debug(f'iframe_url={iframe_url}')
+            break
+        else:
+            raise PluginError('No iframe_url found.')
 
-        iframe_url = self.get_iframe_url()
         self.session.http.headers.update({'Referer': iframe_url})
+        res = self.session.http.get(iframe_url)
+        m = self._hls_re.search(res.text)
+        if not m:
+            raise PluginError('No hls_url found.')
 
-        hls_url = self.get_hls_url(iframe_url)
-        hls_url = update_scheme(self.url, hls_url)
-
-        log.debug('URL={0}'.format(hls_url))
-        variant = HLSStream.parse_variant_playlist(self.session, hls_url)
-        if variant:
-            yield from variant.items()
+        hls_url = update_scheme('https://', m.group('url'))
+        streams = HLSStream.parse_variant_playlist(self.session, hls_url)
+        if streams:
+            yield from streams.items()
         else:
             yield 'live', HLSStream(self.session, hls_url)
 
diff --git a/src/streamlink/plugins/bfmtv.py b/src/streamlink/plugins/bfmtv.py
index a62b2ac8..877ea57e 100644
--- a/src/streamlink/plugins/bfmtv.py
+++ b/src/streamlink/plugins/bfmtv.py
@@ -1,45 +1,46 @@
+import logging
 import re
 
 from streamlink.plugin import Plugin
 from streamlink.plugins.brightcove import BrightcovePlayer
-from streamlink.stream import HLSStream
+
+log = logging.getLogger(__name__)
 
 
 class BFMTV(Plugin):
     _url_re = re.compile(r'https://.+\.(?:bfmtv|01net)\.com')
+    _dailymotion_url = 'https://www.dailymotion.com/embed/video/{}'
     _brightcove_video_re = re.compile(
-        r'data-holder="video(?P<video_id>[0-9]+)" data-account="(?P<account_id>[0-9]+)"'
-    )
-    _brightcove_video_alt_re = re.compile(
-        r'data-account="(?P<account_id>[0-9]+).*?data-video-id="(?P<video_id>[0-9]+)"',
+        r'accountid="(?P<account_id>[0-9]+).*?videoid="(?P<video_id>[0-9]+)"',
         re.DOTALL
     )
-    _embed_video_url_re = re.compile(
-        r"\$YOPLAYER\('liveStitching', {.+?file: '(?P<video_url>[^\"]+?)'.+?}\);",
+    _embed_video_id_re = re.compile(
+        r'<iframe.*?src=".*?/(?P<video_id>\w+)"',
         re.DOTALL
     )
 
     @classmethod
     def can_handle_url(cls, url):
-        return BFMTV._url_re.match(url)
+        return cls._url_re.match(url) is not None
 
     def _get_streams(self):
         # Retrieve URL page and search for Brightcove video data
         res = self.session.http.get(self.url)
-        match = self._brightcove_video_re.search(res.text) or self._brightcove_video_alt_re.search(res.text)
+        match = self._brightcove_video_re.search(res.text)
         if match is not None:
             account_id = match.group('account_id')
+            log.debug(f'Account ID: {account_id}')
             video_id = match.group('video_id')
+            log.debug(f'Video ID: {video_id}')
             player = BrightcovePlayer(self.session, account_id)
-            for stream in player.get_streams(video_id):
-                yield stream
+            yield from player.get_streams(video_id)
         else:
-            # Try to get the stream URL in the page
-            match = self._embed_video_url_re.search(res.text)
+            # Try to find the Dailymotion video ID
+            match = self._embed_video_id_re.search(res.text)
             if match is not None:
-                video_url = match.group('video_url')
-                if '.m3u8' in video_url:
-                    yield from HLSStream.parse_variant_playlist(self.session, video_url).items()
+                video_id = match.group('video_id')
+                log.debug(f'Video ID: {video_id}')
+                yield from self.session.streams(self._dailymotion_url.format(video_id)).items()
 
 
 __plugin__ = BFMTV
diff --git a/src/streamlink/plugins/bilibili.py b/src/streamlink/plugins/bilibili.py
index 619c27ce..a0d0ca94 100644
--- a/src/streamlink/plugins/bilibili.py
+++ b/src/streamlink/plugins/bilibili.py
@@ -3,7 +3,7 @@ import re
 from urllib.parse import urlparse
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.stream import HTTPStream
 
 log = logging.getLogger(__name__)
@@ -55,9 +55,7 @@ class Bilibili(Plugin):
         return Plugin.stream_weight(stream)
 
     def _get_streams(self):
-        self.session.http.headers.update({
-            'User-Agent': useragents.FIREFOX,
-            'Referer': self.url})
+        self.session.http.headers.update({'Referer': self.url})
         match = _url_re.match(self.url)
         channel = match.group("channel")
         res_room_id = self.session.http.get(ROOM_API.format(channel))
diff --git a/src/streamlink/plugins/btsports.py b/src/streamlink/plugins/btsports.py
index 7ddabcbc..bb36b675 100644
--- a/src/streamlink/plugins/btsports.py
+++ b/src/streamlink/plugins/btsports.py
@@ -5,7 +5,6 @@ from urllib.parse import quote
 from uuid import uuid4
 
 from streamlink.plugin import Plugin, PluginArgument, PluginArguments
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -40,10 +39,6 @@ class BTSports(Plugin):
     saml_url = "https://samlfed.bt.com/sportgetfedwebhls"
     login_url = "https://signin1.bt.com/siteminderagent/forms/login.fcc"
 
-    def __init__(self, url):
-        super().__init__(url)
-        self.session.http.headers = {"User-Agent": useragents.FIREFOX}
-
     @classmethod
     def can_handle_url(cls, url):
         return cls.url_re.match(url) is not None
diff --git a/src/streamlink/plugins/live_russia_tv.py b/src/streamlink/plugins/live_russia_tv.py
index 0d7523c9..ae2d1d0e 100644
--- a/src/streamlink/plugins/live_russia_tv.py
+++ b/src/streamlink/plugins/live_russia_tv.py
@@ -3,7 +3,6 @@ import re
 from urllib.parse import parse_qsl, urlparse
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream, HTTPStream
 
@@ -46,7 +45,6 @@ class LiveRussia(Plugin):
             return self.DATA_LIVE_URL.format(**args)
 
     def _get_streams(self):
-        self.session.http.headers.update({"User-Agent": useragents.FIREFOX})
         info_url = None
 
         channel = self.url_re.match(self.url).group('channel')
diff --git a/src/streamlink/plugins/ltv_lsm_lv.py b/src/streamlink/plugins/ltv_lsm_lv.py
index 2400b20e..71f8cc68 100644
--- a/src/streamlink/plugins/ltv_lsm_lv.py
+++ b/src/streamlink/plugins/ltv_lsm_lv.py
@@ -3,7 +3,6 @@ import re
 from urllib.parse import urlparse
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream
 
@@ -21,10 +20,7 @@ class LtvLsmLv(Plugin):
         return cls.url_re.match(url) is not None
 
     def _get_streams(self):
-        self.session.http.headers.update({
-            "Referer": self.url,
-            "User-Agent": useragents.FIREFOX
-        })
+        self.session.http.headers.update({"Referer": self.url})
 
         iframe_url = None
         res = self.session.http.get(self.url)
diff --git a/src/streamlink/plugins/metube.py b/src/streamlink/plugins/metube.py
index 48493a99..b9c54c8a 100644
--- a/src/streamlink/plugins/metube.py
+++ b/src/streamlink/plugins/metube.py
@@ -1,7 +1,6 @@
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 
 
@@ -26,10 +25,7 @@ class MeTube(Plugin):
         stream_type = self._url_re.match(self.url).group("type")
         hls_re = re.compile(r"""["'](?P<url>[^"']+\.m3u8[^"']*?)["']""")
 
-        headers = {
-            "Origin": "https://www.metube.id",
-            "User-Agent": useragents.FIREFOX
-        }
+        headers = {"Origin": "https://www.metube.id"}
 
         res = self.session.http.get(self.url)
         match = hls_re.search(res.text)
diff --git a/src/streamlink/plugins/mitele.py b/src/streamlink/plugins/mitele.py
index b7e513f1..050ae2b4 100644
--- a/src/streamlink/plugins/mitele.py
+++ b/src/streamlink/plugins/mitele.py
@@ -2,7 +2,7 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
 from streamlink.utils import parse_json
 
@@ -45,10 +45,7 @@ class Mitele(Plugin):
 
     def __init__(self, url):
         super().__init__(url)
-        self.session.http.headers.update({
-            "User-Agent": useragents.FIREFOX,
-            "Referer": self.url
-        })
+        self.session.http.headers.update({"Referer": self.url})
 
     @classmethod
     def can_handle_url(cls, url):
diff --git a/src/streamlink/plugins/mjunoon.py b/src/streamlink/plugins/mjunoon.py
index 1497403c..7136d110 100644
--- a/src/streamlink/plugins/mjunoon.py
+++ b/src/streamlink/plugins/mjunoon.py
@@ -3,7 +3,6 @@ import re
 from urllib.parse import parse_qsl, urlparse
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream
 
@@ -18,7 +17,6 @@ class Mjunoon(Plugin):
         return cls.url_re.match(url) is not None
 
     def _get_streams(self):
-        self.session.http.headers.update({"User-Agent": useragents.FIREFOX})
         res = self.session.http.get(self.url)
         for script in itertags(res.text, 'script'):
             if script.attributes.get("id") == "playerScript":
diff --git a/src/streamlink/plugins/okru.py b/src/streamlink/plugins/okru.py
index d3538f7d..50fa8b58 100644
--- a/src/streamlink/plugins/okru.py
+++ b/src/streamlink/plugins/okru.py
@@ -5,7 +5,7 @@ from urllib.parse import unquote
 
 from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream, HTTPStream, RTMPStream
 from streamlink.utils import parse_json
 
@@ -76,10 +76,7 @@ class OKru(Plugin):
         return Plugin.stream_weight(key)
 
     def _get_streams(self):
-        self.session.http.headers.update({
-            'User-Agent': useragents.FIREFOX,
-            'Referer': self.url,
-        })
+        self.session.http.headers.update({'Referer': self.url})
 
         try:
             data = self.session.http.get(self.url, schema=self._data_schema)
diff --git a/src/streamlink/plugins/oneplusone.py b/src/streamlink/plugins/oneplusone.py
index d0cb9b37..4ac04e7e 100644
--- a/src/streamlink/plugins/oneplusone.py
+++ b/src/streamlink/plugins/oneplusone.py
@@ -6,7 +6,7 @@ from urllib.parse import urlparse
 
 from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
 from streamlink.utils import parse_json
 
@@ -71,7 +71,6 @@ class OnePlusOne(Plugin):
                 return data
 
     def _get_streams(self):
-        self.session.http.headers.update({"User-Agent": useragents.FIREFOX})
         res = self.session.http.get(self.url)
         iframe_url = self.find_iframe(res)
         if iframe_url:
diff --git a/src/streamlink/plugins/pixiv.py b/src/streamlink/plugins/pixiv.py
index ea0c0104..d8b105de 100644
--- a/src/streamlink/plugins/pixiv.py
+++ b/src/streamlink/plugins/pixiv.py
@@ -3,7 +3,7 @@ import re
 
 from streamlink.exceptions import FatalPluginError, NoStreamsError, PluginError
 from streamlink.plugin import Plugin, PluginArgument, PluginArguments
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -90,10 +90,7 @@ class Pixiv(Plugin):
         super().__init__(url)
         self._authed = (self.session.http.cookies.get("PHPSESSID")
                         and self.session.http.cookies.get("device_token"))
-        self.session.http.headers.update({
-            "User-Agent": useragents.FIREFOX,
-            "Referer": self.url
-        })
+        self.session.http.headers.update({"Referer": self.url})
 
     @classmethod
     def can_handle_url(cls, url):
diff --git a/src/streamlink/plugins/radiko.py b/src/streamlink/plugins/radiko.py
index 752b112d..61940c62 100644
--- a/src/streamlink/plugins/radiko.py
+++ b/src/streamlink/plugins/radiko.py
@@ -7,7 +7,6 @@ import xml.etree.ElementTree as ET
 from urllib.parse import urlencode
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 
 
@@ -31,7 +30,6 @@ class Radiko(Plugin):
             start_at = match.group('start_at')
             url, token = self._timefree(station_id, start_at)
         headers = {
-            'User-Agent': useragents.FIREFOX,
             'X-Radiko-AuthToken': token
         }
         self.session.http.headers = headers
@@ -70,7 +68,6 @@ class Radiko(Plugin):
 
     def _authorize(self):
         headers = {
-            'User-Agent': useragents.FIREFOX,
             'x-radiko-app': 'pc_html5',
             'x-radiko-app-version': '0.0.1',
             'x-radiko-device': 'pc',
diff --git a/src/streamlink/plugins/reuters.py b/src/streamlink/plugins/reuters.py
index 09e24183..51e932a8 100644
--- a/src/streamlink/plugins/reuters.py
+++ b/src/streamlink/plugins/reuters.py
@@ -2,7 +2,7 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream
 from streamlink.utils import parse_json
@@ -46,7 +46,6 @@ class Reuters(Plugin):
 
     def __init__(self, url):
         super().__init__(url)
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
         self.title = None
 
     @classmethod
diff --git a/src/streamlink/plugins/sbscokr.py b/src/streamlink/plugins/sbscokr.py
index 1c571704..15cd257c 100644
--- a/src/streamlink/plugins/sbscokr.py
+++ b/src/streamlink/plugins/sbscokr.py
@@ -3,7 +3,7 @@ import random
 import re
 
 from streamlink.plugin import Plugin, PluginArgument, PluginArguments
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -69,7 +69,6 @@ class SBScokr(Plugin):
     def _get_streams(self):
         user_channel_id = self.get_option('id')
 
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
         res = self.session.http.get(self.api_channels)
         res = self.session.http.json(res, schema=self._channels_schema)
 
diff --git a/src/streamlink/plugins/showroom.py b/src/streamlink/plugins/showroom.py
index 6e0aba55..a0efb693 100644
--- a/src/streamlink/plugins/showroom.py
+++ b/src/streamlink/plugins/showroom.py
@@ -2,7 +2,7 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream, RTMPStream
 from streamlink.stream.hls import HLSStreamReader, HLSStreamWorker
 
@@ -115,10 +115,7 @@ class Showroom(Plugin):
 
     def __init__(self, url):
         Plugin.__init__(self, url)
-        self._headers = {
-            'Referer': self.url,
-            'User-Agent': useragents.FIREFOX
-        }
+        self._headers = {'Referer': self.url}
         self._room_id = None
         self._stream_urls = None
 
diff --git a/src/streamlink/plugins/sportal.py b/src/streamlink/plugins/sportal.py
index fc9cde33..7ff83416 100644
--- a/src/streamlink/plugins/sportal.py
+++ b/src/streamlink/plugins/sportal.py
@@ -2,7 +2,6 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -19,7 +18,6 @@ class Sportal(Plugin):
         return cls._url_re.match(url) is not None
 
     def _get_streams(self):
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
         res = self.session.http.get(self.url)
         m = self._hls_re.search(res.text)
         if not m:
diff --git a/src/streamlink/plugins/streamme.py b/src/streamlink/plugins/streamme.py
index fc0978c0..de3598fa 100644
--- a/src/streamlink/plugins/streamme.py
+++ b/src/streamlink/plugins/streamme.py
@@ -3,7 +3,7 @@ import re
 
 from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.plugin.api.utils import parse_json
 from streamlink.stream import HLSStream
 
@@ -68,7 +68,6 @@ class StreamMe(Plugin):
         super().__init__(url)
         self.author = None
         self.title = None
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
 
     @classmethod
     def can_handle_url(cls, url):
diff --git a/src/streamlink/plugins/streann.py b/src/streamlink/plugins/streann.py
index 21903d03..2773343c 100644
--- a/src/streamlink/plugins/streann.py
+++ b/src/streamlink/plugins/streann.py
@@ -6,7 +6,6 @@ import time
 from urllib.parse import urlparse
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 from streamlink.utils import parse_qsd
 from streamlink.utils.crypto import decrypt_openssl
@@ -27,7 +26,6 @@ class Streann(Plugin):
     def __init__(self, url):
         super().__init__(url)
         self._device_id = None
-        self._headers = {"User-Agent": useragents.FIREFOX}
 
     @classmethod
     def can_handle_url(cls, url):
@@ -62,7 +60,6 @@ class Streann(Plugin):
                      arg2=base64.b64encode(self.time.encode("utf8")))
 
         headers = {
-            "User-Agent": useragents.FIREFOX,
             "Referer": self.url,
             "X-Requested-With": "XMLHttpRequest",
             "Content-Type": "application/x-www-form-urlencoded"
diff --git a/src/streamlink/plugins/stv.py b/src/streamlink/plugins/stv.py
index 1198b1ef..c55ee29a 100644
--- a/src/streamlink/plugins/stv.py
+++ b/src/streamlink/plugins/stv.py
@@ -3,7 +3,6 @@ import re
 
 from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -26,7 +25,6 @@ class STV(Plugin):
         return self.title
 
     def _get_api_results(self):
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
         res = self.session.http.get(self.API_URL)
         data = self.session.http.json(res)
 
diff --git a/src/streamlink/plugins/teleclubzoom.py b/src/streamlink/plugins/teleclubzoom.py
index 019f17e3..b60aad2d 100644
--- a/src/streamlink/plugins/teleclubzoom.py
+++ b/src/streamlink/plugins/teleclubzoom.py
@@ -4,7 +4,7 @@ from urllib.parse import urlparse
 
 from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream
 from streamlink.utils import update_scheme
@@ -36,8 +36,6 @@ class TeleclubZoom(Plugin):
         return cls._url_re.match(url) is not None
 
     def _get_streams(self):
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
-
         iframe_url = None
         page = self.session.http.get(self.url)
         for a in itertags(page.text, 'a'):
diff --git a/src/streamlink/plugins/tlctr.py b/src/streamlink/plugins/tlctr.py
index b635e400..cb6d9cbe 100644
--- a/src/streamlink/plugins/tlctr.py
+++ b/src/streamlink/plugins/tlctr.py
@@ -2,7 +2,6 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -19,7 +18,6 @@ class TLCtr(Plugin):
         return cls._url_re.match(url) is not None
 
     def _get_streams(self):
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
         res = self.session.http.get(self.url)
 
         m = self._hls_re.search(res.text)
diff --git a/src/streamlink/plugins/tvp.py b/src/streamlink/plugins/tvp.py
index a0f6b9f3..d4e382b3 100644
--- a/src/streamlink/plugins/tvp.py
+++ b/src/streamlink/plugins/tvp.py
@@ -3,7 +3,6 @@ import re
 
 from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream, HTTPStream
 
 log = logging.getLogger(__name__)
@@ -37,8 +36,6 @@ class TVP(Plugin):
         return p_url
 
     def _get_streams(self):
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
-
         embed_url = self.get_embed_url()
         res = self.session.http.get(embed_url)
         m = self._stream_re.findall(res.text)
diff --git a/src/streamlink/plugins/tvrplus.py b/src/streamlink/plugins/tvrplus.py
index 2120e149..22b2ab74 100644
--- a/src/streamlink/plugins/tvrplus.py
+++ b/src/streamlink/plugins/tvrplus.py
@@ -2,7 +2,7 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -24,10 +24,7 @@ class TVRPlus(Plugin):
         return cls.url_re.match(url) is not None
 
     def _get_streams(self):
-        headers = {
-            "User-Agent": useragents.FIREFOX,
-            "Referer": self.url
-        }
+        headers = {"Referer": self.url}
         stream_url = self.stream_schema.validate(self.session.http.get(self.url).text)
         if stream_url:
             stream_url = list(set(stream_url))
diff --git a/src/streamlink/plugins/vinhlongtv.py b/src/streamlink/plugins/vinhlongtv.py
index eba00cc5..6eb90867 100644
--- a/src/streamlink/plugins/vinhlongtv.py
+++ b/src/streamlink/plugins/vinhlongtv.py
@@ -2,7 +2,7 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -27,8 +27,6 @@ class VinhLongTV(Plugin):
         return cls._url_re.match(url) is not None
 
     def _get_streams(self):
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
-
         channel = self._url_re.match(self.url).group('channel')
 
         res = self.session.http.get(self.api_url.format(channel))
diff --git a/src/streamlink/plugins/vtvgo.py b/src/streamlink/plugins/vtvgo.py
index a2be57cd..f9fb70f7 100644
--- a/src/streamlink/plugins/vtvgo.py
+++ b/src/streamlink/plugins/vtvgo.py
@@ -3,7 +3,6 @@ import re
 
 from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -24,7 +23,6 @@ class VTVgo(Plugin):
         self.session.http.headers.update({
             'Origin': 'https://vtvgo.vn',
             'Referer': self.url,
-            'User-Agent': useragents.FIREFOX,
             'X-Requested-With': 'XMLHttpRequest',
         })
         res = self.session.http.get(self.url)
diff --git a/src/streamlink/plugins/willax.py b/src/streamlink/plugins/willax.py
index c3aed2bd..e3517892 100644
--- a/src/streamlink/plugins/willax.py
+++ b/src/streamlink/plugins/willax.py
@@ -3,7 +3,6 @@ import re
 from html import unescape as html_unescape
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.plugin.api.utils import itertags
 
 log = logging.getLogger(__name__)
@@ -17,7 +16,6 @@ class Willax(Plugin):
         return cls._url_re.match(url) is not None
 
     def _get_streams(self):
-        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
         res = self.session.http.get(self.url)
         for iframe in itertags(res.text, 'iframe'):
             return self.session.streams(html_unescape(iframe.attributes.get('src')))
diff --git a/src/streamlink/plugins/zengatv.py b/src/streamlink/plugins/zengatv.py
index 1b6a330c..7a5694c3 100644
--- a/src/streamlink/plugins/zengatv.py
+++ b/src/streamlink/plugins/zengatv.py
@@ -2,7 +2,6 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
@@ -22,10 +21,7 @@ class ZengaTV(Plugin):
         return cls._url_re.match(url) is not None
 
     def _get_streams(self):
-        headers = {
-            "User-Agent": useragents.FIREFOX,
-            "Referer": self.url,
-        }
+        headers = {"Referer": self.url}
 
         res = self.session.http.get(self.url, headers=headers)
         for id_re in (self._id_re, self._id_2_re):
diff --git a/src/streamlink/stream/dash.py b/src/streamlink/stream/dash.py
index e807280b..6f8e1c26 100644
--- a/src/streamlink/stream/dash.py
+++ b/src/streamlink/stream/dash.py
@@ -262,3 +262,6 @@ class DASHStream(Stream):
 
     def to_url(self):
         return self.mpd.url
+
+    def to_manifest_url(self):
+        return self.mpd.url
diff --git a/src/streamlink/stream/hls.py b/src/streamlink/stream/hls.py
index f61f2321..829011f2 100644
--- a/src/streamlink/stream/hls.py
+++ b/src/streamlink/stream/hls.py
@@ -339,7 +339,7 @@ class HLSStreamReader(SegmentedStreamReader):
 class MuxedHLSStream(MuxedStream):
     __shortname__ = "hls-multi"
 
-    def __init__(self, session, video, audio, force_restart=False, ffmpeg_options=None, **args):
+    def __init__(self, session, video, audio, url_master=None, force_restart=False, ffmpeg_options=None, **args):
         tracks = [video]
         maps = ["0:v?", "0:a?"]
         if audio:
@@ -353,6 +353,10 @@ class MuxedHLSStream(MuxedStream):
         ffmpeg_options = ffmpeg_options or {}
 
         super().__init__(session, *substreams, format="mpegts", maps=maps, **ffmpeg_options)
+        self.url_master = url_master
+
+    def to_manifest_url(self):
+        return self.url_master
 
 
 class HLSStream(HTTPStream):
@@ -368,24 +372,31 @@ class HLSStream(HTTPStream):
 
     __shortname__ = "hls"
 
-    def __init__(self, session_, url, force_restart=False, start_offset=0, duration=None, **args):
+    def __init__(self, session_, url, url_master=None, force_restart=False, start_offset=0, duration=None, **args):
         HTTPStream.__init__(self, session_, url, **args)
+        self.url_master = url_master
         self.force_restart = force_restart
         self.start_offset = start_offset
         self.duration = duration
 
     def __repr__(self):
-        return "<HLSStream({0!r})>".format(self.url)
+        return f"<HLSStream({self.url!r}, {self.url_master!r})>"
 
     def __json__(self):
         json = HTTPStream.__json__(self)
 
+        if self.url_master:
+            json["master"] = self.url_master
+
         # Pretty sure HLS is GET only.
         del json["method"]
         del json["body"]
 
         return json
 
+    def to_manifest_url(self):
+        return self.url_master
+
     def open(self):
         reader = HLSStreamReader(self)
         reader.open()
@@ -517,6 +528,7 @@ class HLSStream(HTTPStream):
                 stream = MuxedHLSStream(session_,
                                         video=playlist.uri,
                                         audio=[x.uri for x in external_audio if x.uri],
+                                        url_master=url,
                                         force_restart=force_restart,
                                         start_offset=start_offset,
                                         duration=duration,
@@ -524,6 +536,7 @@ class HLSStream(HTTPStream):
             else:
                 stream = cls(session_,
                              playlist.uri,
+                             url_master=url,
                              force_restart=force_restart,
                              start_offset=start_offset,
                              duration=duration,
diff --git a/src/streamlink/stream/stream.py b/src/streamlink/stream/stream.py
index 59ad132e..7d3dd628 100644
--- a/src/streamlink/stream/stream.py
+++ b/src/streamlink/stream/stream.py
@@ -43,6 +43,9 @@ class Stream(object):
     def to_url(self):
         raise TypeError("{0} cannot be converted to a URL".format(self.shortname()))
 
+    def to_manifest_url(self):
+        raise TypeError("{0} cannot be converted to a URL".format(self.shortname()))
+
 
 class StreamIO(io.IOBase):
     pass
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 8e9c22e4..d71968f4 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -9,7 +9,9 @@ from streamlink.utils.args import (
     boolean, comma_list, comma_list_filter, filesize, keyvalue, num
 )
 from streamlink.utils.times import hours_minutes_seconds
-from streamlink_cli.constants import (DEFAULT_PLAYER_ARGUMENTS, DEFAULT_STREAM_METADATA, STREAM_PASSTHROUGH, SUPPORTED_PLAYERS)
+from streamlink_cli.constants import (
+    DEFAULT_STREAM_METADATA, PLAYER_ARGS_INPUT_DEFAULT, PLAYER_ARGS_INPUT_FALLBACK, STREAM_PASSTHROUGH, SUPPORTED_PLAYERS
+)
 from streamlink_cli.utils import find_default_player
 
 _printable_re = re.compile(r"[{0}]".format(printable))
@@ -310,33 +312,36 @@ def build_parser():
     player.add_argument(
         "-a", "--player-args",
         metavar="ARGUMENTS",
-        default=DEFAULT_PLAYER_ARGUMENTS,
-        help="""
+        default="",
+        help=f"""
         This option allows you to customize the default arguments which are put
         together with the value of --player to create a command to execute.
-        Unlike the --player parameter, custom player arguments will not be logged.
 
-        This value can contain formatting variables surrounded by curly braces,
+        It's usually enough to only use --player instead of this unless you need
+        to add arguments after the player's input argument or if you don't want
+        any of the player arguments to be logged.
+
+        The value can contain formatting variables surrounded by curly braces,
         {{ and }}. If you need to include a brace character, it can be escaped
         by doubling, e.g. {{{{ and }}}}.
 
         Formatting variables available:
 
-        {{filename}}
-            This is the filename that the player will use. It's usually "-"
-            (stdin), but can also be a URL or a file depending on the options
-            used.
-
-        It's usually enough to use --player instead of this unless you need to
-        add arguments after the filename.
+        {{{PLAYER_ARGS_INPUT_DEFAULT}}}
+            This is the input that the player will use. For standard input (stdin),
+            it is ``-``, but it can also be a URL, depending on the options used.
 
-        Default is "{0}".
+        {{{PLAYER_ARGS_INPUT_FALLBACK}}}
+            The old fallback variable name with the same functionality.
 
         Example:
 
-          %(prog)s -p vlc -a "--play-and-exit {{filename}}" <url> [stream]
+          %(prog)s -p vlc -a "--play-and-exit {{{PLAYER_ARGS_INPUT_DEFAULT}}}" <url> [stream]
 
-        """.format(DEFAULT_PLAYER_ARGUMENTS)
+        Note: When neither of the variables are found, ``{{{PLAYER_ARGS_INPUT_DEFAULT}}}``
+        will be appended to the whole parameter value, to ensure that the player
+        always receives an input argument.
+        """
     )
     player.add_argument(
         "-v", "--verbose-player",
diff --git a/src/streamlink_cli/constants.py b/src/streamlink_cli/constants.py
index 75d138ec..0d4dc809 100644
--- a/src/streamlink_cli/constants.py
+++ b/src/streamlink_cli/constants.py
@@ -2,7 +2,9 @@ import os
 
 from streamlink_cli.compat import is_win32
 
-DEFAULT_PLAYER_ARGUMENTS = "{filename}"
+PLAYER_ARGS_INPUT_DEFAULT = "playerinput"
+PLAYER_ARGS_INPUT_FALLBACK = "filename"
+
 DEFAULT_STREAM_METADATA = {
     "title": "Unknown Title",
     "author": "Unknown Author",
@@ -34,6 +36,7 @@ STREAM_SYNONYMS = ["best", "worst", "best-unfiltered", "worst-unfiltered"]
 STREAM_PASSTHROUGH = ["hls", "http", "rtmp"]
 
 __all__ = [
-    "CONFIG_FILES", "DEFAULT_PLAYER_ARGUMENTS",
-    "PLUGINS_DIR", "STREAM_SYNONYMS", "STREAM_PASSTHROUGH"
+    "PLAYER_ARGS_INPUT_DEFAULT", "PLAYER_ARGS_INPUT_FALLBACK",
+    "DEFAULT_STREAM_METADATA", "SUPPORTED_PLAYERS",
+    "CONFIG_FILES", "PLUGINS_DIR", "STREAM_SYNONYMS", "STREAM_PASSTHROUGH"
 ]
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index cb971a73..7341825d 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -606,12 +606,16 @@ def handle_url():
         else:
             console.exit("{0}.\n       Available streams: {1}",
                          err, validstreams)
+    elif console.json:
+        console.msg_json(dict(plugin=plugin.module, streams=streams))
+    elif args.stream_url:
+        try:
+            console.msg("{0}", streams[list(streams)[-1]].to_manifest_url())
+        except TypeError:
+            console.exit("The stream specified cannot be translated to a URL")
     else:
-        if console.json:
-            console.msg_json(dict(streams=streams, plugin=plugin.module))
-        else:
-            validstreams = format_valid_streams(plugin, streams)
-            console.msg("Available streams: {0}", validstreams)
+        validstreams = format_valid_streams(plugin, streams)
+        console.msg("Available streams: {0}", validstreams)
 
 
 def print_plugins():
diff --git a/src/streamlink_cli/output.py b/src/streamlink_cli/output.py
index 2267e9c4..60d1977b 100644
--- a/src/streamlink_cli/output.py
+++ b/src/streamlink_cli/output.py
@@ -1,12 +1,13 @@
 import logging
 import os
+import re
 import shlex
 import subprocess
 import sys
 from time import sleep
 
 from streamlink_cli.compat import is_win32, stdout
-from streamlink_cli.constants import DEFAULT_PLAYER_ARGUMENTS, SUPPORTED_PLAYERS
+from streamlink_cli.constants import PLAYER_ARGS_INPUT_DEFAULT, PLAYER_ARGS_INPUT_FALLBACK, SUPPORTED_PLAYERS
 from streamlink_cli.utils import ignored
 
 if is_win32:
@@ -77,7 +78,12 @@ class FileOutput(Output):
 class PlayerOutput(Output):
     PLAYER_TERMINATE_TIMEOUT = 10.0
 
-    def __init__(self, cmd, args=DEFAULT_PLAYER_ARGUMENTS, filename=None, quiet=True, kill=True,
+    _re_player_args_input = re.compile("|".join(map(
+        lambda const: re.escape(f"{{{const}}}"),
+        [PLAYER_ARGS_INPUT_DEFAULT, PLAYER_ARGS_INPUT_FALLBACK]
+    )))
+
+    def __init__(self, cmd, args="", filename=None, quiet=True, kill=True,
                  call=False, http=None, namedpipe=None, record=None, title=None):
         super().__init__()
         self.cmd = cmd
@@ -106,6 +112,9 @@ class PlayerOutput(Output):
             self.stdout = sys.stdout
             self.stderr = sys.stderr
 
+        if not self._re_player_args_input.search(self.args):
+            self.args += f"{' ' if self.args else ''}{{{PLAYER_ARGS_INPUT_DEFAULT}}}"
+
     @property
     def running(self):
         sleep(0.5)
@@ -201,7 +210,7 @@ class PlayerOutput(Output):
                     self.title = self.title.replace('"', '')
                     filename = filename[:-1] + '\\' + self.title + filename[-1]
 
-        args = self.args.format(filename=filename)
+        args = self.args.format(**{PLAYER_ARGS_INPUT_DEFAULT: filename, PLAYER_ARGS_INPUT_FALLBACK: filename})
         cmd = self.cmd
 
         # player command


Test patch:
diff --git a/tests/plugins/test_abweb.py b/tests/plugins/test_abweb.py
index 3bab6f57..8688600d 100644
--- a/tests/plugins/test_abweb.py
+++ b/tests/plugins/test_abweb.py
@@ -9,12 +9,14 @@ class TestPluginABweb(unittest.TestCase):
             'http://www.abweb.com/bis-tv-online/bistvo-tele-universal.aspx?chn=ab1',
             'http://www.abweb.com/BIS-TV-Online/bistvo-tele-universal.aspx?chn=ab1',
             'http://www.abweb.com/BIS-TV-Online/bistvo-tele-universal.aspx?chn=luckyjack',
+            'https://www.abweb.com/BIS-TV-Online/bistvo-tele-universal.aspx?chn=action',
         ]
         for url in should_match:
-            self.assertTrue(ABweb.can_handle_url(url))
+            self.assertTrue(ABweb.can_handle_url(url), url)
 
+    def test_can_handle_url_negative(self):
         should_not_match = [
             'http://www.abweb.com',
         ]
         for url in should_not_match:
-            self.assertFalse(ABweb.can_handle_url(url))
+            self.assertFalse(ABweb.can_handle_url(url), url)
diff --git a/tests/streams/test_hls.py b/tests/streams/test_hls.py
index 2fe904a8..d8c0ce1e 100644
--- a/tests/streams/test_hls.py
+++ b/tests/streams/test_hls.py
@@ -50,6 +50,17 @@ class SegmentEnc(Segment):
         self.content = encrypt(self.content, key, iv)
 
 
+class TestHLSStreamRepr(unittest.TestCase):
+    def test_repr(self):
+        session = Streamlink()
+
+        stream = hls.HLSStream(session, "https://foo.bar/playlist.m3u8")
+        self.assertEqual(repr(stream), "<HLSStream('https://foo.bar/playlist.m3u8', None)>")
+
+        stream = hls.HLSStream(session, "https://foo.bar/playlist.m3u8", "https://foo.bar/master.m3u8")
+        self.assertEqual(repr(stream), "<HLSStream('https://foo.bar/playlist.m3u8', 'https://foo.bar/master.m3u8')>")
+
+
 class TestHLSVariantPlaylist(unittest.TestCase):
     @classmethod
     def get_master_playlist(cls, playlist):
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
index 20177921..9f9865ea 100644
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -1,11 +1,13 @@
 import os.path
 import tempfile
 import unittest
-from unittest.mock import Mock, patch
+from unittest.mock import Mock, call, patch
 
 import streamlink_cli.main
 from streamlink.plugin.plugin import Plugin
-from streamlink_cli.main import check_file_output, create_output, format_valid_streams, resolve_stream_name
+from streamlink_cli.main import (
+    check_file_output, create_output, format_valid_streams, handle_stream, handle_url, resolve_stream_name
+)
 from streamlink_cli.output import FileOutput, PlayerOutput
 
 
@@ -130,6 +132,60 @@ class TestCLIMain(unittest.TestCase):
             ])
         )
 
+    @patch("streamlink_cli.main.args", stream_url=True, subprocess_cmdline=False)
+    @patch("streamlink_cli.main.console", json=True)
+    def test_handle_stream_with_json_and_stream_url(self, console, args):
+        stream = Mock()
+        streams = dict(best=stream)
+        plugin = Mock(FakePlugin(), module="fake", arguments=[], streams=Mock(return_value=streams))
+
+        handle_stream(plugin, streams, "best")
+        self.assertEqual(console.msg.mock_calls, [])
+        self.assertEqual(console.msg_json.mock_calls, [call(stream)])
+        self.assertEqual(console.error.mock_calls, [])
+        console.msg_json.mock_calls.clear()
+
+        console.json = False
+        handle_stream(plugin, streams, "best")
+        self.assertEqual(console.msg.mock_calls, [call("{0}", stream.to_url())])
+        self.assertEqual(console.msg_json.mock_calls, [])
+        self.assertEqual(console.error.mock_calls, [])
+        console.msg.mock_calls.clear()
+
+        stream.to_url.side_effect = TypeError()
+        handle_stream(plugin, streams, "best")
+        self.assertEqual(console.msg.mock_calls, [])
+        self.assertEqual(console.msg_json.mock_calls, [])
+        self.assertEqual(console.exit.mock_calls, [call("The stream specified cannot be translated to a URL")])
+
+    @patch("streamlink_cli.main.args", stream_url=True, stream=[], default_stream=[], retry_max=0, retry_streams=0)
+    @patch("streamlink_cli.main.console", json=True)
+    def test_handle_url_with_json_and_stream_url(self, console, args):
+        stream = Mock()
+        streams = dict(worst=Mock(), best=stream)
+        plugin = Mock(FakePlugin(), module="fake", arguments=[], streams=Mock(return_value=streams))
+
+        with patch("streamlink_cli.main.streamlink", resolve_url=Mock(return_value=plugin)):
+            handle_url()
+            self.assertEqual(console.msg.mock_calls, [])
+            self.assertEqual(console.msg_json.mock_calls, [call(dict(plugin="fake", streams=streams))])
+            self.assertEqual(console.error.mock_calls, [])
+            console.msg_json.mock_calls.clear()
+
+            console.json = False
+            handle_url()
+            self.assertEqual(console.msg.mock_calls, [call("{0}", stream.to_manifest_url())])
+            self.assertEqual(console.msg_json.mock_calls, [])
+            self.assertEqual(console.error.mock_calls, [])
+            console.msg.mock_calls.clear()
+
+            stream.to_manifest_url.side_effect = TypeError()
+            handle_url()
+            self.assertEqual(console.msg.mock_calls, [])
+            self.assertEqual(console.msg_json.mock_calls, [])
+            self.assertEqual(console.exit.mock_calls, [call("The stream specified cannot be translated to a URL")])
+            console.exit.mock_calls.clear()
+
     def test_create_output_no_file_output_options(self):
         streamlink_cli.main.console = Mock()
         streamlink_cli.main.args = args = Mock()
@@ -139,6 +195,7 @@ class TestCLIMain(unittest.TestCase):
         args.record_and_pipe = None
         args.title = None
         args.player = "mpv"
+        args.player_args = ""
         self.assertIsInstance(create_output(FakePlugin), PlayerOutput)
 
     def test_create_output_file_output(self):
@@ -182,6 +239,7 @@ class TestCLIMain(unittest.TestCase):
             args.record_and_pipe = None
             args.title = None
             args.player = "mpv"
+            args.player_args = ""
             args.player_fifo = None
             self.assertIsInstance(create_output(FakePlugin), PlayerOutput)
         finally:
diff --git a/tests/test_cli_playerout.py b/tests/test_cli_playerout.py
index e3a80bf3..15a692a1 100644
--- a/tests/test_cli_playerout.py
+++ b/tests/test_cli_playerout.py
@@ -1,4 +1,4 @@
-from unittest.mock import ANY, patch
+from unittest.mock import ANY, Mock, patch
 
 from streamlink_cli.output import PlayerOutput
 from tests import posix_only, windows_only
@@ -7,27 +7,30 @@ UNICODE_TITLE = "기타치는소율 with UL섬 "
 
 
 @posix_only
-@patch('subprocess.Popen')
+@patch("streamlink_cli.output.sleep", Mock())
+@patch("subprocess.Popen")
 def test_output_mpv_unicode_title_posix(popen):
     po = PlayerOutput("mpv", title=UNICODE_TITLE)
     popen().poll.side_effect = lambda: None
     po.open()
-    popen.assert_called_with(['mpv', f'--title={UNICODE_TITLE}', '-'],
+    popen.assert_called_with(["mpv", f"--title={UNICODE_TITLE}", "-"],
                              bufsize=ANY, stderr=ANY, stdout=ANY, stdin=ANY)
 
 
 @posix_only
-@patch('subprocess.Popen')
+@patch("streamlink_cli.output.sleep", Mock())
+@patch("subprocess.Popen")
 def test_output_vlc_unicode_title_posix(popen):
     po = PlayerOutput("vlc", title=UNICODE_TITLE)
     popen().poll.side_effect = lambda: None
     po.open()
-    popen.assert_called_with(['vlc', '--input-title-format', UNICODE_TITLE, '-'],
+    popen.assert_called_with(["vlc", "--input-title-format", UNICODE_TITLE, "-"],
                              bufsize=ANY, stderr=ANY, stdout=ANY, stdin=ANY)
 
 
 @windows_only
-@patch('subprocess.Popen')
+@patch("streamlink_cli.output.sleep", Mock())
+@patch("subprocess.Popen")
 def test_output_mpv_unicode_title_windows_py3(popen):
     po = PlayerOutput("mpv.exe", title=UNICODE_TITLE)
     popen().poll.side_effect = lambda: None
@@ -37,10 +40,41 @@ def test_output_mpv_unicode_title_windows_py3(popen):
 
 
 @windows_only
-@patch('subprocess.Popen')
+@patch("streamlink_cli.output.sleep", Mock())
+@patch("subprocess.Popen")
 def test_output_vlc_unicode_title_windows_py3(popen):
     po = PlayerOutput("vlc.exe", title=UNICODE_TITLE)
     popen().poll.side_effect = lambda: None
     po.open()
     popen.assert_called_with(f"vlc.exe --input-title-format \"{UNICODE_TITLE}\" -",
                              bufsize=ANY, stderr=ANY, stdout=ANY, stdin=ANY)
+
+
+@posix_only
+def test_output_args_posix():
+    po_none = PlayerOutput("foo")
+    assert po_none._create_arguments() == ["foo", "-"]
+
+    po_implicit = PlayerOutput("foo", args="--bar")
+    assert po_implicit._create_arguments() == ["foo", "--bar", "-"]
+
+    po_explicit = PlayerOutput("foo", args="--bar {playerinput}")
+    assert po_explicit._create_arguments() == ["foo", "--bar", "-"]
+
+    po_fallback = PlayerOutput("foo", args="--bar {filename}")
+    assert po_fallback._create_arguments() == ["foo", "--bar", "-"]
+
+
+@windows_only
+def test_output_args_windows():
+    po_none = PlayerOutput("foo")
+    assert po_none._create_arguments() == "foo -"
+
+    po_implicit = PlayerOutput("foo", args="--bar")
+    assert po_implicit._create_arguments() == "foo --bar -"
+
+    po_explicit = PlayerOutput("foo", args="--bar {playerinput}")
+    assert po_explicit._create_arguments() == "foo --bar -"
+
+    po_fallback = PlayerOutput("foo", args="--bar {filename}")
+    assert po_fallback._create_arguments() == "foo --bar -"
diff --git a/tests/test_stream_json.py b/tests/test_stream_json.py
index b7c2afe3..1b6b9c29 100644
--- a/tests/test_stream_json.py
+++ b/tests/test_stream_json.py
@@ -39,16 +39,36 @@ class TestStreamToJSON(unittest.TestCase):
 
     def test_hls_stream(self):
         url = "http://test.se/stream.m3u8"
+        master = "http://test.se/master.m3u8"
+
         stream = HLSStream(self.session, url, headers={"User-Agent": "Test"})
         self.assertEqual(
-            {"type": "hls",
-             "url": url,
-             "headers": {
-                 "User-Agent": "Test",
-                 "Accept": "*/*",
-                 "Accept-Encoding": "gzip, deflate",
-                 "Connection": "keep-alive",
-             }},
+            {
+                "type": "hls",
+                "url": url,
+                "headers": {
+                    "User-Agent": "Test",
+                    "Accept": "*/*",
+                    "Accept-Encoding": "gzip, deflate",
+                    "Connection": "keep-alive",
+                }
+            },
+            stream.__json__()
+        )
+
+        stream = HLSStream(self.session, url, master, headers={"User-Agent": "Test"})
+        self.assertEqual(
+            {
+                "type": "hls",
+                "url": url,
+                "headers": {
+                    "User-Agent": "Test",
+                    "Accept": "*/*",
+                    "Accept-Encoding": "gzip, deflate",
+                    "Connection": "keep-alive",
+                },
+                "master": master
+            },
             stream.__json__()
         )