Repository: streamlink/streamlink

Issue:
plugins.twitch: switch access token API request to GQL
As mentioned in #3210, Twitch has begun switching the access token requests on their website to their GQL API a few weeks ago. The transition seems to have been completed by them and no API requests on their old and private `api.twitch.tv/api` API namespace are being made anymore on their website.

Since the old API is still available, the Twitch plugin is still working fine, but once it gets shut down, which can be any time now, it'll break completely. This means we'll have to switch to the GQL API eventually.

I've opened this thread, so this can be monitored and documented, in case Twitch makes further changes.

----

There seem to be two GQL request methods available for the access token acquirement and both return the same response:

1. `PlaybackAccessToken` with the persistedQuery extension (which is a static sha256 of the request, so their servers can use caching when requests are more complicated - variables don't affect the hash):

   ```json
   {
     "operationName": "PlaybackAccessToken",
     "variables": {
       "isLive": true,
       "login": "**CHANNELNAME**",
       "isVod": false,
       "vodID": "",
       "playerType": "site"
     },
     "extensions": {
       "persistedQuery": {
         "version": 1,
         "sha256Hash": "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712"
       }
     }
   }
   ```

2. `PlaybackAccessToken_Template`

   ```json
   {
     "operationName": "PlaybackAccessToken_Template",
     "query": "query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) {  streamPlaybackAccessToken(channelName: $login, params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}) @include(if: $isLive) {    value    signature    __typename  }  videoPlaybackAccessToken(id: $vodID, params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}) @include(if: $isVod) {    value    signature    __typename  }}",
     "variables": {
       "isLive": true,
       "login": "**CHANNELNAME**",
       "isVod": false,
       "vodID": "",
       "playerType": "site"
     }
   }
   ```

Response:

```json
{
  "data": {
    "streamPlaybackAccessToken": {
      "value": "**access-token-string**", 
      "signature": "**signature-string**",
      "__typename": "PlaybackAccessToken"
    }
  },
  "extensions": {
    "durationMilliseconds": 63,
    "operationName": "PlaybackAccessToken",
    "requestID": "**some-random-ID**"
  }
}
```


Discussion:
@bastimeyer Just to address your comment from the other thread. I don't think we should rely on any API endpoint until it's stable. They may also change the naming of the query or the variables which would mean extra work without any benefit. I also have concerns over the fact that it's not actually public. I don't know if this is an error on Twitch's end, or if they aren't updating the documentation.

Resolving patch:
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -285,36 +285,6 @@ def metadata_channel(self, channel_id):
             validate.get("stream")
         ))
 
-    # Private API calls
-
-    def access_token(self, endpoint, asset):
-        return self.call("/api/{0}/{1}/access_token".format(endpoint, asset), private=True, schema=validate.Schema(
-            {
-                "token": validate.text,
-                "sig": validate.text
-            },
-            validate.union((
-                validate.get("sig"),
-                validate.get("token")
-            ))
-        ))
-
-    def token(self, tokenstr):
-        return parse_json(tokenstr, schema=validate.Schema(
-            {
-                "chansub": {
-                    "restricted_bitrates": validate.all(
-                        [validate.text],
-                        validate.filter(
-                            lambda n: not re.match(r"(.+_)?archives|live|chunked", n)
-                        )
-                    )
-                }
-            },
-            validate.get("chansub"),
-            validate.get("restricted_bitrates")
-        ))
-
     def hosted_channel(self, channel_id):
         return self.call("/hosts", subdomain="tmi", include_logins=1, host=channel_id, schema=validate.Schema(
             {
@@ -337,6 +307,62 @@ def hosted_channel(self, channel_id):
 
     # GraphQL API calls
 
+    def access_token(self, is_live, channel_or_vod):
+        request = {
+            "operationName": "PlaybackAccessToken",
+            "extensions": {
+                "persistedQuery": {
+                    "version": 1,
+                    "sha256Hash": "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712"
+                }
+            },
+            "variables": {
+                "isLive": is_live,
+                "login": channel_or_vod if is_live else "",
+                "isVod": not is_live,
+                "vodID": channel_or_vod if not is_live else "",
+                "playerType": "site"
+            }
+        }
+        subschema = {
+            "value": str,
+            "signature": str,
+            "__typename": "PlaybackAccessToken"
+        }
+        return self.call_gql(request, schema=validate.Schema(
+            {"data": validate.any(
+                validate.all(
+                    {"streamPlaybackAccessToken": subschema},
+                    validate.get("streamPlaybackAccessToken")
+                ),
+                validate.all(
+                    {"videoPlaybackAccessToken": subschema},
+                    validate.get("videoPlaybackAccessToken")
+                )
+            )},
+            validate.get("data"),
+            validate.union((
+                validate.get("signature"),
+                validate.get("value")
+            ))
+        ))
+
+    def parse_token(self, tokenstr):
+        return parse_json(tokenstr, schema=validate.Schema(
+            {
+                "chansub": {
+                    "restricted_bitrates": validate.all(
+                        [str],
+                        validate.filter(
+                            lambda n: not re.match(r"(.+_)?archives|live|chunked", n)
+                        )
+                    )
+                }
+            },
+            validate.get("chansub"),
+            validate.get("restricted_bitrates")
+        ))
+
     def clips(self, clipname):
         query = """{{
             clip(slug: "{clipname}") {{
@@ -523,9 +549,9 @@ def _channel_from_login(self, channel):
         except PluginError:
             raise PluginError("Unable to find channel: {0}".format(channel))
 
-    def _access_token(self, endpoint, asset):
+    def _access_token(self, is_live, channel_or_vod):
         try:
-            sig, token = self.api.access_token(endpoint, asset)
+            sig, token = self.api.access_token(is_live, channel_or_vod)
         except PluginError as err:
             if "404 Client Error" in str(err):
                 raise NoStreamsError(self.url)
@@ -533,7 +559,7 @@ def _access_token(self, endpoint, asset):
                 raise
 
         try:
-            restricted_bitrates = self.api.token(token)
+            restricted_bitrates = self.api.parse_token(token)
         except PluginError:
             restricted_bitrates = []
 
@@ -586,14 +612,14 @@ def _get_hls_streams_live(self):
 
         # only get the token once the channel has been resolved
         log.debug("Getting live HLS streams for {0}".format(self.channel))
-        sig, token, restricted_bitrates = self._access_token("channels", self.channel)
+        sig, token, restricted_bitrates = self._access_token(True, self.channel)
         url = self.usher.channel(self.channel, sig=sig, token=token, fast_bread=True)
 
         return self._get_hls_streams(url, restricted_bitrates)
 
     def _get_hls_streams_video(self):
         log.debug("Getting video HLS streams for {0}".format(self.channel))
-        sig, token, restricted_bitrates = self._access_token("vods", self.video_id)
+        sig, token, restricted_bitrates = self._access_token(False, self.video_id)
         url = self.usher.video(self.video_id, nauthsig=sig, nauth=token)
 
         # If the stream is a VOD that is still being recorded, the stream should start at the beginning of the recording


Test patch: