Repository: streamlink/streamlink

Issue:
plugins.kick: 403 error
### Checklist

- [x] This is a [plugin issue](https://streamlink.github.io/plugins.html) 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 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)

### Collaboration

- [x] [I will provide feedback should a pull request be opened with a fix for the plugin](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#pull-request-feedback)

### Streamlink version

streamlink 7.1.2+12.g4b7194b

### Description

with the current nightly, im getting the 403 error from kick links that was fixed 3 weeks ago
test runs from pull request 6384 are used below for illustration, with the "-o /dev/null" part removed 

### Debug log

```text
>streamlink --hls-duration=10 https://kick.com/zmok best -l debug
[session][debug] Loading plugin: kick
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.12.8
[cli][debug] OpenSSL:    OpenSSL 3.0.15 3 Sep 2024
[cli][debug] Streamlink: 7.1.2+12.g4b7194b
[cli][debug] Dependencies:
[cli][debug]  certifi: 2024.12.14
[cli][debug]  isodate: 0.7.2
[cli][debug]  lxml: 5.3.0
[cli][debug]  pycountry: 24.6.1
[cli][debug]  pycryptodome: 3.21.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.32.3
[cli][debug]  trio: 0.28.0
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  urllib3: 2.3.0
[cli][debug]  websocket-client: 1.8.0
[cli][debug] Arguments:
[cli][debug]  url=https://kick.com/zmok
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --hls-duration=10.0
[cli][info] Found matching plugin kick for URL https://kick.com/zmok
error: Unable to open URL: https://kick.com/api/v2/channels/zmok/livestream (403 Client Error: Forbidden for url: https://kick.com/api/v2/channels/zmok/livestream)


>streamlink --hls-duration=10  https://kick.com/kukudota2/videos/4acba712-a1cd-45b0-8ce0-4b534d4c271f best -l debug
[session][debug] Loading plugin: kick
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.12.8
[cli][debug] OpenSSL:    OpenSSL 3.0.15 3 Sep 2024
[cli][debug] Streamlink: 7.1.2+12.g4b7194b
[cli][debug] Dependencies:
[cli][debug]  certifi: 2024.12.14
[cli][debug]  isodate: 0.7.2
[cli][debug]  lxml: 5.3.0
[cli][debug]  pycountry: 24.6.1
[cli][debug]  pycryptodome: 3.21.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.32.3
[cli][debug]  trio: 0.28.0
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  urllib3: 2.3.0
[cli][debug]  websocket-client: 1.8.0
[cli][debug] Arguments:
[cli][debug]  url=https://kick.com/kukudota2/videos/4acba712-a1cd-45b0-8ce0-4b534d4c271f
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --hls-duration=10.0
[cli][info] Found matching plugin kick for URL https://kick.com/kukudota2/videos/4acba712-a1cd-45b0-8ce0-4b534d4c271f
error: Unable to open URL: https://kick.com/api/v1/video/4acba712-a1cd-45b0-8ce0-4b534d4c271f (403 Client Error: Forbidden for url: https://kick.com/api/v1/video/4acba712-a1cd-45b0-8ce0-4b534d4c271f)
```


Discussion:
I've checked the plugin approximately half an hour after you've posted this, and the streams were indeed not working. They are working now though, as you can see in the following debug log.

PR #6384 (release 7.1.2) added a missing HTTP request header that definitely fixed 403 HTTP responses that were returned 100% of the time a few weeks ago.

However, in #6384 I also closed my previous attempt of fixing the plugin, namely PR #6325, which was using Streamlink's webbrowser API instead in order to solve a JavaScript challenge that appeared to be required in order to access their API, similar to the Twitch plugin, which require(d/s) a client-integrity token that can only be retrieved by using a web browser.

In #6325 I already said that something's changing constantly on kick, which made fixing this a bit difficult. To me, this looks like some kind of a bot protection that's activated on their site during certain hours of the day.

Either way, the plugin needs further fixing. And if it's indeed the unsolved JS challenge, then #6325 needs to be tested again. But as said, right now, the plugin is again working fine.

```
$ streamlink -l debug https://kick.com/zmok best
[cli][debug] OS:         Linux-6.13.0-1-git-x86_64-with-glibc2.40
[cli][debug] Python:     3.13.1
[cli][debug] OpenSSL:    OpenSSL 3.4.0 22 Oct 2024
[cli][debug] Streamlink: 7.1.2+12.g4b7194b8
[cli][debug] Dependencies:
[cli][debug]  certifi: 2024.12.14
[cli][debug]  isodate: 0.7.2
[cli][debug]  lxml: 5.3.0
[cli][debug]  pycountry: 24.6.1
[cli][debug]  pycryptodome: 3.21.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.32.3
[cli][debug]  trio: 0.28.0
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  urllib3: 2.3.0
[cli][debug]  websocket-client: 1.8.0
[cli][debug] Arguments:
[cli][debug]  url=https://kick.com/zmok
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player=/usr/bin/mpv
[cli][debug]  --webbrowser-headless=True
[cli][info] Found matching plugin kick for URL https://kick.com/zmok
[utils.l10n][debug] Language code: en_US
[cli][info] Available streams: 160p (worst), 360p, 480p, 720p60, 1080p60 (best)
[cli][info] Opening stream: 1080p60 (hls)
[cli][info] Starting player: /usr/bin/mpv
[stream.hls][debug] Reloading playlist
[cli][debug] Pre-buffering 8192 bytes
[stream.hls][debug] First Sequence: 5814; Last Sequence: 5827
[stream.hls][debug] Start offset: 0; Duration: None; Start Sequence: 5825; End Sequence: None
[stream.hls][debug] Adding segment 5825 to queue
[stream.hls][debug] Adding segment 5826 to queue
[stream.hls][debug] Adding segment 5827 to queue
[stream.hls][debug] Writing segment 5825 to output
[stream.hls][debug] Segment 5825 complete
[cli.output][debug] Opening subprocess: ['/usr/bin/mpv', '--force-media-title=https://kick.com/zmok', '-']
[stream.hls][debug] Writing segment 5826 to output
[stream.hls][debug] Segment 5826 complete
[stream.hls][debug] Writing segment 5827 to output
[stream.hls][debug] Segment 5827 complete
[cli][debug] Writing stream to output
[stream.hls][debug] Reloading playlist
[stream.hls][debug] Adding segment 5828 to queue
[stream.hls][debug] Adding segment 5829 to queue
[stream.hls][debug] Adding segment 5830 to queue
[stream.hls][debug] Writing segment 5828 to output
[stream.hls][debug] Segment 5828 complete
[stream.hls][debug] Writing segment 5829 to output
[stream.hls][debug] Segment 5829 complete
[stream.hls][debug] Writing segment 5830 to output
[stream.hls][debug] Segment 5830 complete
[cli][info] Player closed
[stream.segmented][debug] Closing worker thread
[stream.segmented][debug] Closing writer thread
[cli][info] Stream ended
[cli][info] Closing currently open stream...
```
I've been able to record streams by logging into Kick via my browser and pulling the `session_token` cookie value out and sending it along with streamlink requests in a config file.  This is similar to what's needed for Twitch.

```ini
http-cookie=session_token=[session_token]
http-header=Authorization=Bearer [session_token]
```



Patch:
diff --git a/src/streamlink/plugins/kick.py b/src/streamlink/plugins/kick.py
index e91d9aef5db..bdafec4f95a 100644
--- a/src/streamlink/plugins/kick.py
+++ b/src/streamlink/plugins/kick.py
@@ -11,8 +11,9 @@
 import re
 from ssl import OP_NO_TICKET
 
-from streamlink.plugin import Plugin, pluginmatcher
-from streamlink.plugin.api import validate
+from streamlink.exceptions import NoStreamsError
+from streamlink.plugin import Plugin, PluginError, pluginmatcher
+from streamlink.plugin.api import useragents, validate
 from streamlink.session.http import SSLContextAdapter
 from streamlink.stream.hls import HLSStream
 
@@ -35,10 +36,9 @@ def get_ssl_context(self):
 )
 @pluginmatcher(
     name="clip",
-    pattern=re.compile(r"https?://(?:\w+\.)?kick\.com/(?!video/)(?P<channel>[^/?]+)\?clip=(?P<clip>[^&]+)$"),
+    pattern=re.compile(r"https?://(?:\w+\.)?kick\.com/(?!video/)(?P<channel>[^/?]+)(?:\?clip=|/clips/)(?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}"
@@ -46,106 +46,120 @@ class Kick(Plugin):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.session.http.mount("https://kick.com/", KickAdapter())
-        self.session.http.headers.update({"Sec-Fetch-User": "?1"})
-
-    def _get_token(self):
-        res = self.session.http.get(self._URL_TOKEN, raise_for_status=False)
-        return res.cookies.get("XSRF-TOKEN", "")
+        self.session.http.headers.update({
+            "Referer": self.url,
+            "User-Agent": useragents.CHROME,
+        })
 
-    def _get_api_headers(self):
-        token = self._get_token()
+    @staticmethod
+    def _get_api_headers():
+        if not (m := re.search(r"Chrome/(?P<full>(?P<main>\d+)\S+)", useragents.CHROME)):
+            raise PluginError("Error while parsing Chromium User-Agent")
 
         return {
             "Accept": "application/json",
             "Accept-Language": "en-US",
-            "Referer": self.url,
-            "Authorization": f"Bearer {token}",
+            "sec-ch-ua": f'"Not:A-Brand";v="24", "Chromium";v="{m["main"]}"',
+            "sec-ch-ua-arch": '"x86"',
+            "sec-ch-ua-bitness": '"64"',
+            "sec-ch-ua-full-version": f'"{m["full"]}"',
+            "sec-ch-ua-full-version-list": f'"Not:A-Brand";v="24.0.0.0", "Chromium";v="{m["full"]}"',
+            "sec-ch-ua-mobile": "?0",
+            "sec-ch-ua-model": '""',
+            "sec-ch-ua-platform": '"Windows"',
+            "sec-ch-ua-platform-version": '"6.14.0"',
+            "sec-fetch-dest": "empty",
+            "sec-fetch-mode": "cors",
+            "sec-fetch-site": "same-origin",
+            "sec-fetch-user": "?1",
         }
 
+    def _query_api(self, url, schema):
+        schema = validate.Schema(
+            validate.parse_json(),
+            validate.any(
+                validate.all(
+                    {"message": str},
+                    validate.transform(lambda obj: ("error", obj["message"])),
+                ),
+                validate.all(
+                    {"data": None},
+                    validate.transform(lambda _: ("data", None)),
+                ),
+                validate.all(
+                    schema,
+                    validate.transform(lambda obj: ("data", obj)),
+                ),
+            ),
+        )
+
+        res = self.session.http.get(
+            url,
+            headers=self._get_api_headers(),
+            raise_for_status=False,
+        )
+        if res.status_code == 403:
+            raise PluginError("Error while accessing Kick API: status code 403")
+
+        restype, data = schema.validate(res.text)
+
+        if restype == "error":
+            raise PluginError(f"Error while querying Kick API: {data or 'unknown error'}")
+        if not data:
+            raise NoStreamsError
+
+        return data
+
     def _get_streams_live(self):
         self.author = self.match["channel"]
 
-        data = self.session.http.get(
+        hls_url, self.id, self.category, self.title = self._query_api(
             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",
-                        ),
-                    ),
+                {
+                    "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(
+        hls_url, self.author, self.title = self._query_api(
             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,
-                                    },
-                                },
+                {
+                    "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"),
-                        ),
-                    ),
+                    },
+                },
+                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)
 
