Repository: streamlink/streamlink

Issue:
[0.4.0] beam.pro VODs don't work
### Checklist

- [ ] This is a bug report.
- [ ] This is a plugin request.
- [x] This is a feature request.
- [ ] I used the search function to find already opened/closed issues or pull requests.

### Description

When inputting a beam.pro VOD link into streamlink, you get a 404 Client Error. API problem?

### Expected / Actual behavior

The VOD plays as normal.

### Environment details (operating system, python version, etc.)

Windows 8.1 64bit, MPC-HC, Streamlink 0.4.0.

### Comments, logs, screenshots, etc.

``````
'streamlink.exe --player-passthrough hls https://vodcdn.beam.pro/content_e613c2b1-c3b3-4bb9-9014-7a73b60f8f75/16845301-5961-4442-aeb7-82214480fa71/ best'
[cli][info] Found matching plugin beam for URL https://vodcdn.beam.pro/content_e613c2b1-c3b3-4bb9-9014-7a73b60f8f75/16845301-5961-4442-aeb7-82214480fa71/
error: Unable to open URL: https://beam.pro/api/v1/channels/content_e613c2b1-c3b3-4bb9-9014-7a73b60f8f75 (404 Client Error: Not Found for url: https://beam.pro/api/v1/channels/content_e613c2b1-c3b3-4bb9-9014-7a73b60f8f75)
``````

Going by the straight URL from beam.pro website, I get:

``````
'streamlink.exe --player-passthrough hls https://beam.pro/revocane?vod=1079377 best'
[cli][info] Found matching plugin beam for URL https://beam.pro/revocane?vod=1079377
error: No playable streams found on this URL: https://beam.pro/revocane?vod=1079377
``````


Discussion:
Did this work previously? From what I recall beam.pro has never worked (VODs or livestreams) because they use WebRTC which is a lot of work to implement and we don't support it currently. We had some discussion on it here: https://github.com/streamlink/streamlink/issues/497 and closed it out, it's just too big to tackle right now.
I'm honestly not sure. I just saw the 'matching plugin' line and assumed that it was supposed to.

Livestreams from beam.pro work by the way, using rmtp and with the best quality possible. Tested with 1080p.
@Tharn I'm thinking of the low latency streams. My bad.

Resolving patch:
diff --git a/src/streamlink/plugins/beam.py b/src/streamlink/plugins/beam.py
--- a/src/streamlink/plugins/beam.py
+++ b/src/streamlink/plugins/beam.py
@@ -1,66 +1,109 @@
 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>[^/]+)")
-
-CHANNEL_INFO = "https://beam.pro/api/v1/channels/{0}"
-CHANNEL_MANIFEST = "https://beam.pro/api/v1/channels/{0}/manifest.smil"
-
-_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)
-                    )
-                })
-            ]
-        )
-    })
-)
+_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(self, url):
+    def can_handle_url(cls, url):
         return _url_re.match(url)
 
-    def _get_streams(self):
-        match = _url_re.match(self.url)
-        channel = match.group("channel")
-        res = http.get(CHANNEL_INFO.format(channel))
+    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(CHANNEL_MANIFEST.format(channel_info["id"]))
-        assets = http.xml(res, schema=_assets_schema)
-        streams = {}
+        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"])
             })
-            streams[name] = stream
+            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")
 
-        return streams
+        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


Test patch: