import java.net.URL;
import java.net.URLEncoder
import org.serviio.library.metadata.*
import org.serviio.library.online.*
import groovy.json.*
/**
*
twitch/justin.tv Serviio plugin
*
* Usage instructions
* Add streams as a Web Resource with
* "http://www.twitch.tv/CHANNELNAME" as URL.
*
* VERSION HISTORY
*
* - V18 (16.02.2021): updated for new APIs
* - V17 (15.09.2016): some API calls now require a Client ID field
* - V16 (02.04.2016): urls are now https only - fixed
* - V15 (04.02.2015): added support for /v/ vods
* - V14 (18.12.2014): even newer API urls (Author: ivanmalm)
* - V13 (02.12.2014): newer api urls used, vod extraction fixed
* (courtesy of commandercool).
* - V12 (22.09.2014): fixed VODs; now displaying as segments.
* - V11 (24.01.2014): fixed stream grabbing.
* - V10 (15.01.2014): added support for VODs.
* - V9 (20.12.2013): removed RTMP streams since they are now disabled
* and will likely be defunct forever.
* - V8 (14.12.2013): simplified HLS/mobile grabbing, fixed a bug from
* V5 that broke rtmpurl generation, updated swfUrl
* - V7 (11.12.2013): changed mobile stream grabber to also get source
* quality
* - V6 (09.12.2013): added support for displaying mobile streams
* - V5 (11.08.2013): worked around some pointless twitch api output,
* fixed a bug with transcoding
* - V4 (16.06.2013): worked around bug-inducing twitch swf
* redirection
* - V3 (04.02.2013): fixed more escaping, fixed a bug for null-valued
* jtv tokens
* - V2 (03.02.2013): fixed jtv token escaping for serviio linux
* installations
* - V1 (03.02.2013): initial release
*
*
* @version 17
* @author bog
*
*/
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 TWITCH_STREAM_API = "https://api.twitch.tv/helix/streams?user_login=%s"
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 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 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 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)
//getting stream thubnail
def streamJson = new JsonSlurper().parseText(new URL(String.format(TWITCH_STREAM_API, channelName.toLowerCase())).getText(requestProperties: reqProps))
def thumbnailUrl
if (streamJson.stream) {
thumbnailUrl = streamJson.stream.preview.medium
}
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: thumbnailUrl,
live: true
])
}
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
}
}
}