@@ -153,43 +167,24 @@ def _get_streams_clip(self):
         self.id = self.match["clip"]
         self.author = self.match["channel"]
 
-        data = self.session.http.get(
+        hls_url, self.category, self.title = self._query_api(
             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",
-                        ),
-                    ),
+                {
+                    "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)}
 


Test patch:
diff --git a/tests/plugins/test_kick.py b/tests/plugins/test_kick.py
index eafe268254f..f13bf6e99b0 100644
--- a/tests/plugins/test_kick.py
+++ b/tests/plugins/test_kick.py
@@ -6,8 +6,28 @@ 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"}),
-        (("vod", "https://kick.com/VIDEO_CHANNEL/videos/VIDEO_ID"), {"vod": "VIDEO_ID"}),
-        (("clip", "https://kick.com/CLIP_CHANNEL?clip=CLIP_ID"), {"channel": "CLIP_CHANNEL", "clip": "CLIP_ID"}),
+        (
+            ("live", "https://kick.com/LIVE_CHANNEL"),
+            {"channel": "LIVE_CHANNEL"},
+        ),
+        (
+            ("vod", "https://kick.com/video/VIDEO_ID"),
+            {"vod": "VIDEO_ID"},
+        ),
+        (
+            ("vod", "https://kick.com/VIDEO_CHANNEL/videos/VIDEO_ID"),
+            {"vod": "VIDEO_ID"},
+        ),
+        (
+            ("clip", "https://kick.com/CLIP_CHANNEL?clip=CLIP_ID&foo=bar"),
+            {"channel": "CLIP_CHANNEL", "clip": "CLIP_ID"},
+        ),
+        (
+            ("clip", "https://kick.com/CLIP_CHANNEL/clips/CLIP_ID?foo=bar"),
+            {"channel": "CLIP_CHANNEL", "clip": "CLIP_ID"},
+        ),
+        (
+            ("clip", "https://kick.com/CLIP_CHANNEL/clips/CLIP_ID?clip=CLIP_ID_FROM_QUERY_STRING&foo=bar"),
+            {"channel": "CLIP_CHANNEL", "clip": "CLIP_ID"},
+        ),
     ]