Repository: streamlink/streamlink

Issue:
Unable to play bloomberg with streamlink - unable to parse JSON
<!--
Thanks for reporting a plugin issue!
USE THE TEMPLATE. Otherwise your plugin issue may be rejected.

First, see the contribution guidelines:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

Also check the list of open and closed plugin issues:
https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22

Please see the text preview to avoid unnecessary formatting errors.
-->


## Plugin Issue

<!-- Replace [ ] with [x] in order to check the box -->
- [x] This is a plugin issue and I have read the contribution guidelines.


### Description

<!-- Explain the plugin issue as thoroughly as you can. -->

I am attempting to use streamlink to play bloomberg live.  However, I run into an unable to parse JSON error when doing so.  I can play other streams fine which makes me think this is a plugin issue.

### Reproduction steps / Explicit stream URLs to test

<!-- How can we reproduce this? Please note the exact steps below using the list format supplied. If you need more steps please add them. -->

1. I am running this command: streamlink https://www.bloomberg.com/live/us

### Log output

<!--
TEXT LOG OUTPUT IS REQUIRED for a plugin issue!
Use the `--loglevel debug` parameter and avoid using parameters which suppress log output.
https://streamlink.github.io/cli.html#cmdoption-l

Make sure to **remove usernames and passwords**
You can copy the output to https://gist.github.com/ or paste it below.
-->

```
C:\Windows\system32>streamlink https://www.bloomberg.com/live/us -l debug
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.6.6
[cli][debug] Streamlink: 1.3.1
[cli][debug] Requests(2.22.0), Socks(1.7.1), Websocket(0.57.0)
[cli][info] Found matching plugin bloomberg for URL https://www.bloomberg.com/live/us
error: Unable to parse JSON: Extra data: line 1 column 16474 (char 16473) ('{"schedule":{"allSchedules":{"us": ...)
```


### Additional comments, screenshots, etc.



