Repository: streamlink/streamlink

Issue:
Plugin request: https://garena.live
### Checklist

- [ ] This is a bug report.
- [x] This is a plugin request.
- [ ] This is a feature request.
- [ ] I used the search function to find already opened/closed issues or pull requests.

### Description

Ability to grab livestreams from https://garena.live



Discussion:
Could you provide some more details regarding what requests look like when pulling a live stream from this site?
streamlink.exe https://garena.live/xxx best
```
https://garena.live/391550

API_URL = https://garena.live/api/channel_stream_get
data = {"channel_id": 391550}

res = {
	"reply": {
		"streams": [{
			"url": "https://twdatagarenatv-a.akamaihd.net/hls/162142/391550.m3u8",
			"bitrate": 2000,
			"resolution": 720,
			"format": 3
		}, {
			"url": "rtmp://124.108.162.142/live/391550",
			"bitrate": 2000,
			"resolution": 720,
			"format": 1
		}]
	},
	"result": "success"
}
```

Resolving patch:
diff --git a/src/streamlink/plugins/garena.py b/src/streamlink/plugins/garena.py
new file mode 100644
--- /dev/null
+++ b/src/streamlink/plugins/garena.py
@@ -0,0 +1,69 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+
+_url_re = re.compile(r"https?\:\/\/garena\.live\/(?:(?P<channel_id>\d+)|(?P<alias>\w+))")
+
+
+class Garena(Plugin):
+    API_INFO = "https://garena.live/api/channel_info_get"
+    API_STREAM = "https://garena.live/api/channel_stream_get"
+
+    _info_schema = validate.Schema(
+        {
+            "reply": validate.any({
+                "channel_id": int,
+            }, None),
+            "result": validate.text
+        }
+    )
+    _stream_schema = validate.Schema(
+        {
+            "reply": validate.any({
+                "streams": [
+                    {
+                        "url": validate.text,
+                        "resolution": int,
+                        "bitrate": int,
+                        "format": int
+                    }
+                ]
+            }, None),
+            "result": validate.text
+        }
+    )
+
+    @classmethod
+    def can_handle_url(self, url):
+        return _url_re.match(url)
+
+    def _post_api(self, api, payload, schema):
+        res = http.post(api, json=payload)
+        data = http.json(res, schema=schema)
+
+        if data["result"] == "success":
+            post_data = data["reply"]
+            return post_data
+
+    def _get_streams(self):
+        match = _url_re.match(self.url)
+        if match.group("alias"):
+            payload = {"alias": match.group("alias")}
+            info_data = self._post_api(self.API_INFO, payload, self._info_schema)
+            channel_id = info_data["channel_id"]
+        elif match.group("channel_id"):
+            channel_id = int(match.group("channel_id"))
+
+        if channel_id:
+            payload = {"channel_id": channel_id}
+            stream_data = self._post_api(self.API_STREAM, payload, self._stream_schema)
+            for stream in stream_data["streams"]:
+                n = "{0}p".format(stream["resolution"])
+                if stream["format"] == 3:
+                    s = HLSStream(self.session, stream["url"])
+                    yield n, s
+
+__plugin__ = Garena


Test patch:
diff --git a/tests/test_plugin_garena.py b/tests/test_plugin_garena.py
new file mode 100644
--- /dev/null
+++ b/tests/test_plugin_garena.py
@@ -0,0 +1,86 @@
+import json
+import unittest
+
+from streamlink import Streamlink
+
+try:
+    from unittest.mock import patch, Mock
+except ImportError:
+    from mock import patch, Mock
+
+from streamlink.plugins.garena import Garena
+
+
+class TestPluginGarena(unittest.TestCase):
+    def setUp(self):
+        self.session = Streamlink()
+
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(Garena.can_handle_url("https://garena.live/LOLTW"))
+        self.assertTrue(Garena.can_handle_url("https://garena.live/358220"))
+
+        # shouldn't match
+        self.assertFalse(Garena.can_handle_url("http://local.local/"))
+        self.assertFalse(Garena.can_handle_url("http://localhost.localhost/"))
+
+    @patch('streamlink.plugins.garena.http')
+    def test_post_api_info(self, mock_http):
+        API_INFO = Garena.API_INFO
+        schema = Garena._info_schema
+
+        api_data = {
+            "reply": {
+                "channel_id": 358220,
+            },
+            "result": "success"
+        }
+
+        api_resp = Mock()
+        api_resp.text = json.dumps(api_data)
+        mock_http.post.return_value = api_resp
+        mock_http.json.return_value = api_data
+
+        payload = {"alias": "LOLTW"}
+
+        plugin = Garena("https://garena.live/LOLTW")
+
+        info_data = plugin._post_api(API_INFO, payload, schema)
+
+        self.assertEqual(info_data["channel_id"], 358220)
+
+        mock_http.post.assert_called_with(API_INFO, json=dict(alias="LOLTW"))
+
+    @patch('streamlink.plugins.garena.http')
+    def test_post_api_stream(self, mock_http):
+        API_STREAM = Garena.API_STREAM
+        schema = Garena._stream_schema
+
+        api_data = {
+            "reply": {
+                "streams": [
+                    {
+                        "url": "https://test.se/stream1",
+                        "bitrate": 0,
+                        "resolution": 1080,
+                        "format": 3
+                    },
+                ]
+            },
+            "result": "success"
+        }
+
+        api_resp = Mock()
+        api_resp.text = json.dumps(api_data)
+        mock_http.post.return_value = api_resp
+        mock_http.json.return_value = api_data
+
+        payload = {"channel_id": 358220}
+
+        plugin = Garena("https://garena.live/358220")
+
+        stream_data = plugin._post_api(API_STREAM, payload, schema)
+
+        self.assertEqual(stream_data["streams"], api_data["reply"]["streams"])
+
+        mock_http.post.assert_called_with(API_STREAM, json=dict(channel_id=358220))