Repository: streamlink/streamlink

Issue:
player.tamago.live Plugin
<!--
Thanks for requesting a plugin!
USE THE TEMPLATE. Otherwise your plugin request may be rejected.

First, see the contribution guidelines and plugin request requirements:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

Plugin requests which fall into the categories we will not implement will be closed immediately.

Also check the list of open and closed plugin requests:
https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+request%22

Please see the text preview to avoid unnecessary formatting errors.
-->


## Plugin Request

<!-- Replace [ ] with [x] in order to check the box -->
- [x] This is a plugin request and I have read the contribution guidelines and plugin request requirements.


### Description

https://player.tamago.live

**Tamago** is a live streaming social network that lets you watch or broadcast live video anytime, anywhere. Like *Twitch*, streamers can interact with their viewers in a chat box. Get exclusive content, from your favourite TV shows and concerts, to the hottest e-sports leagues and more, get front row seats or go behind the scenes with Tamago.  

Showcase your talent to a wide audience of viewers and fellow broadcasters. Live video streaming is spontaneous and does not have to be perfect, so anyone can be a broadcaster! You can get rewarded for broadcasting too! Collect gifts during your live streams and keep the profits when you cash out. 

https://download.tamago.live/faq

### Example stream URLs

<!-- Example URLs for streams are required. Plugin requests which do not have example URLs will be closed. -->

1. https://player.tamago.live/w/2009642
2. https://player.tamago.live/w/1882066
3. https://player.tamago.live/w/1870142
4. https://player.tamago.live/w/1729968
5. ...

### Additional comments, screenshots, etc.



[Love Streamlink? Please consider supporting our collective. Thanks!](https://opencollective.com/streamlink/donate)


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/2108?src=pr&el=h1) Report
> Merging [#2108](https://codecov.io/gh/streamlink/streamlink/pull/2108?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/1e624b19b15a983d2dd3a82be89df76bb96beab9?src=pr&el=desc) will **increase** coverage by `0.01%`.
> The diff coverage is `62.5%`.


```diff
@@            Coverage Diff             @@
##           master    #2108      +/-   ##
==========================================
+ Coverage   35.91%   35.93%   +0.01%     
==========================================
  Files         230      231       +1     
  Lines       20057    20090      +33     
==========================================
+ Hits         7204     7219      +15     
- Misses      12853    12871      +18
```

beardypig: Thanks for taking the time to submit a plugin. This looks fine to me, but I didn't have time to test it out yet. 

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 9cb83e2b..c34c9516 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -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