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:

Patch:
diff --git a/src/streamlink/plugins/kick.py b/src/streamlink/plugins/kick.py
index e91d9aef..bdafec4f 100644
--- a/src/streamlink/plugins/kick.py
+++ b/src/streamlink/plugins/kick.py
@@ -11,8 +11,9 @@ $metadata title
 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 @@ class KickAdapter(SSLContextAdapter):
 )
 @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 @@ class Kick(Plugin):
         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 eafe2682..f13bf6e9 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"},
+        ),
     ]