Repository: streamlink/streamlink

Issue:
plugins.kick: 403 Client Error: Forbidden for url
### 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)

### Streamlink version

streamlink 6.7.4+12.gbe04a5e

### Description

Great thanks to @bastimeyer , Streamlink has finally added the support for kick.com and I couldn't wait to have a test using the nightly build generated earlier today. 

But unfortunately, it seems to have some bug while fetching the livestream and VOD. Both of them prompt an error like `error: Unable to open URL: ... (403 Client Error: Forbidden for url: ... )` 

All livestreams and VODs have the same error, and I also tried to fetch with proxy in Japan/HK and it still prompts the same error, so it might not be a geo-blocking issue.

### Debug log

```text
[For livestreams]
streamlink --player "C:\Program Files (x86)\Pure Codec\x64\PotPlayerMini64.exe" --player-no-close --loglevel debug "https://kick.com/ryda" best
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.12.3
[cli][debug] OpenSSL:    OpenSSL 3.0.13 30 Jan 2024
[cli][debug] Streamlink: 6.7.4+12.gbe04a5e
[cli][debug] Dependencies:
[cli][debug]  certifi: 2023.11.17
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.3
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.18.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.22.2
[cli][debug]  trio-websocket: 0.10.3
[cli][debug]  typing-extensions: 4.10.0
[cli][debug]  urllib3: 2.0.3
[cli][debug]  websocket-client: 1.6.1
[cli][debug] Arguments:
[cli][debug]  url=https://kick.com/ryda
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player=C:\Program Files (x86)\Pure Codec\x64\PotPlayerMini64.exe
[cli][debug]  --player-no-close=True
[cli][info] Found matching plugin kick for URL https://kick.com/ryda
error: Unable to open URL: https://kick.com/api/v2/channels/ryda/livestream (403 Client Error: Forbidden for url: https://kick.com/api/v2/channels/ryda/livestream)

[For VODs]
streamlink --player "C:\Program Files (x86)\Pure Codec\x64\PotPlayerMini64.exe" --player-no-close --loglevel debug "https://kick.com/video/489e98c0-8c56-4e4f-8ec1-364ff26095bd" best
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.12.3
[cli][debug] OpenSSL:    OpenSSL 3.0.13 30 Jan 2024
[cli][debug] Streamlink: 6.7.4+12.gbe04a5e
[cli][debug] Dependencies:
[cli][debug]  certifi: 2023.11.17
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.3
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.18.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.22.2
[cli][debug]  trio-websocket: 0.10.3
[cli][debug]  typing-extensions: 4.10.0
[cli][debug]  urllib3: 2.0.3
[cli][debug]  websocket-client: 1.6.1
[cli][debug] Arguments:
[cli][debug]  url=https://kick.com/video/489e98c0-8c56-4e4f-8ec1-364ff26095bd
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player=C:\Program Files (x86)\Pure Codec\x64\PotPlayerMini64.exe
[cli][debug]  --player-no-close=True
[cli][info] Found matching plugin kick for URL https://kick.com/video/489e98c0-8c56-4e4f-8ec1-364ff26095bd
error: Unable to open URL: https://kick.com/api/v1/video/489e98c0-8c56-4e4f-8ec1-364ff26095bd (403 Client Error: Forbidden for url: https://kick.com/api/v1/video/489e98c0-8c56-4e4f-8ec1-364ff26095bd)
```



Discussion:
> I couldn't wait to have a test using the nightly build generated earlier today.

I'd much more prefer receiving feedback **on pull requests** instead of people waiting for changes to be merged into master. Now we have a broken plugin merged, which is bad. We've also [explicitly asked for feedback](https://github.com/streamlink/streamlink/pull/6012#pullrequestreview-2091754384), but nobody bothered.
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#pull-request-feedback

----

> `403 Client Error: Forbidden for url: https://kick.com/api/...`

While implementing the plugin, their API returned 403 responses without the `Accept-Language: en-US` header being set, which is why this is being hardcoded here:
https://github.com/streamlink/streamlink/blob/be04a5eef812877f6120136f8d7d63100ae314f0/src/streamlink/plugins/kick.py#L43-L48

It's possible that something else is missing, or that this header value is invalid for specific regions.

You can override HTTP headers using [`--http-header=KEY=VALUE`](https://streamlink.github.io/cli.html#cmdoption-http-header). Please try changing the `Accept-Language` header and see if that works. `--http-header=Accept-Language=ja-JP`
> I'd much more prefer receiving feedback **on pull requests** instead of people waiting for changes to be merged into master. Now we have a broken plugin merged, which is bad. We've also [explicitly asked for feedback](https://github.com/streamlink/streamlink/pull/6012#pullrequestreview-2091754384), but nobody bothered.

I feel sorry but I didn't be used to checking issues and PRs in GitHub every day. I'll try to do so...

> You can override HTTP headers using [`--http-header=KEY=VALUE`](https://streamlink.github.io/cli.html#cmdoption-http-header). Please try changing the `Accept-Language` header and see if that works. `--http-header=Accept-Language=ja-JP`

I tried this, and, the newly pull request #6020, it still gives the same error information. I also tried to add cookies and User-Agents (because I can open the API URLs, like `https://kick.com/api/v1/video/843ceeaf-9b84-4038-a123-c97b5ef15216`, in my browser) but fails as well.

Resolving patch:
diff --git a/src/streamlink/plugins/kick.py b/src/streamlink/plugins/kick.py
deleted file mode 100644
--- a/src/streamlink/plugins/kick.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""
-$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
deleted file mode 100644
--- a/tests/plugins/test_kick.py
+++ /dev/null
@@ -1,12 +0,0 @@
-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"}),
-    ]