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

*

* * @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 } } }