Repository: streamlink/streamlink
Pull request #2304 (2019-02-05 12:09:16)

plugins.crunchyroll: Allow CR's multilingual URLs to be handled
Multilingual URLs shoud be allowed to be handled by the crunchyroll plugin.

Using [crunchyroll's rss](https://www.crunchyroll.com/rss), I've seen that the `<link></link>` given is formatted depending your localization, probably IP based. Since I am currently living in France I always get the /fr language path

```python
import feedparser
url = 'https://www.crunchyroll.com/rss'
f = feedparser.parse(url)
print(f['entries'][0]['link'])
```
```
# gives:
http://www.crunchyroll.com/fr/shonen-ashibe-go-go-goma-chan/episode-94-graduating-from-goma-chan-780795
```


Patch:
diff --git a/src/streamlink/plugins/bbciplayer.py b/src/streamlink/plugins/bbciplayer.py
index 5e5d0f28..dec5978d 100644
--- a/src/streamlink/plugins/bbciplayer.py
+++ b/src/streamlink/plugins/bbciplayer.py
@@ -187,13 +187,20 @@ class BBCiPlayer(Plugin):
         :return: Whether authentication was successful
         :rtype: bool
         """
+        def auth_check(res):
+            return ptrt_url in ([h.url for h in res.history] + [res.url])
+
+        # make the session request to get the correct cookies
         session_res = self.session.http.get(
             self.session_url,
             params=dict(ptrt=ptrt_url)
         )
 
-        http_nonce = self._extract_nonce(session_res)
+        if auth_check(session_res):
+            log.debug("Already authenticated, skipping authentication")
+            return True
 
+        http_nonce = self._extract_nonce(session_res)
         res = self.session.http.post(
             self.auth_url,
             params=dict(
@@ -208,7 +215,7 @@ class BBCiPlayer(Plugin):
             ),
             headers={"Referer": self.url})
 
-        return len(res.history) != 0
+        return auth_check(res)
 
     def _get_streams(self):
         if not self.get_option("username"):
diff --git a/src/streamlink/plugins/crunchyroll.py b/src/streamlink/plugins/crunchyroll.py
index b3498c27..a1d649da 100644
--- a/src/streamlink/plugins/crunchyroll.py
+++ b/src/streamlink/plugins/crunchyroll.py
@@ -38,6 +38,9 @@ _url_re = re.compile(r"""
     (?:
         com|de|es|fr|co.jp
     )
+    (?:
+        /(en-gb|es|es-es|pt-pt|pt-br|fr|de|ar|it|ru)
+    )?
     (?:/[^/&?]+)?
     /[^/&?]+-(?P<media_id>\d+)
 """, re.VERBOSE)
diff --git a/src/streamlink/plugins/rtve.py b/src/streamlink/plugins/rtve.py
index d4fcb6ac..1c26ca3d 100644
--- a/src/streamlink/plugins/rtve.py
+++ b/src/streamlink/plugins/rtve.py
@@ -1,4 +1,5 @@
 import base64
+import logging
 import re
 from functools import partial
 
@@ -8,11 +9,14 @@ from streamlink.compat import bytes, is_py3
 from streamlink.plugin import Plugin, PluginArguments, PluginArgument
 from streamlink.plugin.api import useragents
 from streamlink.plugin.api import validate
+from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream
 from streamlink.stream import HTTPStream
 from streamlink.stream.ffmpegmux import MuxedStream
 from streamlink.utils import parse_xml
 
+log = logging.getLogger(__name__)
+
 
 class ZTNRClient(object):
     base_url = "http://ztnr.rtve.es/ztnr/res/"
@@ -54,7 +58,6 @@ class ZTNRClient(object):
 
 class Rtve(Plugin):
     secret_key = base64.b64decode("eWVMJmRhRDM=")
-    content_id_re = re.compile(r'data-id\s*=\s*"(\d+)"')
     url_re = re.compile(r"""
         https?://(?:www\.)?rtve\.es/(?:directo|noticias|television|deportes|alacarta|drmn)/.*?/?
     """, re.VERBOSE)
@@ -118,8 +121,11 @@ class Rtve(Plugin):
 
     def _get_content_id(self):
         res = self.session.http.get(self.url)
-        m = self.content_id_re.search(res.text)
-        return m and int(m.group(1))
+        for div in itertags(res.text, "div"):
+            if div.attributes.get("data-id"):
+                return int(div.attributes.get("data-id"))
+        else:
+            log.error("Failed to get content_id")
 
     def _get_subtitles(self, content_id):
         res = self.session.http.get(self.subtitles_api.format(id=content_id))
@@ -138,7 +144,7 @@ class Rtve(Plugin):
         streams = []
         content_id = self._get_content_id()
         if content_id:
-            self.logger.debug("Found content with id: {0}", content_id)
+            log.debug("Found content with id: {0}", content_id)
             stream_data = self.zclient.get_cdn_list(content_id, schema=self.cdn_schema)
             quality_map = None
 
@@ -147,8 +153,8 @@ class Rtve(Plugin):
                     if ".m3u8" in url:
                         try:
                             streams.extend(HLSStream.parse_variant_playlist(self.session, url).items())
-                        except (IOError, OSError):
-                            self.logger.debug("Failed to load m3u8 url: {0}", url)
+                        except (IOError, OSError) as err:
+                            log.error(str(err))
                     elif ((url.endswith("mp4") or url.endswith("mov") or url.endswith("avi"))
                           and self.session.http.head(url, raise_for_status=False).status_code == 200):
                         if quality_map is None:  # only make the request when it is necessary
diff --git a/src/streamlink/plugins/ustreamtv.py b/src/streamlink/plugins/ustreamtv.py
index 31fa7138..d025ef1d 100644
--- a/src/streamlink/plugins/ustreamtv.py
+++ b/src/streamlink/plugins/ustreamtv.py
@@ -83,7 +83,7 @@ class UHSClient(object):
         log.debug("Connecting to {0}".format(self.host))
         self._ws = websocket.create_connection(self.host,
                                                header=["User-Agent: {0}".format(useragents.CHROME)],
-                                               origin="http://www.ustream.tv")
+                                               origin="https://www.ustream.tv")
 
         args = dict(type="viewer",
                     appId=self._app_id,
@@ -269,9 +269,11 @@ class UHSStream(Stream):
             while not self.stopped.wait(0):
                 try:
                     cmd_args = self.api.recv()
-                except SocketError as err:
+                except (SocketError,
+                        websocket._exceptions.WebSocketConnectionClosedException) as err:
                     cmd_args = None
-                    if err.errno in (errno.ECONNRESET, errno.ETIMEDOUT):
+                    if (hasattr(err, "errno") and err.errno in (errno.ECONNRESET, errno.ETIMEDOUT)
+                            or "Connection is already closed." in str(err)):
                         while True:
                             # --stream-timeout will handle the timeout
                             try:
@@ -285,6 +287,8 @@ class UHSStream(Stream):
                                 sleep(reconnect_time_ws)
                     else:
                         raise
+                except PluginError:
+                    continue
 
                 if not cmd_args:
                     continue
@@ -390,7 +394,7 @@ class UStreamTV(Plugin):
                     res["streams"] = []
                     for stream in flv_segmented["streams"]:
                         res["streams"] += [dict(
-                            stream_name=stream["preset"],
+                            stream_name="{0}p".format(stream["videoCodec"]["height"]),
                             path=urljoin(path,
                                          stream["segmentUrl"].replace("%", "%s")),
                             hashes=flv_segmented["hashes"],
@@ -434,7 +438,6 @@ class UStreamTV(Plugin):
             api.connect()
 
             streams_data = {}
-            streams = {}
             for _ in range(5):
                 # do not use to many tries, it might take longer for a timeout
                 # when streamFormats is {} and contentAvailable is True
@@ -450,11 +453,12 @@ class UStreamTV(Plugin):
                         log.debug("Unexpected `{0}` command".format(data["cmd"]))
                         log.trace("{0!r}".format(data))
                 except ModuleInfoNoStreams:
-                    return None
+                    break
 
                 if streams_data.get("streams") and streams_data.get("cdn_url"):
-                    for s in streams_data["streams"]:
-                        streams[s["stream_name"]] = UHSStream(
+                    for s in sorted(streams_data["streams"],
+                                    key=lambda k: (k["stream_name"], k["path"])):
+                        yield s["stream_name"], UHSStream(
                             session=self.session,
                             api=api,
                             first_chunk_data=ChunkData(
@@ -465,7 +469,7 @@ class UStreamTV(Plugin):
                             template_url=urljoin(streams_data["cdn_url"],
                                                  s["path"]),
                         )
-                    return streams
+                    break
 
     def _get_media_app(self):
         umatch = self.url_re.match(self.url)
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 019a55cb..3958a085 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -465,6 +465,9 @@ def build_parser():
             gaming oriented platforms. "Game being played" is a way to categorize
             the stream, so it doesn't need its own separate handling.
 
+        {{url}}
+            URL of the stream.
+
         Examples:
 
             %(prog)s -p vlc --title "{{title}} -!- {{author}} -!- {{category}} \\$A" <url> [stream]
diff --git a/src/streamlink_cli/constants.py b/src/streamlink_cli/constants.py
index 45eb7894..d0407a78 100644
--- a/src/streamlink_cli/constants.py
+++ b/src/streamlink_cli/constants.py
@@ -13,7 +13,8 @@ DEFAULT_STREAM_METADATA = {
 SUPPORTED_PLAYERS = {  # these are the players that streamlink knows how to set the window title for with `--title`. key names are used in help text
     # name: possible binary names (linux/mac and windows)
     "vlc": ["vlc", "vlc.exe"],
-    "mpv": ["mpv", "mpv.exe"]
+    "mpv": ["mpv", "mpv.exe"],
+    "potplayer": ["potplayer", "potplayermini64.exe", "potplayermini.exe"]
 }
 
 if is_win32:
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 992c2119..9acb1094 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -152,7 +152,8 @@ def create_title(plugin=None):
             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"],
-            game=lambda: plugin.get_category() or DEFAULT_STREAM_METADATA["game"]
+            game=lambda: plugin.get_category() or DEFAULT_STREAM_METADATA["game"],
+            url=plugin.url
         )
     else:
         title = args.url
diff --git a/src/streamlink_cli/output.py b/src/streamlink_cli/output.py
index e8fd698d..e04471be 100644
--- a/src/streamlink_cli/output.py
+++ b/src/streamlink_cli/output.py
@@ -177,8 +177,6 @@ class PlayerOutput(Output):
             filename = self.http.url
         else:
             filename = "-"
-        args = self.args.format(filename=filename)
-        cmd = self.cmd
         extra_args = []
 
         if self.title is not None:
@@ -194,6 +192,17 @@ class PlayerOutput(Output):
                 self.title = self._mpv_title_escape(self.title)
                 extra_args.extend(["--title", self.title])
 
+            # potplayer
+            if self.player_name == "potplayer":
+                if filename != "-":
+                    # PotPlayer - About - Command Line
+                    # You can specify titles for URLs by separating them with a backslash (\) at the end of URLs. ("http://...\title of this url")
+                    self.title = self.title.replace('"', '')
+                    filename = filename[:-1] + '\\' + self.title + filename[-1]
+
+        args = self.args.format(filename=filename)
+        cmd = self.cmd
+        
         # player command
         if is_win32:
             eargs = maybe_decode(subprocess.list2cmdline(extra_args))


Test patch:
diff --git a/tests/plugins/test_bbciplayer.py b/tests/plugins/test_bbciplayer.py
index cef746fe..381307de 100644
--- a/tests/plugins/test_bbciplayer.py
+++ b/tests/plugins/test_bbciplayer.py
@@ -1,5 +1,4 @@
 import json
-import logging
 import unittest
 
 from requests import Response, Request
diff --git a/tests/plugins/test_crunchyroll.py b/tests/plugins/test_crunchyroll.py
index edcc4736..1a4e0659 100644
--- a/tests/plugins/test_crunchyroll.py
+++ b/tests/plugins/test_crunchyroll.py
@@ -7,9 +7,13 @@ class TestPluginCrunchyroll(unittest.TestCase):
     def test_can_handle_url(self):
         # should match
         self.assertTrue(Crunchyroll.can_handle_url("http://www.crunchyroll.com/idol-incidents/episode-1-why-become-a-dietwoman-728233"))
+        self.assertTrue(Crunchyroll.can_handle_url("http://www.crunchyroll.com/ru/idol-incidents/episode-1-why-become-a-dietwoman-728233"))
         self.assertTrue(Crunchyroll.can_handle_url("http://www.crunchyroll.com/idol-incidents/media-728233"))
+        self.assertTrue(Crunchyroll.can_handle_url("http://www.crunchyroll.com/fr/idol-incidents/media-728233"))
         self.assertTrue(Crunchyroll.can_handle_url("http://www.crunchyroll.com/media-728233"))
+        self.assertTrue(Crunchyroll.can_handle_url("http://www.crunchyroll.com/de/media-728233"))
         self.assertTrue(Crunchyroll.can_handle_url("http://www.crunchyroll.fr/media-728233"))
+        self.assertTrue(Crunchyroll.can_handle_url("http://www.crunchyroll.fr/es/media-728233"))
 
         # shouldn't match
         self.assertFalse(Crunchyroll.can_handle_url("http://www.crunchyroll.com/gintama"))
diff --git a/tests/test_cmdline_title.py b/tests/test_cmdline_title.py
index a05a1293..2c6a9fac 100644
--- a/tests/test_cmdline_title.py
+++ b/tests/test_cmdline_title.py
@@ -52,3 +52,30 @@ class TestCommandLineWithTitleWindows(CommandLineTestCase):
     def test_open_player_with_default_arg_vlc(self):
         self._test_args(["streamlink", "-p", "c:\\Program Files\\VideoLAN\\vlc.exe --argh", "http://test.se", "test"],
                         "c:\\Program Files\\VideoLAN\\vlc.exe --argh --input-title-format http://test.se -")
+
+    # PotPlayer
+    def test_open_player_with_title_pot(self):
+        self._test_args(["streamlink", "-p", "\"c:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\"", 
+                        "--title", "{title}", "http://test.se/stream", "hls", "--player-passthrough", "hls"],
+                        "\"c:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\" \"http://test.se/playlist.m3u8\\Test Title\"", 
+                        passthrough=True)
+
+    @unittest.skipIf(is_py3, "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\"", 
+                        "--title", "{author}", "http://test.se/stream", "hls", "--player-passthrough", "hls"],
+                        "\"c:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\" \"http://test.se/playlist.m3u8\\" + u"Tѥst Āuƭhǿr".encode(get_filesystem_encoding()) + "\"", 
+                        passthrough=True)
+
+    @unittest.skipIf(not is_py3, "Encoding is different in Python 2")
+    def test_open_player_with_unicode_author_pot_py3(self):
+        self._test_args(["streamlink", "-p", "\"c:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\"", 
+                        "--title", "{author}", "http://test.se/stream", "hls", "--player-passthrough", "hls"],
+                        u"\"c:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\" \"http://test.se/playlist.m3u8\\Tѥst Āuƭhǿr\"", 
+                        passthrough=True)
+
+    def test_open_player_with_default_title_pot(self):
+        self._test_args(["streamlink", "-p", "\"c:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\"", 
+                        "http://test.se/stream", "hls", "--player-passthrough", "hls"],
+                        "\"c:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\" \"http://test.se/playlist.m3u8\\http://test.se/stream\"", 
+                        passthrough=True)
diff --git a/tests/test_lazy_formatter.py b/tests/test_lazy_formatter.py
index 259bc2bf..f41c8ab2 100644
--- a/tests/test_lazy_formatter.py
+++ b/tests/test_lazy_formatter.py
@@ -10,6 +10,7 @@ class TestLazyFormat(unittest.TestCase):
         plugin.get_title.return_value = "title"
         plugin.get_author.return_value = "author"
         plugin.get_game.return_value = "game"
+        plugin.url = "url"
 
         return plugin
 
@@ -19,7 +20,8 @@ class TestLazyFormat(unittest.TestCase):
         res = LazyFormatter.format("{title}",
                                    title=plugin.get_title,
                                    author=plugin.get_author,
-                                   game=plugin.get_game)
+                                   game=plugin.get_game,
+                                   url=plugin.url)
 
         self.assertEqual("title", res)
 
@@ -33,7 +35,8 @@ class TestLazyFormat(unittest.TestCase):
         res = LazyFormatter.format("{title} - {author}",
                                    title=plugin.get_title,
                                    author=plugin.get_author,
-                                   game=plugin.get_game)
+                                   game=plugin.get_game,
+                                   url=plugin.url)
 
         self.assertEqual("title - author", res)
 
@@ -48,7 +51,8 @@ class TestLazyFormat(unittest.TestCase):
         res = LazyFormatter.format("{title} - {author} - {game}",
                                    title=plugin.get_title,
                                    author=plugin.get_author,
-                                   game=plugin.get_game)
+                                   game=plugin.get_game,
+                                   url=plugin.url)
 
         self.assertEqual("title - author - game", res)
 
@@ -56,6 +60,21 @@ class TestLazyFormat(unittest.TestCase):
         plugin.get_author.assert_called()
         plugin.get_game.assert_called()
 
+    def test_format_lazy_title_author_game_url(self):
+        plugin = self._get_fake_plugin()
+
+        res = LazyFormatter.format("{title} - {author} - {game} - {url}",
+                                   title=plugin.get_title,
+                                   author=plugin.get_author,
+                                   game=plugin.get_game,
+                                   url=plugin.url)
+
+        self.assertEqual("title - author - game - url", res)
+
+        plugin.get_title.assert_called()
+        plugin.get_author.assert_called()
+        plugin.get_game.assert_called()
+
     def test_format_lazy_title_not_callable(self):
         res = LazyFormatter.format("{title}",
                                    title="title")
diff --git a/tests/test_output.py b/tests/test_output.py
index e245ef4a..3a0974a9 100644
--- a/tests/test_output.py
+++ b/tests/test_output.py
@@ -14,12 +14,17 @@ class TestPlayerOutput(unittest.TestCase):
         self.assertEqual("mpv",
                          PlayerOutput.supported_player("mpv"))
 
+        self.assertEqual("potplayer",
+                         PlayerOutput.supported_player("potplayermini.exe"))
+
     @patch("streamlink_cli.output.os.path.basename", new=ntpath.basename)
     def test_supported_player_win32(self):
         self.assertEqual("mpv",
                          PlayerOutput.supported_player("C:\\MPV\\mpv.exe"))
         self.assertEqual("vlc",
                          PlayerOutput.supported_player("C:\\VLC\\vlc.exe"))
+        self.assertEqual("potplayer",
+                         PlayerOutput.supported_player("C:\\PotPlayer\\PotPlayerMini64.exe"))
 
     @patch("streamlink_cli.output.os.path.basename", new=posixpath.basename)
     def test_supported_player_posix(self):
@@ -34,6 +39,8 @@ class TestPlayerOutput(unittest.TestCase):
                          PlayerOutput.supported_player("C:\\MPV\\mpv.exe --argh"))
         self.assertEqual("vlc",
                          PlayerOutput.supported_player("C:\\VLC\\vlc.exe --argh"))
+        self.assertEqual("potplayer",
+                         PlayerOutput.supported_player("C:\\PotPlayer\\PotPlayerMini64.exe --argh"))
 
     @patch("streamlink_cli.output.os.path.basename", new=posixpath.basename)
     def test_supported_player_args_posix(self):
@@ -55,6 +62,8 @@ class TestPlayerOutput(unittest.TestCase):
                          PlayerOutput.supported_player("C:\\mpc\\mpc-hd.exe"))
         self.assertEqual(None,
                          PlayerOutput.supported_player("C:\\mplayer\\not-vlc.exe"))
+        self.assertEqual(None,
+                         PlayerOutput.supported_player("C:\\NotPlayer\\NotPlayerMini64.exe"))
 
     def test_open_player_with_title_mpv_escape_1(self):
         self.assertEqual(PlayerOutput._mpv_title_escape("no escape $$ codes $"),