Repository: streamlink/streamlink
Pull request #1356 (2017-12-04 15:31:03)

 [mixer] replaced beam.pro with mixer.com
requires two commits, for a proper commit history _(github blame)_

- New domain mixer.com
- Use Mixer API 1.0
- Removed RTMPStream
- Removed HTTP mp4 VODs (they won't load for me)
- Fixed HLS Livestreams
- Added tests for mixer.com

Fixed streamlink/streamlink#957


Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index db98da77..8aab1490 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -30,7 +30,6 @@ artetv              arte.tv              Yes   Yes
 atresplayer         atresplayer.com      Yes   No    Streams are geo-restricted to Spain.
 bambuser            bambuser.com         Yes   Yes
 bbciplayer          bbc.co.uk/iplayer    Yes   Yes   Streams may be geo-restricted to the United Kingdom.
-beam                beam.pro             Yes   Yes
 beattv              be-at.tv             Yes   Yes   Playlist not implemented yet.
 bfmtv               bfmtv.com            Yes   Yes
                     01net.com
@@ -131,6 +130,7 @@ media_ccc_de        - media.ccc.de       Yes   Yes   Only mp4 and HLS are suppor
 mediaklikk          mediaklikk.hu        Yes   No    Streams may be geo-restricted to Hungary.
 mips                mips.tv              Yes   --    Requires rtmpdump with K-S-V patches.
 mitele              mitele.es            Yes   No    Streams may be geo-restricted to Spain.
+mixer               mixer.com            Yes   Yes
 mlgtv               mlg.tv               Yes   --
 nbc                 nbc.com              No    Yes   Streams are geo-restricted to USA. Authentication is not supported.
 nbcsports           nbcsports.com        No    Yes   Streams maybe be geo-restricted to USA. Authentication is not supported.
diff --git a/src/streamlink/plugins/beam.py b/src/streamlink/plugins/beam.py
deleted file mode 100644
index 4a71faba..00000000
--- a/src/streamlink/plugins/beam.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import re
-
-from streamlink.compat import urlparse, parse_qsl, urljoin
-from streamlink.plugin import Plugin
-from streamlink.plugin.api import http, validate
-from streamlink.stream import HLSStream
-from streamlink.stream import HTTPStream
-from streamlink.stream import RTMPStream
-
-_url_re = re.compile(r"http(s)?://(\w+.)?beam.pro/(?P<channel>[^/?]+)")
-
-
-class Beam(Plugin):
-    api_url = "https://beam.pro/api/v1/{type}/{id}"
-    channel_manifest = "https://beam.pro/api/v1/channels/{id}/manifest.{type}"
-
-    _vod_schema = validate.Schema({
-        "state": "AVAILABLE",
-        "vods": [{
-            "baseUrl": validate.url(),
-            "data": validate.any(None, {
-                "Height": int
-            }),
-            "format": validate.text
-            }]
-        },
-        validate.get("vods"),
-        validate.filter(lambda x: x["format"] in ("raw", "hls")),
-        [validate.union({
-            "url": validate.get("baseUrl"),
-            "format": validate.get("format"),
-            "height": validate.all(validate.get("data"), validate.get("Height"))
-        })])
-    _assets_schema = validate.Schema(
-        validate.union({
-            "base": validate.all(
-                validate.xml_find("./head/meta"),
-                validate.get("base"),
-                validate.url(scheme="rtmp")
-            ),
-            "videos": validate.all(
-                validate.xml_findall(".//video"),
-                [
-                    validate.union({
-                        "src": validate.all(
-                            validate.get("src"),
-                            validate.text
-                        ),
-                        "height": validate.all(
-                            validate.get("height"),
-                            validate.text,
-                            validate.transform(int)
-                        )
-                    })
-                ]
-            )
-        })
-    )
-
-    @classmethod
-    def can_handle_url(cls, url):
-        return _url_re.match(url)
-
-    def _get_vod_stream(self, vod_id):
-        res = http.get(self.api_url.format(type="recordings", id=vod_id))
-        for sdata in http.json(res, schema=self._vod_schema):
-            if sdata["format"] == "hls":
-                hls_url = urljoin(sdata["url"], "manifest.m3u8")
-                yield "{0}p".format(sdata["height"]), HLSStream(self.session, hls_url)
-            elif sdata["format"] == "raw":
-                raw_url = urljoin(sdata["url"], "source.mp4")
-                yield "{0}p".format(sdata["height"]), HTTPStream(self.session, raw_url)
-
-    def _get_live_stream(self, channel):
-        res = http.get(self.api_url.format(type="channels", id=channel))
-        channel_info = http.json(res)
-
-        if not channel_info["online"]:
-            return
-
-        res = http.get(self.channel_manifest.format(id=channel_info["id"], type="smil"))
-        assets = http.xml(res, schema=self._assets_schema)
-
-        for video in assets["videos"]:
-            name = "{0}p".format(video["height"])
-            stream = RTMPStream(self.session, {
-                "rtmp": "{0}/{1}".format(assets["base"], video["src"])
-            })
-            yield name, stream
-
-        for s in HLSStream.parse_variant_playlist(self.session,
-                                                  self.channel_manifest.format(id=channel_info["id"], type="m3u8")).items():
-            yield s
-
-    def _get_streams(self):
-        params = dict(parse_qsl(urlparse(self.url).query))
-        vod_id = params.get("vod")
-        match = _url_re.match(self.url)
-        channel = match.group("channel")
-
-        if vod_id:
-            self.logger.debug("Looking for VOD {0} from channel: {1}", vod_id, channel)
-            return self._get_vod_stream(vod_id)
-        else:
-            self.logger.debug("Looking for channel: {0}", channel)
-            return self._get_live_stream(channel)
-
-
-__plugin__ = Beam
diff --git a/src/streamlink/plugins/mixer.py b/src/streamlink/plugins/mixer.py
new file mode 100644
index 00000000..34b0ead8
--- /dev/null
+++ b/src/streamlink/plugins/mixer.py
@@ -0,0 +1,87 @@
+"""
+    Mixer API 1.0 documentation
+    https://dev.mixer.com/rest.html
+"""
+import re
+
+from streamlink import NoStreamsError
+from streamlink.compat import urlparse, parse_qsl, urljoin
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import HLSStream
+
+_url_re = re.compile(r"http(s)?://(\w+.)?mixer\.com/(?P<channel>[^/?]+)")
+
+
+class Mixer(Plugin):
+    api_url = "https://mixer.com/api/v1/{type}/{id}"
+
+    _vod_schema = validate.Schema(
+        {
+            "state": "AVAILABLE",
+            "vods": [{
+                "baseUrl": validate.url(),
+                "data": validate.any(None, {
+                    "Height": int
+                }),
+                "format": validate.text
+            }]
+        },
+        validate.get("vods"),
+        validate.filter(lambda x: x["format"] in ("raw", "hls")),
+        [validate.union({
+            "url": validate.get("baseUrl"),
+            "format": validate.get("format"),
+            "height": validate.all(validate.get("data"), validate.get("Height"))
+        })])
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return _url_re.match(url)
+
+    def _get_api_res(self, api_type, api_id):
+        try:
+            res = http.get(self.api_url.format(type=api_type, id=api_id))
+            return res
+        except Exception as e:
+            if "404" in str(e):
+                self.logger.error("invalid {0} - {1}".format(api_type, api_id))
+            elif "429" in str(e):
+                self.logger.error("Too Many Requests, API rate limit exceeded.")
+            raise NoStreamsError(self.url)
+
+    def _get_vod_stream(self, vod_id):
+        res = self._get_api_res("recordings", vod_id)
+
+        for sdata in http.json(res, schema=self._vod_schema):
+            if sdata["format"] == "hls":
+                hls_url = urljoin(sdata["url"], "manifest.m3u8")
+                yield "{0}p".format(sdata["height"]), HLSStream(self.session, hls_url)
+
+    def _get_live_stream(self, channel):
+        res = self._get_api_res("channels", channel)
+
+        channel_info = http.json(res)
+        if not channel_info["online"]:
+            return
+
+        user_id = channel_info["id"]
+        hls_url = self.api_url.format(type="channels", id="{0}/manifest.m3u8".format(user_id))
+        for s in HLSStream.parse_variant_playlist(self.session, hls_url).items():
+            yield s
+
+    def _get_streams(self):
+        params = dict(parse_qsl(urlparse(self.url).query))
+        vod_id = params.get("vod")
+        match = _url_re.match(self.url)
+        channel = match.group("channel")
+
+        if vod_id:
+            self.logger.debug("Looking for VOD {0} from channel: {1}", vod_id, channel)
+            return self._get_vod_stream(vod_id)
+        else:
+            self.logger.debug("Looking for channel: {0}", channel)
+            return self._get_live_stream(channel)
+
+
+__plugin__ = Mixer


Test patch:
diff --git a/tests/test_plugin_mixer.py b/tests/test_plugin_mixer.py
new file mode 100644
index 00000000..9c437dda
--- /dev/null
+++ b/tests/test_plugin_mixer.py
@@ -0,0 +1,15 @@
+import unittest
+
+from streamlink.plugins.mixer import Mixer
+
+
+class TestPluginMixer(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(Mixer.can_handle_url('https://mixer.com/Minecraft?vod=11250002'))
+        self.assertTrue(Mixer.can_handle_url('https://mixer.com/Minecraft'))
+        self.assertTrue(Mixer.can_handle_url('https://mixer.com/Monstercat?vod=11030270'))
+        self.assertTrue(Mixer.can_handle_url('https://mixer.com/Monstercat'))
+
+        # shouldn't match
+        self.assertFalse(Mixer.can_handle_url('https://mixer.com'))