Repository: streamlink/streamlink
Pull request #2875 (2020-04-09 22:03:12)

Added support for clan tve, children's channel of RTVE
Added support for clan tve, children's channel of RTVE, online available at [https://www.rtve.es/infantil/directo/](https://www.rtve.es/infantil/directo/) since March. 

codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/2875?src=pr&el=h1) Report
> Merging [#2875](https://codecov.io/gh/streamlink/streamlink/pull/2875?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/fdf739d6cdf5e9f249f1b111d2b80d98d080922b&el=desc) will **decrease** coverage by `0.00%`.
> The diff coverage is `n/a`.


```diff
@@            Coverage Diff             @@
##           master    #2875      +/-   ##
==========================================
- Coverage   52.66%   52.66%   -0.01%     
==========================================
  Files         251      251              
  Lines       15753    15753              
==========================================
- Hits         8297     8296       -1     
- Misses       7456     7457       +1     
```


Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index a52905e7..ee8f4de6 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -176,7 +176,7 @@ rtbf                    - rtbf.be/auvio      Yes   Yes   Streams may be geo-rest
                         - rtbfradioplayer.be
 rtlxl                   rtlxl.nl             No    Yes   Streams may be geo-restricted to The Netherlands. Livestreams not supported.
 rtpplay                 rtp.pt/play          Yes   Yes   Streams may be geo-restricted to Portugal.
-rtve                    rtve.es              Yes   No
+rtve                    rtve.es              Yes   No    Streams may be geo-restricted to Spain.
 rtvs                    rtvs.sk              Yes   No    Streams may be geo-restricted to Slovakia.
 ruv                     ruv.is               Yes   Yes   Streams may be geo-restricted to Iceland.
 sbscokr                 play.sbs.co.kr       Yes   No    Streams may be geo-restricted to South Korea.
diff --git a/src/streamlink/plugins/rtve.py b/src/streamlink/plugins/rtve.py
index 1c26ca3d..771867a9 100644
--- a/src/streamlink/plugins/rtve.py
+++ b/src/streamlink/plugins/rtve.py
@@ -59,7 +59,7 @@ class ZTNRClient(object):
 class Rtve(Plugin):
     secret_key = base64.b64decode("eWVMJmRhRDM=")
     url_re = re.compile(r"""
-        https?://(?:www\.)?rtve\.es/(?:directo|noticias|television|deportes|alacarta|drmn)/.*?/?
+        https?://(?:www\.)?rtve\.es/(?:directo|infantil|noticias|television|deportes|alacarta|drmn)/.*?/?
     """, re.VERBOSE)
     cdn_schema = validate.Schema(
         validate.transform(partial(parse_xml, invalid_char_entities=True)),
diff --git a/src/streamlink/plugins/tv360.py b/src/streamlink/plugins/tv360.py
index 215afa81..f35ed6a5 100644
--- a/src/streamlink/plugins/tv360.py
+++ b/src/streamlink/plugins/tv360.py
@@ -1,32 +1,19 @@
 from __future__ import print_function
+
 import re
-from functools import partial
 
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
-from streamlink.utils import parse_json
 
 
 class TV360(Plugin):
-    url_re = re.compile(r"https?://(?:www.)?tv360.com.tr/CanliYayin")
-    data_re = re.compile(r'''div.*?data-tp=(?P<q>["'])(?P<data>.*?)(?P=q)''', re.DOTALL)
-    _js_to_json = partial(re.compile(r"""(\w+):(["']|\d+,|true|false)""").sub, r'"\1":\2')
-    data_schema = validate.Schema(
-        validate.transform(data_re.search),
-        validate.any(
-            None,
-            validate.all(
-                validate.get("data"),
-                validate.transform(_js_to_json),
-                validate.transform(lambda x: x.replace("'", '"')),
-                validate.transform(parse_json),
-                {
-                    "tp_type": "hls4",
-                    "tp_file": validate.url(),
-                }
-            )
-        )
+    url_re = re.compile(r"https?://(?:www.)?tv360.com.tr/canli-yayin")
+    hls_re = re.compile(r'''hls.loadSource\(["'](http.*m3u8)["']\)''', re.DOTALL)
+
+    hls_schema = validate.Schema(
+        validate.transform(hls_re.search),
+        validate.any(None, validate.all(validate.get(1)))
     )
 
     @classmethod
@@ -35,10 +22,10 @@ class TV360(Plugin):
 
     def _get_streams(self):
         res = self.session.http.get(self.url)
-        data = self.data_schema.validate(res.text)
+        hls_url = self.hls_re.search(res.text)
 
-        if data:
-            return HLSStream.parse_variant_playlist(self.session, data["tp_file"])
+        if hls_url:
+            return HLSStream.parse_variant_playlist(self.session, hls_url.group(1))
 
 
 __plugin__ = TV360
diff --git a/src/streamlink/plugins/youtube.py b/src/streamlink/plugins/youtube.py
index 26d12466..9229b093 100644
--- a/src/streamlink/plugins/youtube.py
+++ b/src/streamlink/plugins/youtube.py
@@ -16,75 +16,42 @@ from streamlink.utils.encoding import maybe_decode
 log = logging.getLogger(__name__)
 
 
-def parse_stream_map(stream_map):
-    if not stream_map:
-        return []
-
-    return [parse_query(s) for s in stream_map.split(",")]
-
-
-def parse_fmt_list(formatsmap):
-    formats = {}
-    if not formatsmap:
-        return formats
-
-    for format in formatsmap.split(","):
-        s = format.split("/")
-        (w, h) = s[1].split("x")
-        formats[int(s[0])] = "{0}p".format(h)
-
-    return formats
-
-
 _config_schema = validate.Schema(
     {
-        validate.optional("fmt_list"): validate.all(
-            validate.text,
-            validate.transform(parse_fmt_list)
-        ),
-        validate.optional("url_encoded_fmt_stream_map"): validate.all(
-            validate.text,
-            validate.transform(parse_stream_map),
-            [{
-                "itag": validate.all(
-                    validate.text,
-                    validate.transform(int)
-                ),
-                "quality": validate.text,
-                "url": validate.url(scheme="http"),
-                validate.optional("s"): validate.text,
-                validate.optional("stereo3d"): validate.all(
-                    validate.text,
-                    validate.transform(int),
-                    validate.transform(bool)
-                ),
-            }]
-        ),
-        validate.optional("adaptive_fmts"): validate.all(
-            validate.text,
-            validate.transform(parse_stream_map),
-            [{
-                validate.optional("s"): validate.text,
-                "type": validate.all(
-                    validate.text,
-                    validate.transform(lambda t: t.split(";")[0].split("/")),
-                    [validate.text, validate.text]
-                ),
-                "url": validate.all(
-                    validate.url(scheme="http")
-                )
-            }]
-        ),
-        validate.optional("hlsvp"): validate.text,
         validate.optional("player_response"): validate.all(
             validate.text,
             validate.transform(parse_json),
             {
                 validate.optional("streamingData"): {
                     validate.optional("hlsManifestUrl"): validate.text,
+                    validate.optional("formats"): [{
+                        "itag": int,
+                        validate.optional("url"): validate.text,
+                        validate.optional("cipher"): validate.text,
+                        "qualityLabel": validate.text
+                    }],
+                    validate.optional("adaptiveFormats"): [{
+                        "itag": int,
+                        "mimeType": validate.all(
+                            validate.text,
+                            validate.transform(
+                                lambda t:
+                                    [t.split(';')[0].split('/')[0], t.split(';')[1].split('=')[1].strip('"')]
+                            ),
+                            [validate.text, validate.text],
+                        ),
+                        validate.optional("url"): validate.url(scheme="http"),
+                        validate.optional("cipher"): validate.text,
+                        validate.optional("qualityLabel"): validate.text,
+                        validate.optional("bitrate"): int
+                    }]
                 },
                 validate.optional("videoDetails"): {
                     validate.optional("isLive"): validate.transform(bool),
+                    validate.optional("isLiveContent"): validate.transform(bool),
+                    validate.optional("author"): validate.text,
+                    validate.optional("title"): validate.all(validate.text,
+                                                             validate.transform(maybe_decode))
                 },
                 validate.optional("playabilityStatus"): {
                     validate.optional("status"): validate.text,
@@ -93,14 +60,6 @@ _config_schema = validate.Schema(
                 },
             },
         ),
-        validate.optional("live_playback"): validate.transform(bool),
-        validate.optional("reason"): validate.all(validate.text, validate.transform(maybe_decode)),
-        validate.optional("livestream"): validate.text,
-        validate.optional("live_playback"): validate.text,
-        validate.optional("author"): validate.all(validate.text,
-                                                  validate.transform(maybe_decode)),
-        validate.optional("title"): validate.all(validate.text,
-                                                 validate.transform(maybe_decode)),
         "status": validate.text
     }
 )
@@ -146,6 +105,7 @@ class YouTube(Plugin):
         }
     )
 
+    # There are missing itags
     adp_video = {
         137: "1080p",
         299: "1080p60",  # HFR
@@ -155,6 +115,9 @@ class YouTube(Plugin):
         315: "2160p60",  # HFR
         138: "2160p",
         302: "720p60",  # HFR
+        135: "480p",
+        133: "240p",
+        160: "144p",
     }
     adp_audio = {
         140: 128,
@@ -231,16 +194,15 @@ class YouTube(Plugin):
         self.author = data["author_name"]
         self.title = data["title"]
 
-    def _create_adaptive_streams(self, info, streams, protected):
+    def _create_adaptive_streams(self, info, streams):
         adaptive_streams = {}
         best_audio_itag = None
 
-        # Extract audio streams from the DASH format list
-        for stream_info in info.get("adaptive_fmts", []):
-            if stream_info.get("s"):
-                protected = True
+        # Extract audio streams from the adaptive format list
+        streaming_data = info.get("player_response", {}).get("streamingData", {})
+        for stream_info in streaming_data.get("adaptiveFormats", []):
+            if "url" not in stream_info:
                 continue
-
             stream_params = dict(parse_qsl(stream_info["url"]))
             if "itag" not in stream_params:
                 continue
@@ -248,7 +210,8 @@ class YouTube(Plugin):
             # extract any high quality streams only available in adaptive formats
             adaptive_streams[itag] = stream_info["url"]
 
-            stream_type, stream_format = stream_info["type"]
+            stream_type, stream_format = stream_info["mimeType"]
+
             if stream_type == "audio":
                 stream = HTTPStream(self.session, stream_info["url"])
                 name = "audio_{0}".format(stream_format)
@@ -272,7 +235,7 @@ class YouTube(Plugin):
                                                 HTTPStream(self.session, vurl),
                                                 HTTPStream(self.session, aurl))
 
-        return streams, protected
+        return streams
 
     def _find_video_id(self, url):
 
@@ -325,16 +288,16 @@ class YouTube(Plugin):
 
             res = self.session.http.get(self._video_info_url, params=params)
             info_parsed = parse_query(res.content if is_py2 else res.text, name="config", schema=_config_schema)
-            if (info_parsed.get("player_response", {}).get("playabilityStatus", {}).get("status") != "OK"
-                    or info_parsed.get("status") == "fail"):
-                reason = (info_parsed.get("player_response", {}).get("playabilityStatus", {}).get("reason")
-                          or info_parsed.get("reason"))
+            player_response = info_parsed.get("player_response", {})
+            playability_status = player_response.get("playabilityStatus", {})
+            if (playability_status.get("status") != "OK"):
+                reason = playability_status.get("reason")
                 log.debug("get_video_info - {0}: {1}".format(
                     count, reason)
                 )
                 continue
-            self.author = info_parsed.get("author")
-            self.title = info_parsed.get("title")
+            self.author = player_response.get("videoDetails", {}).get("author")
+            self.title = player_response.get("videoDetails", {}).get("title")
             log.debug("get_video_info - {0}: Found data".format(count))
             break
 
@@ -354,35 +317,34 @@ class YouTube(Plugin):
             log.error("Could not get video info")
             return
 
-        if info.get("livestream") == '1' or info.get("live_playback") == '1' \
-                or info.get("player_response", {}).get("videoDetails", {}).get("isLive"):
+        if (info.get("player_response", {}).get("videoDetails", {}).get("isLiveContent")
+           or info.get("player_response", {}).get("videoDetails", {}).get("isLive")):
             log.debug("This video is live.")
             is_live = True
 
-        formats = info.get("fmt_list")
         streams = {}
         protected = False
-        for stream_info in info.get("url_encoded_fmt_stream_map", []):
-            if stream_info.get("s"):
-                protected = True
-                continue
+        if (info.get("player_response", {}).get("streamingData", {}).get("adaptiveFormats", [{}])[0].get("cipher")
+           or info.get("player_response", {}).get("streamingData", {}).get("formats", [{}])[0].get("cipher")):
+            protected = True
+            log.debug("This video may be protected.")
 
+        for stream_info in info.get("player_response", {}).get("streamingData", {}).get("formats", []):
+            if "url" not in stream_info:
+                continue
             stream = HTTPStream(self.session, stream_info["url"])
-            name = formats.get(stream_info["itag"]) or stream_info["quality"]
-
-            if stream_info.get("stereo3d"):
-                name += "_3d"
+            name = stream_info["qualityLabel"]
 
             streams[name] = stream
 
         if not is_live:
-            streams, protected = self._create_adaptive_streams(info, streams, protected)
+            streams = self._create_adaptive_streams(info, streams)
 
-        hls_playlist = info.get("hlsvp") or info.get("player_response", {}).get("streamingData", {}).get("hlsManifestUrl")
-        if hls_playlist:
+        hls_manifest = info.get("player_response", {}).get("streamingData", {}).get("hlsManifestUrl")
+        if hls_manifest:
             try:
                 hls_streams = HLSStream.parse_variant_playlist(
-                    self.session, hls_playlist, namekey="pixels"
+                    self.session, hls_manifest, namekey="pixels"
                 )
                 streams.update(hls_streams)
             except IOError as err:
diff --git a/src/streamlink/utils/encoding.py b/src/streamlink/utils/encoding.py
index 19adb484..acf538ca 100644
--- a/src/streamlink/utils/encoding.py
+++ b/src/streamlink/utils/encoding.py
@@ -15,7 +15,10 @@ def get_filesystem_encoding():
 
 def maybe_encode(text, encoding="utf8"):
     if is_py2:
-        return text.encode(encoding)
+        if isinstance(text, unicode):
+            return text.encode(encoding)
+        else:
+            return text
     else:
         return text
 
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 8f438ca0..a32a2cd9 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -30,7 +30,7 @@ from streamlink.utils import LazyFormatter
 import streamlink.logger as logger
 from .argparser import build_parser
 from .compat import stdout, is_win32
-from streamlink.utils.encoding import maybe_encode
+from streamlink.utils.encoding import maybe_decode, get_filesystem_encoding
 from .console import ConsoleOutput, ConsoleUserInputRequester
 from .constants import CONFIG_FILES, PLUGINS_DIR, STREAM_SYNONYMS, DEFAULT_STREAM_METADATA
 from .output import FileOutput, PlayerOutput
@@ -146,7 +146,7 @@ def create_http_server(host=None, port=0):
 def create_title(plugin=None):
     if args.title and plugin:
         title = LazyFormatter.format(
-            maybe_encode(args.title),
+            maybe_decode(args.title, get_filesystem_encoding()),
             title=lambda: plugin.get_title() or DEFAULT_STREAM_METADATA["title"],
             author=lambda: plugin.get_author() or DEFAULT_STREAM_METADATA["author"],
             category=lambda: plugin.get_category() or DEFAULT_STREAM_METADATA["category"],
diff --git a/src/streamlink_cli/output.py b/src/streamlink_cli/output.py
index 76cc8f9f..bc5989b5 100644
--- a/src/streamlink_cli/output.py
+++ b/src/streamlink_cli/output.py
@@ -185,13 +185,13 @@ class PlayerOutput(Output):
             if self.player_name == "vlc":
                 # see https://wiki.videolan.org/Documentation:Format_String/, allow escaping with \$
                 self.title = self.title.replace("$", "$$").replace(r'\$$', "$")
-                extra_args.extend(["--input-title-format", self.title])
+                extra_args.extend([u"--input-title-format", self.title])
 
             # mpv
             if self.player_name == "mpv":
                 # see https://mpv.io/manual/stable/#property-expansion, allow escaping with \$, respect mpv's $>
                 self.title = self._mpv_title_escape(self.title)
-                extra_args.append("--title={}".format(self.title))
+                extra_args.append(u"--title={}".format(self.title))
 
             # potplayer
             if self.player_name == "potplayer":
@@ -209,8 +209,7 @@ class PlayerOutput(Output):
         if is_win32:
             eargs = maybe_decode(subprocess.list2cmdline(extra_args))
             # do not insert and extra " " when there are no extra_args
-            return maybe_encode(u' '.join([cmd] + ([eargs] if eargs else []) + [args]),
-                                encoding=get_filesystem_encoding())
+            return u' '.join([cmd] + ([eargs] if eargs else []) + [args])
         return shlex.split(cmd) + extra_args + shlex.split(args)
 
     def _open(self):
@@ -234,7 +233,8 @@ class PlayerOutput(Output):
         else:
             fargs = subprocess.list2cmdline(args)
         log.debug(u"Calling: {0}".format(fargs))
-        subprocess.call(args,
+
+        subprocess.call(maybe_encode(args, get_filesystem_encoding()),
                         stdout=self.stdout,
                         stderr=self.stderr)
 
@@ -247,7 +247,8 @@ class PlayerOutput(Output):
         else:
             fargs = subprocess.list2cmdline(args)
         log.debug(u"Opening subprocess: {0}".format(fargs))
-        self.player = subprocess.Popen(args,
+
+        self.player = subprocess.Popen(maybe_encode(args, get_filesystem_encoding()),
                                        stdin=self.stdin, bufsize=0,
                                        stdout=self.stdout,
                                        stderr=self.stderr)


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index a6e20084..e572a37f 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,17 +1,29 @@
+import os
 import warnings
 
+import pytest
+
+from streamlink.compat import is_py2, is_py3
+
 
 def catch_warnings(record=False, module=None):
     def _catch_warnings_wrapper(f):
         def _catch_warnings(*args, **kwargs):
             with warnings.catch_warnings(record=True, module=module) as w:
                 if record:
-                    return f(*(args + (w, )), **kwargs)
+                    return f(*(args + (w,)), **kwargs)
                 else:
                     return f(*args, **kwargs)
+
         return _catch_warnings
 
     return _catch_warnings_wrapper
 
 
-__all__ = ['ignore_warnings']
+windows_only = pytest.mark.skipif(os.name != "nt", reason="test only applicable on Window")
+posix_only = pytest.mark.skipif(os.name != "posix", reason="test only applicable on a POSIX OS")
+py3_only = pytest.mark.skipif(not is_py3, reason="test only applicable for Python 3")
+py2_only = pytest.mark.skipif(not is_py2, reason="test only applicable for Python 2")
+
+
+__all__ = ['catch_warnings', 'windows_only', 'posix_only', 'py2_only', 'py3_only']
diff --git a/tests/plugins/test_rtve.py b/tests/plugins/test_rtve.py
index 42b1c5e6..26079ef0 100644
--- a/tests/plugins/test_rtve.py
+++ b/tests/plugins/test_rtve.py
@@ -10,6 +10,7 @@ class TestPluginRtve(unittest.TestCase):
             'http://www.rtve.es/directo/la-2/',
             'http://www.rtve.es/directo/teledeporte/',
             'http://www.rtve.es/directo/canal-24h/',
+            'http://www.rtve.es/infantil/directo/',
         ]
         for url in should_match:
             self.assertTrue(Rtve.can_handle_url(url))
diff --git a/tests/plugins/test_tv360.py b/tests/plugins/test_tv360.py
index 306b62b4..0e87c18b 100644
--- a/tests/plugins/test_tv360.py
+++ b/tests/plugins/test_tv360.py
@@ -6,7 +6,7 @@ from streamlink.plugins.tv360 import TV360
 class TestPluginTV360(unittest.TestCase):
     def test_can_handle_url(self):
         should_match = [
-            'http://tv360.com.tr/CanliYayin',
+            'http://tv360.com.tr/canli-yayin',
         ]
         for url in should_match:
             self.assertTrue(TV360.can_handle_url(url))
diff --git a/tests/test_cli_playerout.py b/tests/test_cli_playerout.py
new file mode 100644
index 00000000..e2c2464a
--- /dev/null
+++ b/tests/test_cli_playerout.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+from streamlink.utils import get_filesystem_encoding
+from streamlink_cli.output import PlayerOutput
+from tests import posix_only, windows_only, py2_only, py3_only
+from tests.mock import patch, ANY
+
+UNICODE_TITLE = u"기타치는소율 with UL섬 "
+
+
+@posix_only
+@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', u"--title=" + UNICODE_TITLE, '-'],
+                             bufsize=ANY, stderr=ANY, stdout=ANY, stdin=ANY)
+
+
+@posix_only
+@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', u'--input-title-format', UNICODE_TITLE, '-'],
+                             bufsize=ANY, stderr=ANY, stdout=ANY, stdin=ANY)
+
+
+@py2_only
+@windows_only
+@patch('subprocess.Popen')
+def test_output_mpv_unicode_title_windows_py2(popen):
+    po = PlayerOutput("mpv.exe", title=UNICODE_TITLE)
+    popen().poll.side_effect = lambda: None
+    po.open()
+    popen.assert_called_with("mpv.exe \"--title=" + UNICODE_TITLE.encode(get_filesystem_encoding()) + "\" -",
+                             bufsize=ANY, stderr=ANY, stdout=ANY, stdin=ANY)
+
+
+@py2_only
+@windows_only
+@patch('subprocess.Popen')
+def test_output_vlc_unicode_title_windows_py2(popen):
+    po = PlayerOutput("vlc.exe", title=UNICODE_TITLE)
+    popen().poll.side_effect = lambda: None
+    po.open()
+    popen.assert_called_with("vlc.exe --input-title-format \"" + UNICODE_TITLE.encode(get_filesystem_encoding()) + "\" -",
+                             bufsize=ANY, stderr=ANY, stdout=ANY, stdin=ANY)
+
+
+@py3_only
+@windows_only
+@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
+    po.open()
+    popen.assert_called_with("mpv.exe \"--title=" + UNICODE_TITLE + "\" -",
+                             bufsize=ANY, stderr=ANY, stdout=ANY, stdin=ANY)
+
+
+@py3_only
+@windows_only
+@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("vlc.exe --input-title-format \"" + UNICODE_TITLE + "\" -",
+                             bufsize=ANY, stderr=ANY, stdout=ANY, stdin=ANY)
diff --git a/tests/test_cmdline_title.py b/tests/test_cmdline_title.py
index f00f47da..258c079c 100644
--- a/tests/test_cmdline_title.py
+++ b/tests/test_cmdline_title.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 import unittest
 
-from streamlink.compat import is_win32, is_py3
+from streamlink.compat import is_win32, is_py3, is_py2
 from streamlink.utils import get_filesystem_encoding
 from tests.test_cmdline import CommandLineTestCase
 
@@ -28,6 +28,10 @@ class TestCommandLineWithTitlePOSIX(CommandLineTestCase):
         self._test_args(["streamlink", "-p", "/usr/bin/mpv", "--title", "{title}", "http://test.se", "test"],
                         ["/usr/bin/mpv", "--title=Test Title", "-"])
 
+    def test_unicode_title_2444(self):
+        self._test_args(["streamlink", "-p", "mpv", "-t", "★", "http://test.se", "test"],
+                        ["mpv", u'--title=\u2605', "-"])
+
 
 @unittest.skipIf(not is_win32, "test only applicable on Windows")
 class TestCommandLineWithTitleWindows(CommandLineTestCase):
@@ -77,7 +81,7 @@ class TestCommandLineWithTitleWindows(CommandLineTestCase):
             passthrough=True
         )
 
-    @unittest.skipIf(is_py3, "Encoding is different in Python 2")
+    @unittest.skipUnless(is_py2, "Encoding is different in Python 2")
     def test_open_player_with_unicode_author_pot_py2(self):
         self._test_args(
             ["streamlink", "-p", "\"c:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\"",
@@ -88,7 +92,7 @@ class TestCommandLineWithTitleWindows(CommandLineTestCase):
             passthrough=True
         )
 
-    @unittest.skipIf(not is_py3, "Encoding is different in Python 2")
+    @unittest.skipUnless(is_py3, "Encoding is different in Python 3")
     def test_open_player_with_unicode_author_pot_py3(self):
         self._test_args(
             ["streamlink", "-p", "\"c:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\"",
@@ -106,3 +110,13 @@ class TestCommandLineWithTitleWindows(CommandLineTestCase):
             + "\"http://test.se/playlist.m3u8\\http://test.se/stream\"",
             passthrough=True
         )
+
+    @unittest.skipUnless(is_py2, "test only valid for Python 2")
+    def test_unicode_title_2444_py2(self):
+        self._test_args(["streamlink", "-p", "mpv", "-t", u"★".encode(get_filesystem_encoding()), "http://test.se", "test"],
+                        "mpv --title=" + u"★".encode(get_filesystem_encoding()) + " -")
+
+    @unittest.skipUnless(is_py3, "test only valid for Python 3")
+    def test_unicode_title_2444_py3(self):
+        self._test_args(["streamlink", "-p", "mpv", "-t", "★", "http://test.se", "test"],
+                        "mpv --title=★ -")