Repository: streamlink/streamlink

Issue:
Kick.com
### Checklist

- [X] This is a plugin request and not [a different kind of issue](https://github.com/streamlink/streamlink/issues/new/choose)
- [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

I see this request closed more than a year ago: https://github.com/streamlink/streamlink/issues/5238 but I think we can say Kick is an established site at this point.

### Input URLs

https://kick.com


Discussion:
I'd argue that Kick still violates rule 10 of the [contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#plugin-requests):

> Sites which have no way to determine viewership numbers
> Plugins must not implement websites which are unused or which only have a handful of real/actual users.

As far as I understand the situation Kick still does nothing in regards to the most basic view botting techniques and faking viewer counts. @bastimeyer I'm unsure what our stance on this sort of site should be as there's a lot of other problematic content which leads me to being biased against it on top of the rule violation so I can't be objective enough.
The rule you're talking about is meant for sites which actually don't have any viewers. Yes, they are faking their viewer numbers, but it's not like there aren't any actual viewers (unfortunately).

While I have very strong opinions about kick, its content and partnered streamers, its business model, as well as its owners, I still don't have anything against adding a plugin. We already have plugins for similar sites, so it doesn't matter to me that this site is supported as well, regardless of what it stands for. If I would judge the content which plugins provide, then I would have removed a couple dozens of them already.

I already had a look at a plugin implementation, because it appeared to be rather trivial, which it indeed was.
Thanks for consideration and the plugin, I only know this service because a lot of streamers I follow on Twitch started to switch to Kick recently, some co-stream with Twitch and some switched entirely to Kick so I think it's an established service at this point.

Their [guidelines](https://kick.com/community-guidelines) say this about botting: 
> "Activities such as botting, view botting, follow-for-follow schemes, and subscriber fraud are prohibited. These rules are in place to uphold the integrity of our platform and its community and **will be enforced accordingly**. "

So I think they at least do something about botting.

Resolving patch:
diff --git a/src/streamlink/plugins/kick.py b/src/streamlink/plugins/kick.py
new file mode 100644
--- /dev/null
+++ b/src/streamlink/plugins/kick.py
@@ -0,0 +1,190 @@
+"""
+$description Global live-streaming and video hosting social platform owned by Kick Streaming Pty Ltd.
+$url kick.com
+$type live, vod
+$metadata id
+$metadata author
+$metadata category
+$metadata title
+"""
+
+import re
+
+from streamlink.plugin import Plugin, pluginmatcher
+from streamlink.plugin.api import validate
+from streamlink.stream.hls import HLSStream
+
+
+@pluginmatcher(
+    name="live",
+    pattern=re.compile(r"https?://(?:\w+\.)?kick\.com/(?!video/)(?P<channel>[^/?]+)$"),
+)
+@pluginmatcher(
+    name="vod",
+    pattern=re.compile(r"https?://(?:\w+\.)?kick\.com/video/(?P<vod>[^/?]+)"),
+)
+@pluginmatcher(
+    name="clip",
+    pattern=re.compile(r"https?://(?:\w+\.)?kick\.com/(?!video/)(?P<channel>[^/?]+)\?clip=(?P<clip>[^&]+)$"),
+)
+class Kick(Plugin):
+    _URL_TOKEN = "https://kick.com/"
+    _URL_API_LIVESTREAM = "https://kick.com/api/v2/channels/{channel}/livestream"
+    _URL_API_VOD = "https://kick.com/api/v1/video/{vod}"
+    _URL_API_CLIP = "https://kick.com/api/v2/clips/{clip}"
+
+    def _get_token(self):
+        res = self.session.http.get(self._URL_TOKEN, raise_for_status=False)
+        return res.cookies.get("XSRF-TOKEN", "")
+
+    def _get_api_headers(self):
+        token = self._get_token()
+
+        return {
+            "Accept": "application/json",
+            "Accept-Language": "en-US",
+            "Referer": self.url,
+            "Authorization": f"Bearer {token}",
+        }
+
+    def _get_streams_live(self):
+        self.author = self.match["channel"]
+
+        data = self.session.http.get(
+            self._URL_API_LIVESTREAM.format(channel=self.author),
+            acceptable_status=(200, 404),
+            headers=self._get_api_headers(),
+            schema=validate.Schema(
+                validate.parse_json(),
+                validate.any(
+                    validate.all(
+                        {"message": str},
+                        validate.transform(lambda _: None),
+                    ),
+                    validate.all(
+                        {"data": None},
+                        validate.transform(lambda _: None),
+                    ),
+                    validate.all(
+                        {
+                            "data": {
+                                "playback_url": validate.url(path=validate.endswith(".m3u8")),
+                                "id": int,
+                                "category": {"name": str},
+                                "session_title": str,
+                            },
+                        },
+                        validate.get("data"),
+                        validate.union_get(
+                            "playback_url",
+                            "id",
+                            ("category", "name"),
+                            "session_title",
+                        ),
+                    ),
+                ),
+            ),
+        )
+        if not data:
+            return
+
+        hls_url, self.id, self.category, self.title = data
+
+        return HLSStream.parse_variant_playlist(self.session, hls_url)
+
+    def _get_streams_vod(self):
+        self.id = self.match["vod"]
+
+        data = self.session.http.get(
+            self._URL_API_VOD.format(vod=self.id),
+            acceptable_status=(200, 404),
+            headers=self._get_api_headers(),
+            schema=validate.Schema(
+                validate.parse_json(),
+                validate.any(
+                    validate.all(
+                        {"message": str},
+                        validate.transform(lambda _: None),
+                    ),
+                    validate.all(
+                        {
+                            "source": validate.url(path=validate.endswith(".m3u8")),
+                            "livestream": {
+                                "session_title": str,
+                                "channel": {
+                                    "user": {
+                                        "username": str,
+                                    },
+                                },
+                            },
+                        },
+                        validate.union_get(
+                            "source",
+                            ("livestream", "channel", "user", "username"),
+                            ("livestream", "session_title"),
+                        ),
+                    ),
+                ),
+            ),
+        )
+        if not data:
+            return
+
+        hls_url, self.author, self.title = data
+
+        return HLSStream.parse_variant_playlist(self.session, hls_url)
+
+    def _get_streams_clip(self):
+        self.id = self.match["clip"]
+        self.author = self.match["channel"]
+
+        data = self.session.http.get(
+            self._URL_API_CLIP.format(clip=self.id),
+            acceptable_status=(200, 404),
+            headers=self._get_api_headers(),
+            schema=validate.Schema(
+                validate.parse_json(),
+                validate.any(
+                    validate.all(
+                        {"message": str},
+                        validate.transform(lambda _: None),
+                    ),
+                    validate.all(
+                        {"clip": None},
+                        validate.transform(lambda _: None),
+                    ),
+                    validate.all(
+                        {
+                            "clip": {
+                                "clip_url": validate.url(path=validate.endswith(".m3u8")),
+                                "category": {"name": str},
+                                "title": str,
+                            },
+                        },
+                        validate.get("clip"),
+                        validate.union_get(
+                            "clip_url",
+                            ("category", "name"),
+                            "title",
+                        ),
+                    ),
+                ),
+            ),
+        )
+        if not data:
+            return
+
+        hls_url, self.category, self.title = data
+
+        return {"clip": HLSStream(self.session, hls_url)}
+
+    def _get_streams(self):
+        if self.matches["live"]:
+            return self._get_streams_live()
+        if self.matches["vod"]:
+            return self._get_streams_vod()
+        if self.matches["clip"]:
+            return self._get_streams_clip()
+
+
+__plugin__ = Kick


Test patch:
diff --git a/tests/plugins/test_kick.py b/tests/plugins/test_kick.py
new file mode 100644
--- /dev/null
+++ b/tests/plugins/test_kick.py
@@ -0,0 +1,12 @@
+from streamlink.plugins.kick import Kick
+from tests.plugins import PluginCanHandleUrl
+
+
+class TestPluginCanHandleUrlKick(PluginCanHandleUrl):
+    __plugin__ = Kick
+
+    should_match_groups = [
+        (("live", "https://kick.com/LIVE_CHANNEL"), {"channel": "LIVE_CHANNEL"}),
+        (("vod", "https://kick.com/video/VIDEO_ID"), {"vod": "VIDEO_ID"}),
+        (("clip", "https://kick.com/CLIP_CHANNEL?clip=CLIP_ID"), {"channel": "CLIP_CHANNEL", "clip": "CLIP_ID"}),
+    ]