Repository: streamlink/streamlink

Issue:
plugins.goodgame: Stream could have no game.
### 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.2.1

### Description

There are some channels that can't be opened even after the #5557.
The problem manifests itself only for certain streams.

You can try to reproduce it with the following script.
```sh
page=1

curl -G \
    -d 'hidden=true' \
    -d 'only_gg=true' \
    -d "page=$page" \
    -H 'accept: application/json' \
    'https://goodgame.ru/api/4/streams/2/' | 
    jq -r '.streams[].link' | 
    xargs -I {} streamlink {}
```
The API endpoint returns 50 channels per page. Change the value of the page variable to load a different page.

You can explore the API using the [Swagger page](https://goodgame.ru/html/api4docs/index.html).

### Debug log

```text
[cli][debug] OS:         Linux-6.1.55-1-lts-x86_64-with-glibc2.38
[cli][debug] Python:     3.11.5
[cli][debug] OpenSSL:    OpenSSL 3.1.3 19 Sep 2023
[cli][debug] Streamlink: 6.2.1
[cli][debug] Dependencies:
[cli][debug]  certifi: 2023.7.22
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.2
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.19.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.28.2
[cli][debug]  trio: 0.22.2
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  typing-extensions: 4.8.0
[cli][debug]  urllib3: 1.26.15
[cli][debug]  websocket-client: 1.6.3
[cli][debug] Arguments:
[cli][debug]  url=https://goodgame.ru/channel/Helen_frost
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Helen_frost
error: Unable to validate response text: ValidationError(NoneOrAllSchema):
  ValidationError(dict):
    Unable to validate value of key 'game'
    Context(type):
      Type of None should be str, but is NoneType
```


Discussion:
IosifShmidt: Hi, everything looks great, most streams are working fine.

However, I've found some unexpected problems.
Could you please add some logging function calls?
For example, it would be useful to see http requests at debug level and http responses at trace level.

As for the errors:
1. https://goodgame.ru/channel/osbvmt%7CVitaTV
   1. `curl https://goodgame.ru/api/4/streams/2/channel/osbvmt%7CVitaTV`
      ```json
      {"error":"Not found"}
      ```
   1. `curl https://goodgame.ru/api/4/streams/2/channel/osbvmt|VitaTV`
      ```json
      {
        "id": 201098,
        "key": "osbvmt|VitaTV",
        "preview": "https://hls.goodgame.ru/previews/201098_240.jpg",
        "title": "OSBVMT|VitaTV",
        "viewers": 1,
        "streamer": {
          "id": 1580220,
          "obj_key": "9:1580220",
          "nickname": "osbvmt1VitaTV",
          "username": "osbvmt1VitaTV",
          "avatar": "https://goodgame.ru//files/avatars/av_1580220_kjzI.jpg",
          "premium": false,
          "banned": false
        },
        "streamKey": "201098",
        "online": true,
        "game": {
          "id": 0,
          "title": null,
          "url": "/games/"
        },
        "poster": "https://goodgame.ru/files/logotypes/ch_201098_7hl4_logo.png",
        "hidden": false,
        "adult": false,
        "link": "https://goodgame.ru/channel/osbvmt%7CVitaTV/",
        "premium": false,
        "players": [
          {
            "title": "GoodGame",
            "status": true,
            "gg": true,
            "online": true,
            "content": "<iframe frameborder=\"0\" width=\"100%\" height=\"100%\" src=\"https://goodgame.ru/player?201098\"></iframe>"
          }
        ],
        "watchTogether": false,
        "premiums": 0,
        "followers": 0,
        "rating": {
          "value": 2406,
          "place": 108
        }
      }
      ```
   1. `curl https://hls.goodgame.ru/manifest/201098_master.m3u8`
      ```
      #EXTM3U
      #EXT-X-VERSION:3
      #GG-Balancer:1.0
      #EXT-X-STREAM-INF:BANDWIDTH=3149240,RESOLUTION=1280x720
      https://hls.goodgame.ru/hls/201098.m3u8
      ```
1. https://goodgame.ru/channel/sunishere/
   1. `curl https://goodgame.ru/api/4/streams/2/channel/sunishere`
      ```json
      {
        "id": 121276,
        "key": "sunishere",
        "preview": "https://hls.goodgame.ru/previews/121276_240.jpg",
        "title": "Привет, это мой первый стрим на Goodgame.ru",
        "viewers": 0,
        "streamer": {
          "id": 629604,
          "obj_key": "9:629604",
          "nickname": "sunishere",
          "username": "sunishere",
          "avatar": "https://goodgame.ru//files/avatars/av_avatar_elf.png",
          "premium": false,
          "banned": false
        },
        "streamKey": "121276",
        "online": true,
        "game": {
          "id": 34,
          "title": "Другое",
          "url": "/games/others"
        },
        "poster": "https://goodgame.ru/images/ico_tv.png",
        "hidden": true,
        "adult": true,
        "link": "https://goodgame.ru/channel/sunishere/",
        "premium": false,
        "players": [
          {
            "title": "Twitch",
            "status": false,
            "gg": false,
            "online": true,
            "content": "<iframe src=\"https://player.twitch.tv/?parent=goodgame.ru&channel=mintymouse\" frameborder=\"0\" allowfullscreen=\"true\" webkitallowfullscreen=\"true\" mozallowfullscreen=\"true\" scrolling=\"no\"  width=\"100%\" height=\"100%\"></iframe>"
          }
        ],
        "watchTogether": false,
        "premiums": 0,
        "followers": 44,
        "rating": {
          "value": 0,
          "place": 0
        }
      }
      ```
   1. `curl -v https://hls.goodgame.ru/manifest/121276_master.m3u8`
      `stream is offline or not found`
bastimeyer: > goodgame.ru/channel/osbvmt%7CVitaTV
> goodgame.ru/channel/osbvmt|VitaTV

`|` is not a valid character for URL paths and must be percent-encoded to `%7C` according to RFC 3986 (section 2).

This is done via `requests` and `urllib3` when making the HTTP request here:

- https://github.com/psf/requests/blob/v2.31.0/requests/models.py#L434
- https://github.com/urllib3/urllib3/blob/2.0.6/src/urllib3/util/url.py#L444
- https://github.com/urllib3/urllib3/blob/2.0.6/src/urllib3/util/url.py#L253-L258
- https://github.com/urllib3/urllib3/blob/2.0.6/src/urllib3/util/url.py#L71-L76

While curl ignores the percent-encoding (check with `--verbose`)

```sh
$ curl -Is --http1.1 'https://goodgame.ru/channel/osbvmt%7CVitaTV' | head -n1
HTTP/1.1 404 not found
$ curl -Is --http1.1 'https://goodgame.ru/channel/osbvmt|VitaTV' | head -n1
HTTP/1.1 200 OK
```

Web browsers like Chromium for example follow RFC 3986 correctly, whereas Firefox does not:

```js
>>> (await fetch("https://goodgame.ru/channel/osbvmt%7CVitaTV")).status
404
>>> (await fetch("https://goodgame.ru/channel/osbvmt|VitaTV")).status
404
```

Since there is no control over how urllib3 encodes the URL path, this channel is simply not accessible. They need to fix their server/API and compare the channel name from the URL's path after percent-decoding it.

> Could you please add some logging function calls?
> For example, it would be useful to see http requests at debug level and http responses at trace level.

Use a Python debugger. Streamlink CLI does only handle output streams of the `streamlink` root logger. Loggers of other modules like urllib3 need to be set up via the Python API. This is intentional.

> goodgame.ru/channel/sunishere

That channel is offline. You must've checked this while it was going offline. I don't see any issues here. The plugin simply checks the `online` status of the API response, as you can see in the plugin code.
IosifShmidt: > They need to fix their server/API and compare the channel name from the URL's path after percent-decoding it.

I've sent an email to support, they will probably fix this problem because it's not just the API problem. The website has the same problem.

> That channel is offline. You must've checked this while it was going offline.

>> "online": true,

It was online at that moment, but it only has twitch player.

<details>
<summary>Other twitch players</summary>

```
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/9743339/
error: Unable to open URL: https://hls.goodgame.ru/manifest/22640_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/22640_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/dokenz/
error: Unable to open URL: https://hls.goodgame.ru/manifest/141117_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/141117_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/resiagoodboy/
error: Unable to open URL: https://hls.goodgame.ru/manifest/17587_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/17587_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/LimGlHf/
error: Unable to open URL: https://hls.goodgame.ru/manifest/25214_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/25214_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/xLife/
error: Unable to open URL: https://hls.goodgame.ru/manifest/19743_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/19743_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/trancezistar/
error: Unable to open URL: https://hls.goodgame.ru/manifest/20206_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/20206_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/woops/
error: Unable to open URL: https://hls.goodgame.ru/manifest/25192_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/25192_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/hstoxicboy/
error: Unable to open URL: https://hls.goodgame.ru/manifest/19620_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/19620_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Kalem81127/
error: Unable to open URL: https://hls.goodgame.ru/manifest/16238_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/16238_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/CorbenDallas/
error: Unable to open URL: https://hls.goodgame.ru/manifest/7395_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/7395_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Artosis/
error: Unable to open URL: https://hls.goodgame.ru/manifest/1055_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/1055_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/TorNis/
error: Unable to open URL: https://hls.goodgame.ru/manifest/1152_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/1152_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Zerocul/
error: Unable to open URL: https://hls.goodgame.ru/manifest/2327_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/2327_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/GGRivolF/
error: Unable to open URL: https://hls.goodgame.ru/manifest/5846_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/5846_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Daopa/
error: Unable to open URL: https://hls.goodgame.ru/manifest/7854_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/7854_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/vip_koss/
error: Unable to open URL: https://hls.goodgame.ru/manifest/10457_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/10457_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Axon228/
error: Unable to open URL: https://hls.goodgame.ru/manifest/11257_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/11257_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/streambot/
error: Unable to open URL: https://hls.goodgame.ru/manifest/12654_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/12654_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Drimkast/
error: Unable to open URL: https://hls.goodgame.ru/manifest/197231_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/197231_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Hance/
error: Unable to open URL: https://hls.goodgame.ru/manifest/13111_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/13111_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/itsHafu/
error: Unable to open URL: https://hls.goodgame.ru/manifest/14076_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/14076_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Genrix_161rus/
error: Unable to open URL: https://hls.goodgame.ru/manifest/128013_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/128013_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/welderb/
error: Unable to open URL: https://hls.goodgame.ru/manifest/26421_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/26421_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/deadscum/
error: Unable to open URL: https://hls.goodgame.ru/manifest/181478_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/181478_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/cuteavalanche/
error: Unable to open URL: https://hls.goodgame.ru/manifest/154280_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/154280_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/_ForGG_/
error: Unable to open URL: https://hls.goodgame.ru/manifest/142736_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/142736_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/ripnat/
error: Unable to open URL: https://hls.goodgame.ru/manifest/193046_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/193046_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/VooDooSh/
error: Unable to open URL: https://hls.goodgame.ru/manifest/146613_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/146613_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/BarrinMW/
error: Unable to open URL: https://hls.goodgame.ru/manifest/189092_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/189092_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/bzzzz/
error: Unable to open URL: https://hls.goodgame.ru/manifest/155582_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/155582_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Xan_x/
error: Unable to open URL: https://hls.goodgame.ru/manifest/162710_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/162710_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Persona404/
error: Unable to open URL: https://hls.goodgame.ru/manifest/163903_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/163903_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/shroud/
error: Unable to open URL: https://hls.goodgame.ru/manifest/121106_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/121106_master.m3u8)
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/channel/Tiger_rus/
error: Unable to open URL: https://hls.goodgame.ru/manifest/109653_master.m3u8 (404 Client Error: Not Found for url: https://hls.goodgame.ru/manifest/109653_master.m3u8)
```

</details>

`curl https://goodgame.ru/api/4/streams/2/channel/Verginiab | jq '. | .link, .players[]'`
```
https://goodgame.ru/channel/Verginiab/
{
  "title": "GoodGame",
  "status": true,
  "gg": true,
  "online": true,
  "content": "<iframe frameborder=\"0\" width=\"100%\" height=\"100%\" src=\"https://goodgame.ru/player?178133\"></iframe>"
}
{
  "title": "Twitch",
  "status": false,
  "gg": false,
  "online": true,
  "content": "<iframe src=\"https://player.twitch.tv/?parent=goodgame.ru&channel=verginiab\" frameborder=\"0\" allowfullscreen=\"true\" webkitallowfullscreen=\"true\" mozallowfullscreen=\"true\" scrolling=\"no\"
  width=\"100%\" height=\"100%\"></iframe>"
}
```

It seems that `status` has the same value as `gg`.

Sometimes this can be a bit weird. The stream is online, but there are no players.

<details>
<summary> <a href="https://goodgame.ru/channel/Starsky_TV/">https://goodgame.ru/channel/Starsky_TV/</a> </summary>

```json
{
  "id": 76832,
  "key": "Starsky_TV",
  "preview": "https://hls.goodgame.ru/previews/76832_240.jpg",
  "title": "Комментатор в деле (Starsky stream)",
  "viewers": 0,
  "streamer": {
    "id": 740896,
    "obj_key": "9:740896",
    "nickname": "Starsky_TV",
    "username": "Starsky_TV",
    "avatar": "https://goodgame.ru//files/avatars/av_avatar_hum.png",
    "premium": false,
    "banned": false
  },
  "streamKey": "76832",
  "online": true,
  "game": {
    "id": 55788,
    "title": "Playerunknown's Battlegrounds",
    "url": "/games/playerunknowns-battlegrounds"
  },
  "poster": "https://goodgame.ru/files/logotypes/ch_76832_ODnM_logo.png",
  "hidden": false,
  "adult": false,
  "link": "https://goodgame.ru/channel/Starsky_TV/",
  "premium": false,
  "players": [],
  "watchTogether": false,
  "premiums": 0,
  "followers": 19,
  "rating": {
    "value": 0,
    "place": 0
  }
}
```

</details>

I wonder if the right solution would be to iterate over the players of the channel and select the first one that is online.
bastimeyer: I've removed the status check of the embedded players. It now only checks the embedded player's `online` flag.

Embedded players also get checked when the channel's `online` flag is false or if the HLS multivariant playlist returns 404.

This should cover all the cases. Online channels, offline channels and offline channels with embedded players.

Patch:
diff --git a/src/streamlink/plugin/plugin.py b/src/streamlink/plugin/plugin.py
index 4f7cc3eb..89fbb9f9 100644
--- a/src/streamlink/plugin/plugin.py
+++ b/src/streamlink/plugin/plugin.py
@@ -45,7 +45,7 @@ BIT_RATE_WEIGHT_RATIO = 2.8
 
 ALT_WEIGHT_MOD = 0.01
 
-QUALITY_WEIGTHS_EXTRA = {
+QUALITY_WEIGHTS_EXTRA = {
     "other": {
         "live": 1080,
     },
@@ -79,7 +79,7 @@ _COOKIE_KEYS = \
 
 
 def stream_weight(stream):
-    for group, weights in QUALITY_WEIGTHS_EXTRA.items():
+    for group, weights in QUALITY_WEIGHTS_EXTRA.items():
         if stream in weights:
             return weights[stream], group
 
diff --git a/src/streamlink/plugins/goodgame.py b/src/streamlink/plugins/goodgame.py
index 8e985f0e..727913bc 100644
--- a/src/streamlink/plugins/goodgame.py
+++ b/src/streamlink/plugins/goodgame.py
@@ -10,9 +10,9 @@ $metadata title
 
 import logging
 import re
-import sys
-from urllib.parse import urlparse, urlunparse
+from urllib.parse import parse_qsl, urlparse
 
+from streamlink.exceptions import NoStreamsError, PluginError
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.stream.hls import HLSStream
@@ -21,69 +21,125 @@ from streamlink.stream.hls import HLSStream
 log = logging.getLogger(__name__)
 
 
-@pluginmatcher(re.compile(
-    r"https?://(?:www\.)?goodgame\.ru/(?:channel/)?(?P<user>[^/]+)",
+@pluginmatcher(name="default", pattern=re.compile(
+    r"https?://(?:www\.)?goodgame\.ru/(?P<name>(?!channel|player)[^/?]+)",
+))
+@pluginmatcher(name="channel", pattern=re.compile(
+    r"https?://(?:www\.)?goodgame\.ru/channel/(?P<channel>[^/?]+)",
+))
+@pluginmatcher(name="player", pattern=re.compile(
+    r"https?://(?:www\.)?goodgame\.ru/player\?(?P<id>\d+)$",
 ))
 class GoodGame(Plugin):
-    @classmethod
-    def stream_weight(cls, stream):
-        if stream == "source":
-            return sys.maxsize, stream
-        return super().stream_weight(stream)
+    _API_STREAMS_ID = "https://goodgame.ru/api/4/streams/2/id/{id}"
+    _API_STREAMS_CHANNEL = "https://goodgame.ru/api/4/streams/2/channel/{channel}"
+    _URL_HLS = "https://hls.goodgame.ru/manifest/{id}_master.m3u8"
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+    def _get_channel_key(self):
+        return self.session.http.get(self.url, schema=validate.Schema(
+            re.compile(r"api:(?P<json>{.+?}),\n"),
+            validate.none_or_all(
+                validate.get("json"),
+                validate.parse_json(),
+                {"channel_key": str},
+                validate.get("channel_key"),
+            ),
+        ))
 
-        # `/channel/user` returns different JSON data with less useful information
-        url = urlparse(self.url)
-        self.url = urlunparse(url._replace(path=re.sub(r"^/channel/", "/", url.path)))
+    def _get_api_url(self):
+        if self.matches["default"]:
+            channel = self._get_channel_key()
+            log.debug(f"{channel=}")
+            if not channel:
+                raise NoStreamsError
+            return self._API_STREAMS_CHANNEL.format(channel=channel)
 
-    def _get_streams(self):
-        data = self.session.http.get(
-            self.url,
-            schema=validate.Schema(
-                validate.parse_html(),
-                validate.xml_xpath_string(".//script[contains(text(),'channel:{')][1]/text()"),
-                re.compile(r"channel:(?P<json>{.+?}),?\n"),
-                validate.none_or_all(
-                    validate.get("json"),
-                    validate.parse_json(),
+        elif self.matches["channel"]:
+            return self._API_STREAMS_CHANNEL.format(channel=self.match["channel"])
+
+        elif self.matches["player"]:
+            return self._API_STREAMS_ID.format(id=self.match["id"])
+
+        raise PluginError("Invalid matcher")
+
+    def _api_stream(self, url):
+        return self.session.http.get(url, schema=validate.Schema(
+            validate.parse_json(),
+            validate.any(
+                validate.all(
                     {
-                        "status": bool,
+                        "error": str,
+                    },
+                    validate.get("error"),
+                    validate.transform(lambda data: ("error", data)),
+                ),
+                validate.all(
+                    {
+                        "online": bool,
                         "id": int,
-                        "streamer": {"username": str},
-                        "game": str,
-                        "title": str,
-                        "sources": validate.all(
-                            {str: validate.url()},
-                            validate.filter(lambda k, v: ".m3u8" in v),
-                        ),
+                        "streamer": {
+                            "username": str,
+                        },
+                        "game": {
+                            "title": validate.none_or_all(str),
+                        },
+                        "title": validate.none_or_all(str),
+                        "players": [
+                            validate.all(
+                                {
+                                    "title": str,
+                                    "online": bool,
+                                    "content": validate.all(
+                                        str,
+                                        validate.parse_html(),
+                                        validate.xml_find(".//iframe"),
+                                        validate.get("src"),
+                                        validate.transform(urlparse),
+                                    ),
+                                },
+                                validate.union_get(
+                                    "title",
+                                    "online",
+                                    "content",
+                                ),
+                            ),
+                        ],
                     },
                     validate.union_get(
-                        "status",
+                        "online",
                         "id",
                         ("streamer", "username"),
-                        "game",
+                        ("game", "title"),
                         "title",
-                        "sources",
+                        "players",
                     ),
+                    validate.transform(lambda data: ("data", *data)),
                 ),
             ),
-        )
+        ))
 
-        if not data:
-            log.error("Could not find channel info")
+    def _get_streams(self):
+        api_url = self._get_api_url()
+        log.debug(f"{api_url=}")
+
+        result, *data = self._api_stream(api_url)
+        if result == "error":
+            log.error(data[0] or "Unknown error")
             return
 
-        status, self.id, self.author, self.category, self.title, sources = data
+        online, self.id, self.author, self.category, self.title, players = data
+        hls_url = self._URL_HLS.format(id=self.id)
 
-        if not status:
-            log.debug("Channel appears to be offline")
-            return
+        if online and self.session.http.get(hls_url, raise_for_status=False).status_code < 400:
+            return HLSStream.parse_variant_playlist(self.session, hls_url)
 
-        for name, url in sources.items():
-            name = f"{name}{'p' if name.isnumeric() else ''}"
-            yield name, HLSStream(self.session, url)
+        log.debug("Channel is offline, checking for embedded players...")
+        for p_title, p_online, p_url in players:
+            if p_title == "Twitch" and p_online:
+                channel = dict(parse_qsl(p_url.query)).get("channel")
+                if channel:
+                    log.debug(f"Redirecting to Twitch: {channel=}")
+                    return self.session.streams(f"twitch.tv/{channel}")
 
 
 __plugin__ = GoodGame
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index e2c65d3c..67509f56 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -17,12 +17,15 @@ import base64
 import logging
 import re
 import sys
+from contextlib import suppress
 from datetime import datetime, timedelta
 from json import dumps as json_dumps
 from random import random
 from typing import List, Mapping, NamedTuple, Optional, Tuple
 from urllib.parse import urlparse
 
+from requests.exceptions import HTTPError
+
 from streamlink.exceptions import NoStreamsError, PluginError
 from streamlink.plugin import Plugin, pluginargument, pluginmatcher
 from streamlink.plugin.api import validate
@@ -900,11 +903,22 @@ class Twitch(Plugin):
                 **extra_params,
             )
         except OSError as err:
-            err = str(err)
-            if "404 Client Error" in err or "Failed to parse playlist" in err:
+            # TODO: fix the "err" attribute set by HTTPSession.request()
+            orig = getattr(err, "err", None)
+            if isinstance(orig, HTTPError) and orig.response.status_code >= 400:
+                error = None
+                with suppress(PluginError):
+                    error = validate.Schema(
+                        validate.parse_json(),
+                        [{
+                            "type": "error",
+                            "error": str,
+                        }],
+                        validate.get((0, "error")),
+                    ).validate(orig.response.text)
+                log.error(error or "Could not access HLS playlist")
                 return
-            else:
-                raise PluginError(err) from err
+            raise PluginError(err) from err
 
         for name in restricted_bitrates:
             if name not in streams:


Test patch:
diff --git a/tests/plugins/test_goodgame.py b/tests/plugins/test_goodgame.py
index 6fa8e19f..cbfde8f3 100644
--- a/tests/plugins/test_goodgame.py
+++ b/tests/plugins/test_goodgame.py
@@ -1,7 +1,4 @@
-import pytest
-
 from streamlink.plugins.goodgame import GoodGame
-from streamlink.session import Streamlink
 from tests.plugins import PluginCanHandleUrl
 
 
@@ -9,15 +6,21 @@ class TestPluginCanHandleUrlGoodGame(PluginCanHandleUrl):
     __plugin__ = GoodGame
 
     should_match_groups = [
-        ("https://goodgame.ru/CHANNELNAME", {"user": "CHANNELNAME"}),
-        ("https://goodgame.ru/channel/CHANNELNAME", {"user": "CHANNELNAME"}),
-    ]
+        (("default", "https://goodgame.ru/CHANNELNAME"), {"name": "CHANNELNAME"}),
+        (("default", "https://goodgame.ru/CHANNELNAME/"), {"name": "CHANNELNAME"}),
+        (("default", "https://goodgame.ru/CHANNELNAME?foo=bar"), {"name": "CHANNELNAME"}),
+        (("default", "https://www.goodgame.ru/CHANNELNAME"), {"name": "CHANNELNAME"}),
 
+        (("channel", "https://goodgame.ru/channel/CHANNELNAME"), {"channel": "CHANNELNAME"}),
+        (("channel", "https://goodgame.ru/channel/CHANNELNAME/"), {"channel": "CHANNELNAME"}),
+        (("channel", "https://goodgame.ru/channel/CHANNELNAME?foo=bar"), {"channel": "CHANNELNAME"}),
+        (("channel", "https://www.goodgame.ru/channel/CHANNELNAME"), {"channel": "CHANNELNAME"}),
 
-@pytest.mark.parametrize(("url", "expected"), [
-    ("https://goodgame.ru/CHANNELNAME", "https://goodgame.ru/CHANNELNAME"),
-    ("https://goodgame.ru/channel/CHANNELNAME", "https://goodgame.ru/CHANNELNAME"),
-])
-def test_url_translation(session: Streamlink, url: str, expected: str):
-    plugin = GoodGame(session, url)
-    assert plugin.url == expected
+        (("player", "https://goodgame.ru/player?1234"), {"id": "1234"}),
+        (("player", "https://www.goodgame.ru/player?1234"), {"id": "1234"}),
+    ]
+
+    should_not_match = [
+        "https://goodgame.ru/channel",
+        "https://goodgame.ru/player",
+    ]
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
index 059cfa04..a0c9d8f0 100644
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -1,4 +1,5 @@
 import unittest
+from contextlib import nullcontext
 from datetime import datetime, timedelta, timezone
 from unittest.mock import MagicMock, Mock, call, patch
 
@@ -585,6 +586,60 @@ class TestTwitchAPIAccessToken:
         assert headers["Client-Integrity"] == "client-integrity-token"
 
 
+class TestTwitchHLSMultivariantResponse:
+    @pytest.fixture()
+    def plugin(self, request: pytest.FixtureRequest, requests_mock: rm.Mocker, session: Streamlink):
+        requests_mock.get("mock://multivariant", **getattr(request, "param", {}))
+        return Twitch(session, "https://twitch.tv/channelname")
+
+    @pytest.mark.parametrize(("plugin", "raises", "streams", "log"), [
+        pytest.param(
+            {"text": "#EXTM3U\n"},
+            nullcontext(),
+            {},
+            [],
+            id="success",
+        ),
+        pytest.param(
+            {"text": "Not an HLS playlist"},
+            pytest.raises(PluginError),
+            {},
+            [],
+            id="invalid HLS playlist",
+        ),
+        pytest.param(
+            {
+                "status_code": 403,
+                "json": [{
+                    "url": "mock://multivariant",
+                    "error": "Content Restricted In Region",
+                    "error_code": "content_geoblocked",
+                    "type": "error",
+                }],
+            },
+            nullcontext(),
+            None,
+            [("streamlink.plugins.twitch", "error", "Content Restricted In Region")],
+            id="geo restriction",
+        ),
+        pytest.param(
+            {
+                "status_code": 404,
+                "text": "Not found",
+            },
+            nullcontext(),
+            None,
+            [("streamlink.plugins.twitch", "error", "Could not access HLS playlist")],
+            id="non-json error response",
+        ),
+    ], indirect=["plugin"])
+    def test_multivariant_response(self, caplog: pytest.LogCaptureFixture, plugin: Twitch, raises, streams, log):
+        caplog.set_level("error", "streamlink.plugins.twitch")
+        with raises:
+            assert plugin._get_hls_streams("mock://multivariant", []) == streams
+        assert [(record.name, record.levelname, record.message) for record in caplog.records] == log
+
+
 class TestTwitchMetadata:
     @pytest.fixture()
     def metadata(self, request: pytest.FixtureRequest, session: Streamlink):