[Love Streamlink? Please consider supporting our collective. Thanks!](https://opencollective.com/streamlink/donate)



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/bloomberg.py b/src/streamlink/plugins/bloomberg.py
--- a/src/streamlink/plugins/bloomberg.py
+++ b/src/streamlink/plugins/bloomberg.py
@@ -32,10 +32,12 @@ class Bloomberg(Plugin):
 ''', re.VERBOSE)
     _live_player_re = re.compile(r'{APP_BUNDLE:"(?P<live_player_url>.+?/app.js)"')
     _js_to_json_re = partial(re.compile(r'(\w+):(["\']|\d?\.?\d+,|true|false|\[|{)').sub, r'"\1":\2')
-    _video_id_re = re.compile(r'data-bmmr-id=\\"(?P<video_id>.+?)\\"')
     _mp4_bitrate_re = re.compile(r'.*_(?P<bitrate>[0-9]+)\.mp4')
-    _preload_state_re = re.compile(r'window.__PRELOADED_STATE__\s*=\s*({.*});', re.DOTALL)
-    _live_stream_info_re = re.compile(r'12:.*t.exports=({.*})},{}],13:', re.DOTALL)
+    _preload_state_re = re.compile(r'<script>\s*window.__PRELOADED_STATE__\s*=\s*({.+});?\s*</script>')
+    _live_stream_info_module_id_re = re.compile(
+        r'{(?:(?:"[^"]+"|\w+):(?:\d+|"[^"]+"),)*"(?:[^"]*/)?config/livestreams":(\d+)(?:,(?:"[^"]+"|\w+):(?:\d+|"[^"]+"))*}'
+    )
+    _live_stream_info_re = r'],{id}:\[function\(.+var n=({{.+}});r.default=n}},{{"[^"]+":\d+}}],{next}:\['
 
     _live_streams_schema = validate.Schema(
         validate.transform(_js_to_json_re),
@@ -75,6 +77,18 @@ class Bloomberg(Plugin):
         validate.get("byChannelId"),
     )
 
+    _vod_list_schema = validate.Schema(
+        validate.transform(parse_json),
+        validate.get("video"),
+        validate.get("videoList"),
+        validate.all([
+            validate.Schema({
+                "slug": validate.text,
+                "video": validate.Schema({"bmmrId": validate.text}, validate.get("bmmrId"))
+            })
+        ])
+    )
+
     _vod_api_schema = validate.Schema(
         {
             'secureStreams': validate.all([
@@ -94,6 +108,18 @@ class Bloomberg(Plugin):
         validate.transform(lambda x: list(set(x['secureStreams'] + x['streams'] + [x['contentLoc']])))
     )
 
+    _headers = {
+        "authority": "www.bloomberg.com",
+        "upgrade-insecure-requests": "1",
+        "dnt": "1",
+        "accept": ";".join([
+            "text/html,application/xhtml+xml,application/xml",
+            "q=0.9,image/webp,image/apng,*/*",
+            "q=0.8,application/signed-exchange",
+            "v=b3"
+        ])
+    }
+
     @classmethod
     def can_handle_url(cls, url):
         return Bloomberg._url_re.match(url)
@@ -103,65 +129,76 @@ def _get_live_streams(self):
         match = self._url_re.match(self.url)
         channel = match.group('channel')
 
-        res = self.session.http.get(self.url, headers={
-            "authority": "www.bloomberg.com",
-            "upgrade-insecure-requests": "1",
-            "dnt": "1",
-            "accept": ";".join([
-                "text/html,application/xhtml+xml,application/xml",
-                "q=0.9,image/webp,image/apng,*/*",
-                "q=0.8,application/signed-exchange",
-                "v=b3"
-            ])
-        })
+        res = self.session.http.get(self.url, headers=self._headers)
         if "Are you a robot?" in res.text:
             log.error("Are you a robot?")
+
         match = self._preload_state_re.search(res.text)
-        if match:
-            live_ids = self._channel_list_schema.validate(match.group(1))
-            live_id = live_ids.get(channel)
-            if live_id:
-                log.debug("Found liveId = {0}".format(live_id))
-                # Retrieve live player URL
-                res = self.session.http.get(self.PLAYER_URL)
-                match = self._live_player_re.search(res.text)
-                if match is None:
-                    return []
-                live_player_url = update_scheme(self.url, match.group('live_player_url'))
-
-                # Extract streams from the live player page
-                log.debug("Player URL: {0}".format(live_player_url))
-                res = self.session.http.get(live_player_url)
-                match = self._live_stream_info_re.search(res.text)
-                if match:
-                    stream_info = self._live_streams_schema.validate(match.group(1))
-                    data = stream_info.get(live_id, {})
-                    return data.get('cdns', [])
-            else:
-                log.error("Could not find liveId for channel '{0}'".format(channel))
-
-        return []
+        if match is None:
+            return
+
+        live_ids = self._channel_list_schema.validate(match.group(1))
+        live_id = live_ids.get(channel)
+        if not live_id:
+            log.error("Could not find liveId for channel '{0}'".format(channel))
+            return
+
+        log.debug("Found liveId: {0}".format(live_id))
+        # Retrieve live player URL
+        res = self.session.http.get(self.PLAYER_URL)
+        match = self._live_player_re.search(res.text)
+        if match is None:
+            return
+        live_player_url = update_scheme(self.url, match.group('live_player_url'))
+
+        # Extract streams from the live player page
+        log.debug("Player URL: {0}".format(live_player_url))
+        res = self.session.http.get(live_player_url)
+
+        # Get the ID of the webpack module containing the streams config from another module's dependency list
+        match = self._live_stream_info_module_id_re.search(res.text)
+        if match is None:
+            return
+        webpack_module_id = int(match.group(1))
+        log.debug("Webpack module ID: {0}".format(webpack_module_id))
+
+        # Finally get the streams JSON data from the config/livestreams webpack module
+        regex = re.compile(self._live_stream_info_re.format(id=webpack_module_id, next=webpack_module_id + 1))
+        match = regex.search(res.text)
+        if match is None:
+            return
+        stream_info = self._live_streams_schema.validate(match.group(1))
+        data = stream_info.get(live_id, {})
+
+        return data.get('cdns')
 
     def _get_vod_streams(self):
         # Retrieve URL page and search for video ID
-        res = self.session.http.get(self.url)
-        match = self._video_id_re.search(res.text)
+        res = self.session.http.get(self.url, headers=self._headers)
+
+        match = self._preload_state_re.search(res.text)
         if match is None:
-            return []
-        video_id = match.group('video_id')
+            return
 
-        res = self.session.http.get(self.VOD_API_URL.format(video_id))
+        videos = self._vod_list_schema.validate(match.group(1))
+        video_id = next((v["video"] for v in videos if v["slug"] in self.url), None)
+        if video_id is None:
+            return
+
+        log.debug("Found videoId: {0}".format(video_id))
+        res = self.session.http.get(self.VOD_API_URL.format(video_id), headers=self._headers)
         streams = self.session.http.json(res, schema=self._vod_api_schema)
+
         return streams
 
     def _get_streams(self):
         self.session.http.headers.update({"User-Agent": useragents.CHROME})
         if '/news/videos/' in self.url:
             # VOD
-            streams = self._get_vod_streams()
+            streams = self._get_vod_streams() or []
         else:
             # Live
-            streams = self._get_live_streams()
+            streams = self._get_live_streams() or []
 
         for video_url in streams:
             log.debug("Found stream: {0}".format(video_url))


Test patch: