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


Comments:

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 084239b3..4693f248 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -92,6 +92,7 @@ foxtr               fox.com.tr           Yes   No
 funimationnow       - funimation.com     --    Yes
                     - funimationnow.uk
 furstream           furstre.am           Yes   No
+garena              garena.live          Yes   --
 gomexp              gomexp.com           Yes   No
 goodgame            goodgame.ru          Yes   No    Only HLS streams are available.
 gulli               replay.gulli.fr      Yes   Yes   Streams may be geo-restricted to France.
diff --git a/src/streamlink/plugins/garena.py b/src/streamlink/plugins/garena.py
new file mode 100644
index 00000000..8349ebc7
--- /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
diff --git a/src/streamlink/utils/l10n.py b/src/streamlink/utils/l10n.py
index 1791446b..461fbecc 100644
--- a/src/streamlink/utils/l10n.py
+++ b/src/streamlink/utils/l10n.py
@@ -12,7 +12,9 @@ except ImportError:  # pragma: no cover
 
     PYCOUNTRY = True
 
-DEFAULT_LANGUAGE_CODE = "en_US"
+DEFAULT_LANGUAGE = "en"
+DEFAULT_COUNTRY = "US"
+DEFAULT_LANGUAGE_CODE = "{0}_{1}".format(DEFAULT_LANGUAGE, DEFAULT_COUNTRY)
 
 
 class Country(object):
@@ -116,8 +118,15 @@ class Localization(object):
     def language_code(self):
         return self._language_code
 
+    def _parse_locale_code(self, language_code):
+        parts = language_code.split("_", 1)
+        if len(parts) != 2 or len(parts[0]) != 2 or len(parts[1]) != 2:
+                raise LookupError("Invalid language code: {0}".format(language_code))
+        return self.get_language(parts[0]), self.get_country(parts[1])
+
     @language_code.setter
     def language_code(self, language_code):
+        is_system_locale = language_code is None
         if language_code is None:
             try:
                 language_code, _ = locale.getdefaultlocale()
@@ -125,16 +134,19 @@ class Localization(object):
                 language_code = None
             if language_code is None or language_code == "C":
                 # cannot be determined
-                language_code = DEFAULT_LANGUAGE_CODE
-
-        parts = language_code.split("_", 1)
+                language_code = DEFAULT_LANGUAGE
 
-        if len(parts) != 2 or len(parts[0]) != 2 or len(parts[1]) != 2:
-            raise LookupError("Invalid language code: {0}".format(language_code))
-
-        self._language_code = language_code
-        self.language = self.get_language(parts[0])
-        self.country = self.get_country(parts[1])
+        try:
+            self.language, self.country = self._parse_locale_code(language_code)
+            self._language_code = language_code
+        except LookupError:
+            if is_system_locale:
+                # If the system locale returns an invalid code, use the default
+                self.language = self.get_language(DEFAULT_LANGUAGE)
+                self.country = self.get_country(DEFAULT_COUNTRY)
+                self._language_code = DEFAULT_LANGUAGE_CODE
+            else:
+                raise
 
     def equivalent(self, language=None, country=None):
         equivalent = True


Test patch:
diff --git a/tests/test_localization.py b/tests/test_localization.py
index 102a6472..4a17da9c 100644
--- a/tests/test_localization.py
+++ b/tests/test_localization.py
@@ -63,6 +63,13 @@ class LocalizationTestsMixin(object):
         self.assertEqual("en_US", l.language_code)
         self.assertTrue(l.equivalent(language="en", country="US"))
 
+    @patch("locale.getdefaultlocale")
+    def test_default_invalid(self, getdefaultlocale):
+        getdefaultlocale.return_value = ("en_150", None)
+        l = l10n.Localization()
+        self.assertEqual("en_US", l.language_code)
+        self.assertTrue(l.equivalent(language="en", country="US"))
+
     def test_get_country(self):
         self.assertEqual("US",
                          l10n.Localization.get_country("USA").alpha2)
diff --git a/tests/test_plugin_garena.py b/tests/test_plugin_garena.py
new file mode 100644
index 00000000..05b82d2a
--- /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))