Repository: streamlink/streamlink

Issue:
Booyah.live
<!--
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 the space character between the square brackets with an x in order to check the boxes -->
- [x] This is a plugin request and I have read the contribution guidelines and plugin request requirements.
- [x] I am using the latest development version from the master branch.


### Description

<!-- Explain the plugin and site as clearly as you can. What is the site about? Who runs it? What content does it provide? What value does it bring to Streamlink? Etc. -->
Booyah.live is a gaming streaming platfmorm, just like Twitch. It also hosts the official broadcast of the Loading TV channel online.

### Example stream URLs

<!-- Example URLs for streams are required. Plugin requests which do not have example URLs will be closed. -->

1. https://booyah.live/loading52x
2. https://booyah.live/nfa
3. https://booyah.live/levelup


### Additional comments, etc.

No login required, no DRM, no geo-block.

[Love Streamlink? Please consider supporting our collective. Thanks!](https://opencollective.com/streamlink/donate)



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/booyah.py b/src/streamlink/plugins/booyah.py
new file mode 100644
--- /dev/null
+++ b/src/streamlink/plugins/booyah.py
@@ -0,0 +1,153 @@
+import logging
+import re
+from urllib.parse import urljoin
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream, HTTPStream
+
+log = logging.getLogger(__name__)
+
+
+class Booyah(Plugin):
+    url_re = re.compile(r'''
+        https?://(?:www\.)?booyah\.live/
+        (?:(?P<type>channels|clips|vods)/)?(?P<id>[^?]+)
+    ''', re.VERBOSE)
+
+    auth_api_url = 'https://booyah.live/api/v3/auths/sessions'
+    vod_api_url = 'https://booyah.live/api/v3/playbacks/{0}'
+    live_api_url = 'https://booyah.live/api/v3/channels/{0}'
+    streams_api_url = 'https://booyah.live/api/v3/channels/{0}/streams'
+
+    auth_schema = validate.Schema({
+        'expiry_time': int,
+        'uid': int,
+    })
+
+    vod_schema = validate.Schema({
+        'user': {
+            'nickname': str,
+        },
+        'playback': {
+            'name': str,
+            'endpoint_list': [{
+                'stream_url': validate.url(),
+                'resolution': validate.all(
+                    int,
+                    validate.transform(lambda x: f'{x}p'),
+                ),
+            }],
+        },
+    })
+
+    live_schema = validate.Schema({
+        'user': {
+            'nickname': str,
+        },
+        'channel': {
+            'channel_id': int,
+            'name': str,
+            'is_streaming': bool,
+            validate.optional('hostee'): {
+                'channel_id': int,
+                'nickname': str,
+            },
+        },
+    })
+
+    streams_schema = validate.Schema({
+        'stream_addr_list': [{
+            'resolution': str,
+            'url_path': str,
+        }],
+        'mirror_list': [{
+            'url_domain': validate.url(),
+        }],
+    })
+
+    author = None
+    category = None
+    title = None
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def get_author(self):
+        return self.author
+
+    def get_category(self):
+        return self.category
+
+    def get_title(self):
+        return self.title
+
+    def do_auth(self):
+        res = self.session.http.post(self.auth_api_url)
+        self.session.http.json(res, self.auth_schema)
+
+    def get_vod(self, id):
+        res = self.session.http.get(self.vod_api_url.format(id))
+        user_data = self.session.http.json(res, schema=self.vod_schema)
+
+        self.author = user_data['user']['nickname']
+        self.category = 'VOD'
+        self.title = user_data['playback']['name']
+
+        for stream in user_data['playback']['endpoint_list']:
+            if stream['stream_url'].endswith('.mp4'):
+                yield stream['resolution'], HTTPStream(
+                    self.session,
+                    stream['stream_url'],
+                )
+            else:
+                yield stream['resolution'], HLSStream(
+                    self.session,
+                    stream['stream_url'],
+                )
+
+    def get_live(self, id):
+        res = self.session.http.get(self.live_api_url.format(id))
+        user_data = self.session.http.json(res, schema=self.live_schema)
+
+        if user_data['channel']['is_streaming']:
+            self.category = 'Live'
+            stream_id = user_data['channel']['channel_id']
+        elif 'hostee' in user_data['channel']:
+            self.category = f'Hosted by {user_data["channel"]["hostee"]["nickname"]}'
+            stream_id = user_data['channel']['hostee']['channel_id']
+        else:
+            log.info('User is offline')
+            return
+
+        self.author = user_data['user']['nickname']
+        self.title = user_data['channel']['name']
+
+        res = self.session.http.get(self.streams_api_url.format(stream_id))
+        streams = self.session.http.json(res, schema=self.streams_schema)
+
+        for stream in streams['stream_addr_list']:
+            if stream['resolution'] != 'Auto':
+                for mirror in streams['mirror_list']:
+                    yield stream['resolution'], HLSStream(
+                        self.session,
+                        urljoin(mirror['url_domain'], stream['url_path']),
+                    )
+
+    def _get_streams(self):
+        self.do_auth()
+
+        m = self.url_re.match(self.url)
+        url_data = m.groupdict()
+        log.debug(f'ID={url_data["id"]}')
+
+        if not url_data['type'] or url_data['type'] == 'channels':
+            log.debug('Type=Live')
+            return self.get_live(url_data['id'])
+        else:
+            log.debug('Type=VOD')
+            return self.get_vod(url_data['id'])
+
+
+__plugin__ = Booyah


Test patch:
diff --git a/tests/plugins/test_booyah.py b/tests/plugins/test_booyah.py
new file mode 100644
--- /dev/null
+++ b/tests/plugins/test_booyah.py
@@ -0,0 +1,32 @@
+from streamlink.plugins.booyah import Booyah
+from tests.plugins import PluginCanHandleUrl
+
+
+class TestPluginCanHandleUrlBooyah(PluginCanHandleUrl):
+    __plugin__ = Booyah
+
+    should_match = [
+        'http://booyah.live/nancysp',
+        'https://booyah.live/nancysp',
+        'http://booyah.live/channels/21755518',
+        'https://booyah.live/channels/21755518',
+        'http://booyah.live/clips/13271208573492782667?source=2',
+        'https://booyah.live/clips/13271208573492782667?source=2',
+        'http://booyah.live/vods/13865237825203323136?source=2',
+        'https://booyah.live/vods/13865237825203323136?source=2',
+        'http://www.booyah.live/nancysp',
+        'https://www.booyah.live/nancysp',
+        'http://www.booyah.live/channels/21755518',
+        'https://www.booyah.live/channels/21755518',
+        'http://www.booyah.live/clips/13271208573492782667?source=2',
+        'https://www.booyah.live/clips/13271208573492782667?source=2',
+        'http://www.booyah.live/vods/13865237825203323136?source=2',
+        'https://www.booyah.live/vods/13865237825203323136?source=2',
+    ]
+
+    should_not_match = [
+        'http://booyah.live/',
+        'https://booyah.live/',
+        'http://www.booyah.live/',
+        'https://www.booyah.live/',
+    ]