Repository: streamlink/streamlink

Issue:
plugins.twitch: clips 404 - require access token
<!--
Thanks for reporting a bug!
USE THE TEMPLATE. Otherwise your bug report may be rejected.

First, see the contribution guidelines:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

Bugs are the result of broken functionality within Streamlink's main code base. Use the plugin issue template if your report is about a broken plugin.

Also check the list of open and closed bug reports:
https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22

Please see the text preview to avoid unnecessary formatting errors.
-->


## Bug Report

<!-- Replace the space character between the square brackets with an x in order to check the boxes -->
- [x] This is a bug report and I have read the contribution guidelines.
- [x] I am using the latest development version from the master branch.


### Description

When running streamlink with a twitchclip URL it results in an 404 error

### Expected / Actual behavior

It shouldn't 404


### Reproduction steps / Explicit stream URLs to test

<!-- How can we reproduce this? Please note the exact steps below using the list format supplied. If you need more steps please add them. -->

1. run stramlink with a twitch clip URL
2. results in a 404 error


### Log output

<!--
DEBUG LOG OUTPUT IS REQUIRED for a bug report!
INCLUDE THE ENTIRE COMMAND LINE and make sure to **remove usernames and passwords**

Use the `--loglevel debug` parameter and avoid using parameters which suppress log output.
Debug log includes important details about your platform. Don't remove it.
https://streamlink.github.io/latest/cli.html#cmdoption-loglevel

You can copy the output to https://gist.github.com/ or paste it below.

Don't post screenshots of the log output and instead copy the text from your terminal application.
-->

```
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.8.2
[cli][debug] Streamlink: 2.1.2+10.ge747226
[cli][debug] Requests(2.25.1), Socks(1.7.1), Websocket(1.0.1)
[cli][debug] Arguments:
[cli][debug]  url=https://clips.twitch.tv/GrossRamshacklePheasantPipeHype-vb-LdFjwD-kLo87L
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --rtmpdump=D:\Program Files (x86)\Streamlink\rtmpdump\rtmpdump.exe
[cli][debug]  --ffmpeg-ffmpeg=D:\Program Files (x86)\Streamlink\ffmpeg\ffmpeg.exe
[cli][info] Found matching plugin twitch for URL https://clips.twitch.tv/GrossRamshacklePheasantPipeHype-vb-LdFjwD-kLo87L
[cli][info] Available streams: 360p (worst), 480p, 720p, 1080p (best)
[cli][info] Opening stream: 1080p (http)
[cli][error] Try 1/1: Could not open stream <HTTPStream('https://production.assets.clips.twitchcdn.net/AT-cm%7C1198016717.mp4')> (Could not open stream: Unable to open URL: https://production.assets.clips.twitchcdn.net/AT-cm%7C1198016717.mp4 (404 Client Error:  for url: https://production.assets.clips.twitchcdn.net/AT-cm%7C1198016717.mp4))
error: Could not open stream <HTTPStream('https://production.assets.clips.twitchcdn.net/AT-cm%7C1198016717.mp4')>, tried 1 times, exiting
```


### Additional comments, etc.

Twitch now needs a JWT in the URL for authentication. The page 404 if it isn't present. 
The JSON is just plaintext, but urlencoded and has the form:
{"authorization":{"forbidden":false,"reason":""},"clip_uri":"","device_id":str,"expires":int,"user_id":str,"version":2}
The signature appears to be sha1, but I couldn't figure out what exactly that were hashing as nothing I tried resulted in a valid hash.

[Love Streamlink? Please consider supporting our collective. Thanks!](https://opencollective.com/streamlink/donate)



Discussion:
Clips now seem to require an access token, similar to the live streams and vods.

The access token can be acquired via this GQL query:
```json
[
  {
    "operationName": "VideoAccessToken_Clip",
    "variables": {
      "slug": "CLIPNAME"
    },
    "extensions": {
      "persistedQuery": {
        "version": 1,
        "sha256Hash": "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11"
      }
    }
  }
]
```

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
@@ -15,6 +15,7 @@
 from streamlink.stream.hls import HLSStreamReader, HLSStreamWorker, HLSStreamWriter
 from streamlink.stream.hls_playlist import M3U8, M3U8Parser, load as load_hls_playlist
 from streamlink.utils.times import hours_minutes_seconds
+from streamlink.utils.url import update_qsd
 
 log = logging.getLogger(__name__)
 
@@ -346,47 +347,89 @@ def parse_token(self, tokenstr):
         ))
 
     def clips(self, clipname):
-        query = """{{
-            clip(slug: "{clipname}") {{
-                broadcaster {{
-                    displayName
-                }}
-                title
-                game {{
-                    name
-                }}
-                videoQualities {{
-                    quality
-                    sourceURL
-                }}
-            }}
-        }}""".format(clipname=clipname)
-
-        return self.call_gql({"query": query}, schema=validate.Schema(
-            {"data": {
-                "clip": validate.any(None, validate.all({
-                    "broadcaster": validate.all({"displayName": validate.text}, validate.get("displayName")),
-                    "title": validate.text,
-                    "game": validate.all({"name": validate.text}, validate.get("name")),
-                    "videoQualities": [validate.all({
-                        "quality": validate.all(
-                            validate.text,
-                            validate.transform(lambda q: "{0}p".format(q))
-                        ),
-                        "sourceURL": validate.url()
-                    }, validate.union((
-                        validate.get("quality"),
-                        validate.get("sourceURL")
-                    )))]
-                }, validate.union((
-                    validate.get("broadcaster"),
-                    validate.get("title"),
-                    validate.get("game"),
-                    validate.get("videoQualities")
-                ))))
-            }},
-            validate.get("data"),
-            validate.get("clip")
+        queries = [
+            {
+                "operationName": "VideoAccessToken_Clip",
+                "extensions": {
+                    "persistedQuery": {
+                        "version": 1,
+                        "sha256Hash": "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11"
+                    }
+                },
+                "variables": {"slug": clipname}
+            },
+            {
+                "operationName": "ClipsView",
+                "extensions": {
+                    "persistedQuery": {
+                        "version": 1,
+                        "sha256Hash": "4480c1dcc2494a17bb6ef64b94a5213a956afb8a45fe314c66b0d04079a93a8f"
+                    }
+                },
+                "variables": {"slug": clipname}
+            },
+            {
+                "operationName": "ClipsTitle",
+                "extensions": {
+                    "persistedQuery": {
+                        "version": 1,
+                        "sha256Hash": "f6cca7f2fdfbfc2cecea0c88452500dae569191e58a265f97711f8f2a838f5b4"
+                    }
+                },
+                "variables": {"slug": clipname}
+            }
+        ]
+
+        return self.call_gql(queries, schema=validate.Schema(
+            [
+                validate.all(
+                    {"data": {
+                        "clip": validate.all({
+                            "playbackAccessToken": validate.all({
+                                "__typename": "PlaybackAccessToken",
+                                "signature": str,
+                                "value": str
+                            }, validate.union((
+                                validate.get("signature"),
+                                validate.get("value")
+                            ))),
+                            "videoQualities": [validate.all({
+                                "frameRate": validate.any(int, float),
+                                "quality": str,
+                                "sourceURL": validate.url()
+                            }, validate.union((
+                                validate.transform(lambda q: f"{q['quality']}p{int(q['frameRate'])}"),
+                                validate.get("sourceURL")
+                            )))]
+                        }, validate.union((
+                            validate.get("playbackAccessToken"),
+                            validate.get("videoQualities")
+                        )))
+                    }},
+                    validate.get("data"),
+                    validate.get("clip")
+                ),
+                validate.all(
+                    {"data": {
+                        "clip": validate.all({
+                            "broadcaster": validate.all({"displayName": str}, validate.get("displayName")),
+                            "game": validate.all({"name": str}, validate.get("name"))
+                        }, validate.union((
+                            validate.get("broadcaster"),
+                            validate.get("game")
+                        )))
+                    }},
+                    validate.get("data"),
+                    validate.get("clip")
+                ),
+                validate.all(
+                    {"data": {
+                        "clip": validate.all({"title": str}, validate.get("title"))
+                    }},
+                    validate.get("data"),
+                    validate.get("clip")
+                )
+            ]
         ))
 
     def stream_metadata(self, channel):
@@ -662,12 +705,12 @@ def _get_hls_streams(self, url, restricted_bitrates, **extra_params):
 
     def _get_clips(self):
         try:
-            (self.author, self.title, self.category, streams) = self.api.clips(self.clip_name)
+            (((sig, token), streams), (self.author, self.category), self.title) = self.api.clips(self.clip_name)
         except (PluginError, TypeError):
             return
 
         for quality, stream in streams:
-            yield quality, HTTPStream(self.session, stream)
+            yield quality, HTTPStream(self.session, update_qsd(stream, {"sig": sig, "token": token}))
 
     def _get_streams(self):
         if self.video_id:


Test patch: