Repository: streamlink/streamlink

Issue:
plugins.goodgame: Handling player links
### 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

It would be great to handle links like https://goodgame.ru/player?199287.
This link represents a *player in window* link class, that can be accessed by clicking the player button.
![Open in window player button](https://github.com/streamlink/streamlink/assets/141326111/72415980-6964-48fe-961f-3c744c567c58)


### 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/player?199287
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin goodgame for URL https://goodgame.ru/player?199287
error: Unable to validate response text: ValidationError(Pattern):
  Type of None should be str or bytes, but is NoneType
```



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/goodgame.py b/src/streamlink/plugins/goodgame.py
--- a/src/streamlink/plugins/goodgame.py
+++ b/src/streamlink/plugins/goodgame.py
@@ -10,9 +10,9 @@
 
 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 @@
 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


Test patch:
diff --git a/tests/plugins/test_goodgame.py b/tests/plugins/test_goodgame.py
--- 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",
+    ]