Repository: streamlink/streamlink

Issue:
plugins.picarto: Unable to validate JSON: Key 'getLoadBalancerUrl' not found
### Checklist

- [X] This is a plugin issue and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest stable release

### Description

Streamlink fails with `error: Unable to validate JSON: Key 'getLoadBalancerUrl' not found in ...` for any Picarto URL, regardless of whether the target livestream is online or not.

### Debug log

```text
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.9.6
[cli][debug] Streamlink: 2.4.0
[cli][debug] Requests(2.26.0), Socks(1.7.1), Websocket(1.1.1)
[cli][debug] Arguments:
[cli][debug]  url=https://picarto.tv/Sephive
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin picarto for URL https://picarto.tv/Sephive
[plugins.picarto][debug] Type=Live
error: Unable to validate JSON: Key 'getLoadBalancerUrl' not found in {'channel': {'account_type': 'FREE', 'adult': False, 'avatar': 'https://images.picarto.tv/ptvimages/4/47/471629/avatars/3b903da4e61234afaa49f8d4f0f2a9c0.jpg', 'color': '#1da456', 'columns': 3, 'commissions': False, 'created_at': '2016-10-02 00:31:45', 'enable_subscription': False, 'gaming': False, 'id': 471629, 'name': 'Sephive', 'online': False, 'private': False, 'show_subscriber_count': False, 'title': 'Illustration! (mostly dragons)', 'total_views': 1434, 'verified': False, 'viewers': 0, 'bio': '', 'profile_color': None, 'banned': False, 'redirect': False, 'followers_count': 67, 'subscribers_count': 0, 'videos_count': 0, 'notification': False, 'gifted': False, 'gifted_channel_name': None, 'following': False, 'subscribed': False, 'ubanned': False, 'stream_name': 'golive+Sephive', 'image_thumbnail': 'https://thumb.picarto.tv/thumbnail/Sephive.jpg', 'video_thumbnail': 'https://thumb.picarto.tv/thumbnail/Sephive.mp4', 'banners': [], 'descriptions': [{'id': 1826, 'channel_id': 471629, 'title': '', 'body': 'Follow me on Twitter and FA!\nhttps://twitter.com/SephiveDewlap\nhttp://www.furaffinity.net/user/sephive/', 'image': 'https://images.picarto.tv/ptvimages/4/47/471629/panels/1826_d9406789b0a9688efd7ae264441a7ff65089589e5b99307af1ea36cb5b1ddf08.png', 'url': None, 'button_enabled': 0, 'button_text': '', 'button_link': '', 'position': 0, 'image_link': '', 'background': False, 'shadow': False, 'hide': False, 'icon_id': None, 'created_at': None, 'updated_at': None, 'deleted_at': None, 'image_url': 'https://images.picarto.tv/ptvimages/4/47/471629/panels/1826_d9406789b0a9688efd7ae264441a7ff65089589e5b99307af1ea36cb5b1ddf08.png'}, {'id': 1827, 'channel_id': 471629, 'title': '', 'body': '', 'image': None, 'url': None, 'button_enabled': 0, 'button_text': '', 'button_link': '', 'position': 1, 'image_link': '', 'background': False, 'shadow': False, 'hide': False, 'icon_id': None, 'created_at': None, 'updated_at': None, 'deleted_at': None, 'image_url': 'https://ptvintern.picarto.tv/storage/'}], 'tools': [], 'categories': [{'id': 8, 'name': 'Furry', 'label': 'Furry', 'value': 8, '_id': 8, 'pivot': {'channel_id': 471629, 'category_id': 8}}], 'languages': [{'id': 56, 'name': 'None', 'code': '', 'label': 'None', 'value': 56, '_id': 56, 'image': None, 'pivot': {'channel_id': 471629, 'language_id': 56}}], 'social_medias': [{'id': 13, 'name': 'Homepage', 'icon': 'https://images.picarto.tv/socialmedias/d2bc802bab173a0ae2fdde67.png', 'regex': '', 'created_at': '2021-03-22 23:32:15', 'updated_at': '2021-07-26 09:26:25', 'deleted_at': None, 'pivot': {'channel_id': 471629, 'social_media_id': 13, 'id': 400322, 'link': 'http://www.furaffinity.net/user/sephive/'}}], 'softwares': [{'id': 20, 'name': 'Clip Studio Paint', 'label': 'Clip Studio Paint', 'value': 20, '_id': 20, 'pivot': {'channel_id': 471629, 'software_id': 20}}], 'tags': [], 'user': None}, 'getMultiStreams': {'name': 'Sephive', 'online': False, 'viewers': 0, 'multistream': False, 'streams': [{'id': 471629, 'user_id': 471629, 'name': 'Sephive', 'account_type': 'FREE', 'avatar': 'https://images.picarto.tv/ptvimages/4/47/471629/avatars/3b903da4e61234afaa49f8d4f0f2a9c0.jpg', 'offline_image': None, 'thumbnail_image': 'https://thumb-us-east1.picarto.tv/thumbnail/Sephive.jpg', 'online': False, 'stream_name': 'golive+Sephive', 'color': '#1da456', 'adult': False, 'multistream': False, 'webrtc': False, 'host': False, 'viewers': 0, 'subscription_enabled': False, 'accountType': 'FREE', 'channelId': 471629, 'channelName': 'Sephive', 'nicknameColor': '#1da456', 'offlineImage': None, 'streamName': 'golive+Sephive', 'thumbnailImage': 'https://thumb-us-east1.picarto.tv/thumbnail/Sephive.jpg', 'hosted': False, 'following': False, 'subscription': False}], 'displayName': 'Sephive'}}
```



Discussion:
I did some quick poking around, and it looks like they just dropped the edge server hostname from the channel detail API response. I don't see any obvious replacements in the new response, so we'll need to find a new way of getting the server hostname. If anyone has any ideas, let me know. The only place I see the hostname showing up is in the HTML of the viewer page, and I'd rather not have to scrape it if there's a more reliable source. 

Resolving patch:
diff --git a/src/streamlink/plugins/picarto.py b/src/streamlink/plugins/picarto.py
--- a/src/streamlink/plugins/picarto.py
+++ b/src/streamlink/plugins/picarto.py
@@ -1,5 +1,6 @@
 import logging
 import re
+from urllib.parse import urlparse
 
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
@@ -15,71 +16,66 @@
     (?:\?tab=videos&id=(?P<vod_id>\d+))?
 """, re.VERBOSE))
 class Picarto(Plugin):
-    channel_schema = validate.Schema({
-        'channel': validate.any(None, {
-            'stream_name': str,
-            'title': str,
-            'online': bool,
-            'private': bool,
-            'categories': [{'label': str}],
-        }),
-        'getLoadBalancerUrl': {'origin': str},
-        'getMultiStreams': validate.any(None, {
-            'multistream': bool,
-            'streams': [{
-                'name': str,
-                'online': bool,
-            }],
-        }),
-    })
-    vod_schema = validate.Schema({
-        'data': {
-            'video': validate.any(None, {
-                'id': str,
-                'title': str,
-                'file_name': str,
-                'channel': {'name': str},
-            }),
-        },
-    }, validate.get('data'), validate.get('video'))
-
-    HLS_URL = 'https://{origin}.picarto.tv/stream/hls/{file_name}/index.m3u8'
+    API_URL_LIVE = "https://ptvintern.picarto.tv/api/channel/detail/{username}"
+    API_URL_VOD = "https://ptvintern.picarto.tv/ptvapi"
+    HLS_URL = "https://{netloc}/stream/hls/{file_name}/index.m3u8"
 
     def get_live(self, username):
-        res = self.session.http.get(f'https://ptvintern.picarto.tv/api/channel/detail/{username}')
-        channel_data = self.session.http.json(res, schema=self.channel_schema)
-        log.trace(f'channel_data={channel_data!r}')
+        netloc = self.session.http.get(self.url, schema=validate.Schema(
+            validate.parse_html(),
+            validate.xml_xpath_string(".//script[contains(@src,'/stream/player.js')][1]/@src"),
+            validate.any(None, validate.transform(lambda src: urlparse(src).netloc))
+        ))
+        if not netloc:
+            log.error("Could not find server netloc")
+            return
 
-        if not channel_data['channel'] or not channel_data['getMultiStreams']:
-            log.debug('Missing channel or streaming data')
+        channel, multistreams = self.session.http.get(self.API_URL_LIVE.format(username=username), schema=validate.Schema(
+            validate.parse_json(),
+            {
+                "channel": validate.any(None, {
+                    "stream_name": str,
+                    "title": str,
+                    "online": bool,
+                    "private": bool,
+                    "categories": [{"label": str}],
+                }),
+                "getMultiStreams": validate.any(None, {
+                    "multistream": bool,
+                    "streams": [{
+                        "name": str,
+                        "online": bool,
+                    }],
+                }),
+            },
+            validate.union_get("channel", "getMultiStreams")
+        ))
+        if not channel or not multistreams:
+            log.debug("Missing channel or streaming data")
             return
 
-        if channel_data['channel']['private']:
-            log.info('This is a private stream')
+        log.trace(f"netloc={netloc!r}")
+        log.trace(f"channel={channel!r}")
+        log.trace(f"multistreams={multistreams!r}")
+
+        if not channel["online"]:
+            log.error("User is not online")
             return
 
-        if channel_data['getMultiStreams']['multistream']:
-            msg = 'Found multistream: '
-            i = 1
-            for user in channel_data['getMultiStreams']['streams']:
-                msg += user['name']
-                msg += ' (online)' if user['online'] else ' (offline)'
-                if i < len(channel_data['getMultiStreams']['streams']):
-                    msg += ', '
-                i += 1
-            log.info(msg)
-
-        if not channel_data['channel']['online']:
-            log.error('User is not online')
+        if channel["private"]:
+            log.info("This is a private stream")
             return
 
         self.author = username
-        self.category = channel_data['channel']['categories'][0]['label']
-        self.title = channel_data['channel']['title']
+        self.category = channel["categories"][0]["label"]
+        self.title = channel["title"]
 
-        return HLSStream.parse_variant_playlist(self.session,
-                                                self.HLS_URL.format(file_name=channel_data['channel']['stream_name'],
-                                                                    origin=channel_data['getLoadBalancerUrl']['origin']))
+        hls_url = self.HLS_URL.format(
+            netloc=netloc,
+            file_name=channel["stream_name"]
+        )
+
+        return HLSStream.parse_variant_playlist(self.session, hls_url)
 
     def get_vod(self, vod_id):
         data = {
@@ -89,6 +85,7 @@ def get_vod(self, vod_id):
                 '    id\n'
                 '    title\n'
                 '    file_name\n'
+                '    video_recording_image_url\n'
                 '    channel {\n'
                 '      name\n'
                 '      }'
@@ -97,19 +94,37 @@ def get_vod(self, vod_id):
             ),
             'variables': {'videoId': vod_id},
         }
-        res = self.session.http.post('https://ptvintern.picarto.tv/ptvapi', json=data)
-        vod_data = self.session.http.json(res, schema=self.vod_schema)
-        log.trace(f'vod_data={vod_data!r}')
+        vod_data = self.session.http.post(self.API_URL_VOD, json=data, schema=validate.Schema(
+            validate.parse_json(),
+            {"data": {
+                "video": validate.any(None, {
+                    "id": str,
+                    "title": str,
+                    "file_name": str,
+                    "video_recording_image_url": str,
+                    "channel": {"name": str},
+                }),
+            }},
+            validate.get(("data", "video"))
+        ))
+
         if not vod_data:
-            log.debug('Missing video data')
+            log.debug("Missing video data")
             return
 
-        self.author = vod_data['channel']['name']
-        self.category = 'VOD'
-        self.title = vod_data['title']
-        return HLSStream.parse_variant_playlist(self.session,
-                                                self.HLS_URL.format(file_name=vod_data['file_name'],
-                                                                    origin='recording-eu-1'))
+        log.trace(f"vod_data={vod_data!r}")
+
+        self.author = vod_data["channel"]["name"]
+        self.category = "VOD"
+        self.title = vod_data["title"]
+
+        netloc = urlparse(vod_data["video_recording_image_url"]).netloc
+        hls_url = self.HLS_URL.format(
+            netloc=netloc,
+            file_name=vod_data["file_name"]
+        )
+
+        return HLSStream.parse_variant_playlist(self.session, hls_url)
 
     def _get_streams(self):
         m = self.match.groupdict()


Test patch: