forked from commandercool/serviio-plugins
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathtwitch.groovy
256 lines (224 loc) · 10 KB
/
twitch.groovy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import java.net.URL;
import java.net.URLEncoder
import org.serviio.library.metadata.*
import org.serviio.library.online.*
import groovy.json.*
/**
* <h1>twitch/justin.tv Serviio plugin</h1>
*
* <h2>Usage instructions</h2>
* <p>Add streams as a <strong>Web Resource</strong> with
* "<i>http://www.twitch.tv/CHANNELNAME</i>" as URL.</p>
*
* <h2>VERSION HISTORY</h2>
* <p><ul>
* <li>V18 (16.02.2021): updated for new APIs</li>
* <li>V17 (15.09.2016): some API calls now require a Client ID field</li>
* <li>V16 (02.04.2016): urls are now https only - fixed</li>
* <li>V15 (04.02.2015): added support for /v/ vods</li>
* <li>V14 (18.12.2014): even newer API urls (Author: ivanmalm)</li>
* <li>V13 (02.12.2014): newer api urls used, vod extraction fixed
* (courtesy of commandercool).</li>
* <li>V12 (22.09.2014): fixed VODs; now displaying as segments.</li>
* <li>V11 (24.01.2014): fixed stream grabbing.</li>
* <li>V10 (15.01.2014): added support for VODs.</li>
* <li>V9 (20.12.2013): removed RTMP streams since they are now disabled
* and will likely be defunct forever.</li>
* <li>V8 (14.12.2013): simplified HLS/mobile grabbing, fixed a bug from
* V5 that broke rtmpurl generation, updated swfUrl</li>
* <li>V7 (11.12.2013): changed mobile stream grabber to also get source
* quality</li>
* <li>V6 (09.12.2013): added support for displaying mobile streams</li>
* <li>V5 (11.08.2013): worked around some pointless twitch api output,
* fixed a bug with transcoding</li>
* <li>V4 (16.06.2013): worked around bug-inducing twitch swf
* redirection</li>
* <li>V3 (04.02.2013): fixed more escaping, fixed a bug for null-valued
* jtv tokens</li>
* <li>V2 (03.02.2013): fixed jtv token escaping for serviio linux
* installations</li>
* <li>V1 (03.02.2013): initial release</li>
* </ul></p>
*
* @version 17
* @author <a href="https://twitter.com/bogenpirat">bog</a>
*
*/
class Twitch extends WebResourceUrlExtractor {
final String CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
final String CLIENT_ID_API = "hssx1bgogbpcukaz4xf2g18syu2ied"
final String OAUTH_TOKEN = "qu8nj7ez1nm3189tpspqciev8lyk3t"
final Integer VERSION = 18
final String VALID_FEED_URL = "^https?://(?:[^\\.]*.)?twitch\\.tv/([a-zA-Z0-9_]+).*\$"
final String VALID_HLS_VOD_URL = "^https?://(?:[^\\.]*.)?twitch\\.tv/videos/(\\d+)[^\\d]*\$"
final String TWITCH_HLS_API_PLAYLIST_URL = "http://usher.twitch.tv/api/channel/hls/%s.m3u8?sig=%s&token=%s&allow_source=true"
final String TWITCH_VOD_API_URL = "https://api.twitch.tv/api/videos/%s%s?client_id=${CLIENT_ID}"
final String TWITCH_HLS_VOD_API_URL = "https://usher.ttvnw.net/vod/%s.m3u8?allow_source=true&sig=%s&supported_codecs=avc1&token=%s&cdm=wv"
final String TWITCH_VOD_API_INFO = "https://api.twitch.tv/helix/videos?id=%s"
final String TWITCH_GQL_API = "https://gql.twitch.tv/gql"
final String TWITCH_HLSVOD_ACCESSTOKEN_API = "https://api.twitch.tv/api/vods/%s/access_token?as3=t&client_id=${CLIENT_ID}"
final String THUMBNAIL_URL = "https://static-cdn.jtvnw.net/previews-ttv/live_user_%s-440x248.jpg";
final def reqProps = [ 'Authorization': 'Bearer ' + OAUTH_TOKEN, 'Client-Id': CLIENT_ID_API ]
final String TWITCH_GQL_LIVE_ACCESS_TOKEN_PAYLOAD = JsonOutput.toJson([
"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":"%s",
"isVod":false,
"vodID":"",
"playerType":"site"
]
])
final String TWITCH_GQL_VOD_ACCESS_TOKEN_PAYLOAD = JsonOutput.toJson([
"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":false,
"login":"",
"isVod":true,
"vodID":"%s",
"playerType":"site"
]
])
int getVersion() {
return VERSION
}
String getExtractorName() {
return 'twitch.tv'
}
boolean extractorMatches(URL feedUrl) {
return (feedUrl ==~ VALID_FEED_URL) || (feedUrl ==~ VALID_VOD_URL)
}
WebResourceContainer extractItems(URL resourceUrl, int maxItemsToRetrieve) {
def items, title
def channelName = (String) (resourceUrl =~ VALID_FEED_URL)[0][1] // extract channel name from url
if(resourceUrl ==~ VALID_HLS_VOD_URL) {
def vodId = (resourceUrl =~ VALID_HLS_VOD_URL)[0][1] as Integer
title = "${channelName} VOD ${vodId}"
items = extractHlsVods(vodId)
} else if(resourceUrl ==~ VALID_FEED_URL) { // it's a stream
title = "${channelName} Stream"
items = extractHlsStream(channelName)
}
// create and fill the container
def container = new WebResourceContainer()
container.setTitle(title)
container.setItems(items)
return container
}
List<WebResourceItem> extractHlsVods(Integer vodId) {
def info = new JsonSlurper().parseText(new URL(String.format(TWITCH_VOD_API_INFO, vodId)).getText(requestProperties: reqProps))
def vodTitle = info.data[0].title
def preview = info.data[0].thumbnail_url.replace('%{width}', '1920').replace('%{height}', '1080')
// grab auth token
def message = String.format(TWITCH_GQL_VOD_ACCESS_TOKEN_PAYLOAD, vodId)
def conn = new URL(TWITCH_GQL_API).openConnection()
conn.setRequestMethod("POST")
conn.setRequestProperty('Client-ID', CLIENT_ID)
conn.setRequestProperty('User-Agent', 'curl/7.68.0')
conn.setRequestProperty('Authorization', "Bearer ${OAUTH_TOKEN}")
conn.setDoOutput(true)
conn.getOutputStream().write(message.getBytes("UTF-8"))
def t = conn.getInputStream().getText()
def auth = new JsonSlurper().parseText(t)
def playlist = new URL(String.format(TWITCH_HLS_VOD_API_URL, vodId, URLEncoder.encode(auth.data.videoPlaybackAccessToken.signature), URLEncoder.encode(auth.data.videoPlaybackAccessToken.value))).text
def m = playlist =~ /(?s)NAME="([^"]*)".*?BANDWIDTH=(\d+).*?(https?:\/\/.+?)[\n\r]/
def items = []
while(m.find()) {
// a generic string should be enough for identifying purposes
def title = vodTitle + " [${m.group(1)}/${(Float.parseFloat(m.group(2))/1024) as Integer}K]"
items += new WebResourceItem(title: title, additionalInfo: [
expiresImmediately: true,
cacheKey: title,
url: m.group(3),
thumbnailUrl: preview,
live: true
])
}
return items
}
List<WebResourceItem> extractVods(Integer vodId, String urlKind) {
def type
// type can be 'b' or 'a' depending on urlKind
if (urlKind == "b") {
type = "a"
} else {
type = "c"
}
def info = new JsonSlurper().parseText(new URL(String.format(TWITCH_VOD_API_INFO, type, vodId)).text)
def title = info.title
def preview = info.preview
def json = new JsonSlurper().parseText(new URL(String.format(TWITCH_VOD_API_URL, type, vodId)).text)
def items = []
json.chunks.each { chunk, part ->
def ptNr = 1
part.each { data ->
items += new WebResourceItem(title: "[${chunk}, ${ptNr}/${part.size()}] " + title, additionalInfo: [
expiresImmediately: false,
cacheKey: title,
url: data.url,
thumbnailUrl: preview,
live: false
])
ptNr++
}
}
return items
}
List<WebResourceItem> extractHlsStream(String channelName) {
def items = [] // prepare list
// grab auth token
def message = String.format(TWITCH_GQL_LIVE_ACCESS_TOKEN_PAYLOAD, channelName)
def conn = new URL(TWITCH_GQL_API).openConnection()
conn.setRequestMethod("POST")
conn.setRequestProperty('Client-ID', CLIENT_ID)
conn.setRequestProperty('User-Agent', 'curl/7.68.0')
conn.setRequestProperty('Authorization', "Bearer ${OAUTH_TOKEN}")
conn.setDoOutput(true)
conn.getOutputStream().write(message.getBytes("UTF-8"))
def t = conn.getInputStream().getText()
def auth = new JsonSlurper().parseText(t)
try {
def playlist = new URL(String.format(TWITCH_HLS_API_PLAYLIST_URL, channelName.toLowerCase(), auth.data.streamPlaybackAccessToken.signature, auth.data.streamPlaybackAccessToken.value)).text
def m = playlist =~ /(?s)NAME="([^"]*)".*?BANDWIDTH=(\d+).*?(https?:\/\/.+?)[\n\r]/
while(m.find()) {
// a generic string should be enough for identifying purposes
def title = channelName + "-hls" + " [${m.group(1)}/${(Float.parseFloat(m.group(2))/1024) as Integer}K]"
items += new WebResourceItem(title: title, additionalInfo: [
expiresImmediately: true,
cacheKey: title,
url: m.group(3),
thumbnailUrl: String.format(THUMBNAIL_URL, channelName.toLowerCase()),
live: true
])
}
} catch (FileNotFoundException notFound) {
log("Channel is not found or offline: " + channelName)
}
return items
}
ContentURLContainer extractUrl(WebResourceItem arg0, PreferredQuality arg1) {
def c = new ContentURLContainer()
if(arg0 != null) {
c.setExpiresImmediately(arg0.additionalInfo.expiresImmediately)
c.setCacheKey(arg0.additionalInfo.cacheKey)
c.setContentUrl(arg0.additionalInfo.url)
c.setLive(arg0.additionalInfo.live)
c.setThumbnailUrl(arg0.additionalInfo.thumbnailUrl)
}
return c
}
static void main(args) {
Twitch twitch = new Twitch()
def url = ""
if(!args[0].contains("http"))
url = "http://www.twitch.tv/" + args[0]
else
url = args[0]
twitch.extractItems(new URL(url), 123).getItems().each { it->
ContentURLContainer result = twitch.extractUrl(it, PreferredQuality.HIGH)
println result
}
}
}