Repository: streamlink/streamlink

Issue:
trovo.live
### Checklist

- [X] This is a plugin request 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 requests](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+request%22)

### Description

trovo(https://trovo.live/) is a streaming media platform launched by Tencent. I have seen the Plugin Request record in issues before(#3069 , #3076 ), but it was not considered because trovo was in the beta stage at that time. At present, trovo has removed the beta logo and ended the beta stage. Hope to re-apply for plugin support for this platform.

### Input URLs

1. https://trovo.live/BloodyTV1
2. https://trovo.live/Sibuyas
3. https://trovo.live/SpaK


Discussion:
It looks like it's possible to get HLS from the site, even though they are using either streamed or chunked FLV - I've not quite worked out which (but I think it's the latter), the whole site is a bit weird compared to the usual...

Viewer numbers are a mixed bag - not sure what's considered worthwhile.  Looking at https://trovo.live/trending/streamers might be useful in that regard.

> It looks like it's possible to get HLS from the site, even though they are using either streamed or chunked FLV - I've not quite worked out which (but I think it's the latter), the whole site is a bit weird compared to the usual...
> 
> Viewer numbers are a mixed bag - not sure what's considered worthwhile. Looking at https://trovo.live/trending/streamers might be useful in that regard.

it may be chunked FLV. It is said that it will sponsor some game events to promote its own site. You can view the distribution of specific sections and the number of viewers: https://trovo.live/trending



Resolving patch:
diff --git a/src/streamlink/plugins/trovo.py b/src/streamlink/plugins/trovo.py
new file mode 100644
--- /dev/null
+++ b/src/streamlink/plugins/trovo.py
@@ -0,0 +1,181 @@
+"""
+$description Global video game live streaming and video hosting platform, owned by Tencent.
+$url trovo.live
+$type live, vod
+"""
+
+import logging
+import random
+import re
+
+from streamlink.plugin import Plugin, pluginmatcher
+from streamlink.plugin.api import validate
+from streamlink.stream.hls import HLSStream
+from streamlink.utils.url import update_scheme
+
+log = logging.getLogger(__name__)
+
+
+@pluginmatcher(re.compile(r"""
+        https?://(?:www\.)?trovo\.live/
+        (?:
+            (?:
+                (?:clip|video)/(?P<video_id>[^/?&]+)
+            )
+            |
+            (?P<user>[^/?&]+)
+        )
+""", re.VERBOSE))
+class Trovo(Plugin):
+    @staticmethod
+    def generate_qid():
+        return f"{random.getrandbits(40):010x}".upper()
+
+    def get_vod(self, video_id):
+        json = self.session.http.post(
+            f"https://gql.trovo.live/?qid={self.generate_qid()}",
+            json=[{
+                "operationName": "batchGetVodDetailInfo",
+                "variables": {
+                    "params": {
+                        "vids": [video_id],
+                    },
+                },
+                "extensions": {
+                    "persistedQuery": {
+                        "version": 1,
+                        "sha256Hash": "ceae0355d66476e21a1dd8e8af9f68de95b4019da2cda8b177c9a2255dad31d0",
+                    },
+                },
+            }],
+            schema=validate.Schema(
+                validate.parse_json(),
+                [{
+                    "data": {
+                        "batchGetVodDetailInfo": {
+                            "VodDetailInfos": validate.any(
+                                {
+                                    video_id: {
+                                        "streamerInfo": {
+                                            "userName": str,
+                                        },
+                                        "vodInfo": {
+                                            "playInfos": [{
+                                                "desc": validate.all(validate.transform(lambda s: s.lower()), str),
+                                                "playUrl": validate.url(),
+                                            }],
+                                            "vid": str,
+                                            "title": str,
+                                            "categoryName": str,
+                                            "playbackRights": {
+                                                "playbackRightsSetting": str,
+                                                "playbackRights": str,
+                                            },
+                                        },
+                                    },
+                                },
+                                {},
+                            ),
+                        },
+                    },
+                }],
+                validate.get((0, "data", "batchGetVodDetailInfo", "VodDetailInfos", video_id)),
+            ),
+        )
+
+        if not json:
+            log.error("Video not found")
+            return
+
+        log.debug(json["vodInfo"]["playbackRights"])
+        self.id = json["vodInfo"]["vid"]
+        self.author = json["streamerInfo"]["userName"]
+        self.title = json["vodInfo"]["title"]
+        self.category = json["vodInfo"]["categoryName"]
+
+        for s in json["vodInfo"]["playInfos"]:
+            q = s["desc"]
+            if "(source)" in q:
+                q = f"source_{q.replace('(source)', '')}"
+            yield q, HLSStream(self.session, update_scheme("https:", s["playUrl"]))
+
+    def get_live(self, user):
+        json = self.session.http.post(
+            f"https://api-web.trovo.live/graphql?qid={self.generate_qid()}",
+            json=[{
+                "operationName": "live_LiveReaderService_GetLiveInfo",
+                "variables": {
+                    "params": {
+                        "userName": user,
+                    },
+                },
+            }],
+            schema=validate.Schema(
+                validate.parse_json(),
+                validate.any(
+                    [{
+                        "data": {
+                            validate.optional("live_LiveReaderService_GetLiveInfo"): {
+                                "streamerInfo": {
+                                    "userName": str,
+                                },
+                                "programInfo": {
+                                    "id": str,
+                                    "title": str,
+                                    "streamInfo": [{
+                                        "desc": validate.all(validate.transform(lambda s: s.lower()), str),
+                                        "playUrl": validate.transform(lambda s: s.replace(".flv?", ".m3u8?")),
+                                    }],
+                                },
+                                "categoryInfo": {
+                                    "shortName": str,
+                                },
+                                "isLive": int,
+                            },
+                        },
+                    }],
+                    [{
+                        "errors": [{
+                            "message": validate.transform(lambda s: s.replace('\\"', '"')),
+                        }],
+                    }],
+                ),
+                validate.get(0),
+            ),
+        )
+
+        if "errors" in json:
+            errors = [e["message"] for e in json["errors"]]
+            log.error(f"API error(s): {', '.join(errors)}")
+            return
+
+        if json["data"]["live_LiveReaderService_GetLiveInfo"]:
+            json = json["data"]["live_LiveReaderService_GetLiveInfo"]
+        else:
+            log.error("Stream data not found")
+            return
+
+        if not json["isLive"]:
+            log.error("This stream is no longer live")
+            return
+
+        self.id = json["programInfo"]["id"]
+        self.author = json["streamerInfo"]["userName"]
+        self.title = json["programInfo"]["title"]
+        self.category = json["categoryInfo"]["shortName"]
+
+        for s in json["programInfo"]["streamInfo"]:
+            if s["playUrl"]:
+                yield s["desc"], HLSStream(self.session, update_scheme("https:", s["playUrl"]))
+
+    def _get_streams(self):
+        self.session.http.headers.update({"Origin": "https://trovo.live"})
+        url_data = self.match.groupdict()
+
+        if url_data["video_id"]:
+            return self.get_vod(url_data["video_id"])
+        elif url_data["user"]:
+            return self.get_live(url_data["user"])
+
+
+__plugin__ = Trovo


Test patch:
diff --git a/tests/plugins/test_trovo.py b/tests/plugins/test_trovo.py
new file mode 100644
--- /dev/null
+++ b/tests/plugins/test_trovo.py
@@ -0,0 +1,20 @@
+from streamlink.plugins.trovo import Trovo
+from tests.plugins import PluginCanHandleUrl
+
+
+class TestPluginCanHandleUrlTrovo(PluginCanHandleUrl):
+    __plugin__ = Trovo
+
+    should_match_groups = [
+        ("https://trovo.live/UserName", {"user": "UserName"}),
+        ("https://trovo.live/clip/clip_123", {"video_id": "clip_123"}),
+        ("https://trovo.live/video/video_456", {"video_id": "video_456"}),
+        ("https://www.trovo.live/UserName", {"user": "UserName"}),
+        ("https://www.trovo.live/clip/clip_123", {"video_id": "clip_123"}),
+        ("https://www.trovo.live/video/video_456", {"video_id": "video_456"}),
+    ]
+
+    should_not_match = [
+        "https://trovo.live/",
+        "https://www.trovo.live/",
+    ]