Repository: streamlink/streamlink
Pull request #2091 (2018-10-03 22:18:57)

docs: add appropriate funimation docs
I added documentation and notes for the Funimation plugin

**cli.rst**:
  - added authentication and `incap_ses` cookie information

**plugin_matrix.rst**:
  - added note about the required cookie

codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/2091?src=pr&el=h1) Report
> Merging [#2091](https://codecov.io/gh/streamlink/streamlink/pull/2091?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/6a2d263bbecd6f5bd0ee0bf546f67404876a6d01?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `n/a`.


```diff
@@           Coverage Diff           @@
##           master    #2091   +/-   ##
=======================================
  Coverage   35.91%   35.91%           
=======================================
  Files         230      230           
  Lines       20054    20054           
=======================================
  Hits         7202     7202           
  Misses      12852    12852
```

gravyboat: Looks great. @beardypig any feedback? My only notes would be do we need to title it as FunimationNow instead of just Funimation? Would this lead to confusion, and should we link to the docs directly from the note in the plugin matrix? If that happens we should probably do the same for crunchyroll.
vinnyA3: @bastimeyer  I could definitely see some confusion arising from the explicit cookie value, thanks for the review!  A link to #2091 would be a good addition as well.

@beardypig  I think FunimationNow would be best to avoid some confusion;  I'll make the updates!  As **bastimeyer** suggested above, I'll link to the issue for some further clarification.  If you would like, I could link to the funimation and crunchyroll docs in the plugin_matrix.

As for the digital ocean t-shirt:  I completely forgot about that!  That's really cool, I'm going to sign up :smile: 
beardypig: Good idea @vinnyA3, I didn't think to add documentation ;) Thanks!

Also, hope you get the hacktoberfest t-shirt... 
vinnyA3: Glad I could help a bit @beardypig :)  I hope to get the shirt too lol 

Patch:
diff --git a/docs/cli.rst b/docs/cli.rst
index 02c5ed31..0ebcadf0 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -239,6 +239,27 @@ For this, the plugin provides the :option:`--crunchyroll-purge-credentials`
 option, which removes your saved session and credentials and tries to log
 in again using your username and password.
 
+.. _cli-funimationnow:
+
+Authenticating with FunimationNow
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Like Crunchyroll, the FunimationNow plugin requires authenticating with a premium account to access some
+content: :option:`--funimation-email`, :option:`--funimation-password`. In addition, this plugin requires a ``incap_ses`` cookie to be
+sent with each HTTP request (see issue #2088); this unique session cookie can be found in your browser and sent via the :option:`--http-cookie` option.
+
+For example:
+
+.. sourcecode:: console
+
+    $ streamlink --funimation-email='xxx' --funimation-password='xxx' --http-cookie 'incap_ses_xxx=xxxx=' https://funimation.com/shows/show/an-episode-link
+
+.. note::
+
+    There are multiple ways to retrieve the required cookie.  For more
+    information on browser cookies, please consult the following:
+
+    - `What are cookies? <http://www.whatarecookies.com/view.asp>`_
+
 Sideloading plugins
 -------------------
 
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 9cb83e2b..5b6694bc 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -81,7 +81,7 @@ europaplus              europaplus.ru        Yes   --
 facebook                facebook.com         Yes   No    Only 360p HLS streams.
 filmon                  filmon.com           Yes   Yes   Only SD quality streams.
 foxtr                   fox.com.tr           Yes   No
-funimationnow           - funimation.com     --    Yes
+funimationnow           - funimation.com     --    Yes   :ref:`Requires session cookies <cli-funimationnow>`
                         - funimationnow.uk
 gardenersworld          gardenersworld.com   --    Yes
 garena                  garena.live          Yes   --
@@ -188,6 +188,7 @@ svtplay                 - svtplay.se         Yes   Yes   Streams may be geo-rest
                         - oppetarkiv.se
 swisstxt                - srf.ch             Yes   No    Streams are geo-restricted to Switzerland.
                         - rsi.ch
+tamago                  player.tamago.live   Yes   --
 teamliquid              teamliquid.net       Yes   Yes
 teleclubzoom            teleclubzoom.ch      Yes   No    Streams are geo-restricted to Switzerland.
 telefe                  telefe.com           No    Yes   Streams are geo-restricted to Argentina.
diff --git a/src/streamlink/plugins/tamago.py b/src/streamlink/plugins/tamago.py
new file mode 100644
index 00000000..0b6dc719
--- /dev/null
+++ b/src/streamlink/plugins/tamago.py
@@ -0,0 +1,52 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.stream import HTTPStream
+from streamlink import NoStreamsError
+
+
+class Tamago(Plugin):
+
+    _url_re = re.compile(r"https?://(?:player\.)?tamago\.live/w/(?P<id>\d+)")
+
+    _api_url_base = "https://player.tamago.live/api/rooms/{id}"
+
+    _api_response_schema = validate.Schema({
+        u"status": 200,
+        u"message": u"Success",
+        u"data": {
+            u"room_number": validate.text,
+            u"stream": {validate.text: validate.url()}
+        }
+    })
+
+    _stream_qualities = {
+        u"150": "144p",
+        u"350": "360p",
+        u"550": "540p",
+        u"900": "720p",
+    }
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def _get_streams(self):
+        user_id = self._url_re.match(self.url).group('id')
+
+        try:
+            api_response = self.session.http.get(self._api_url_base.format(id=user_id))
+            streams = self.session.http.json(api_response, schema=self._api_response_schema)['data']['stream']
+        except Exception:
+            raise NoStreamsError(self.url)
+
+        unique_stream_urls = []
+        for stream in streams.keys():
+            if streams[stream] not in unique_stream_urls:
+                unique_stream_urls.append(streams[stream])
+                quality = self._stream_qualities[stream] if stream in self._stream_qualities.keys() else "720p+"
+                yield quality, HTTPStream(self.session, streams[stream])
+
+
+__plugin__ = Tamago
diff --git a/src/streamlink_cli/utils/progress.py b/src/streamlink_cli/utils/progress.py
index dc7a0687..46dca155 100644
--- a/src/streamlink_cli/utils/progress.py
+++ b/src/streamlink_cli/utils/progress.py
@@ -13,22 +13,51 @@ PROGRESS_FORMATS = (
     "[download] {written}"
 )
 
+# widths generated from
+# http://www.unicode.org/Public/4.0-Update/EastAsianWidth-4.0.0.txt
 
-def terminal_len(value):
-    """Returns the length of the string it would be when displayed.
 
-    Attempts to decode the string as UTF-8 first if it's a bytestring.
-    """
+widths = [
+    (13, 1),    (15, 0),    (126, 1),   (159, 0),   (687, 1),   (710, 0),
+    (711, 1),   (727, 0),   (733, 1),   (879, 0),   (1154, 1),  (1161, 0),
+    (4347, 1),  (4447, 2),  (7467, 1),  (7521, 0),  (8369, 1),  (8426, 0),
+    (9000, 1),  (9002, 2),  (11021, 1), (12350, 2), (12351, 1), (12438, 2),
+    (12442, 0), (19893, 2), (19967, 1), (55203, 2), (63743, 1), (64106, 2),
+    (65039, 1), (65059, 0), (65131, 2), (65279, 1), (65376, 2), (65500, 1),
+    (65510, 2), (120831, 1), (262141, 2), (1114109, 1)
+]
+
+
+def get_width(o):
+    """Returns the screen column width for unicode ordinal."""
+    for num, wid in widths:
+        if o <= num:
+            return wid
+    return 1
+
+
+def terminal_width(value):
+    """Returns the width of the string it would be when displayed."""
     if isinstance(value, bytes):
         value = value.decode("utf8", "ignore")
+    return sum(map(get_width, map(ord, value)))
+
 
-    return len(value)
+def get_cut_prefix(value, max_len):
+    """Drops Characters by unicode not by bytes."""
+    should_convert = isinstance(value, bytes)
+    if should_convert:
+        value = value.decode("utf8", "ignore")
+    for i in range(len(value)):
+        if terminal_width(value[i:]) <= max_len:
+            break
+    return value[i:].encode("utf8", "ignore") if should_convert else value[i:]
 
 
 def print_inplace(msg):
     """Clears out the previous line and prints a new one."""
     term_width = get_terminal_size().columns
-    spacing = term_width - terminal_len(msg)
+    spacing = term_width - terminal_width(msg)
 
     # On windows we need one less space or we overflow the line for some reason.
     if is_win32:
@@ -89,7 +118,8 @@ def progress(iterator, prefix):
      - Time elapsed
      - Average speed, based on the last few seconds.
     """
-    prefix = (".." + prefix[-23:]) if len(prefix) > 25 else prefix
+    if terminal_width(prefix) > 25:
+        prefix = (".." + get_cut_prefix(prefix, 23))
     speed_updated = start = time()
     speed_written = written = 0
     speed_history = deque(maxlen=5)


Test patch:
diff --git a/tests/plugins/test_tamago.py b/tests/plugins/test_tamago.py
new file mode 100644
index 00000000..06afc7db
--- /dev/null
+++ b/tests/plugins/test_tamago.py
@@ -0,0 +1,24 @@
+import unittest
+
+from streamlink.plugins.tamago import Tamago
+
+
+class TestPluginTamago(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'https://player.tamago.live/w/2009642',
+            'https://player.tamago.live/w/1882066',
+            'https://player.tamago.live/w/1870142',
+            'https://player.tamago.live/w/1729968',
+        ]
+        for url in should_match:
+            self.assertTrue(Tamago.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'https://download.tamago.live/faq',
+            'https://player.tamago.live/gaming/pubg',
+            'https://www.twitch.tv/twitch'
+        ]
+        for url in should_not_match:
+            self.assertFalse(Tamago.can_handle_url(url))
diff --git a/tests/test_cli_util_progress.py b/tests/test_cli_util_progress.py
new file mode 100644
index 00000000..77afe5c2
--- /dev/null
+++ b/tests/test_cli_util_progress.py
@@ -0,0 +1,20 @@
+# coding: utf-8
+from streamlink_cli.utils.progress import terminal_width, get_cut_prefix
+import unittest
+
+
+class TestCliUtilProgess(unittest.TestCase):
+    def test_terminal_width(self):
+        self.assertEqual(10, terminal_width("ABCDEFGHIJ"))
+        self.assertEqual(30, terminal_width("A你好世界こんにちは안녕하세요B"))
+        self.assertEqual(30, terminal_width("·「」『』【】-=！@#￥%……&×（）"))
+        pass
+
+    def test_get_cut_prefix(self):
+        self.assertEqual("녕하세요CD",
+                         get_cut_prefix("你好世界こんにちは안녕하세요CD", 10))
+        self.assertEqual("하세요CD",
+                         get_cut_prefix("你好世界こんにちは안녕하세요CD", 9))
+        self.assertEqual("こんにちは안녕하세요CD",
+                         get_cut_prefix("你好世界こんにちは안녕하세요CD", 23))
+        pass