Skip to content

Commit

Permalink
Fixed twitch source
Browse files Browse the repository at this point in the history
  • Loading branch information
Walkyst committed Mar 2, 2023
1 parent eb3c473 commit 45314fe
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpUriRequest;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpUriRequest;

import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;
import static com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools.fetchResponseLines;
Expand All @@ -25,6 +27,7 @@
*/
public abstract class M3uStreamSegmentUrlProvider {
private static final long SEGMENT_WAIT_STEP_MS = 200;
private static final RequestConfig streamingRequestConfig = RequestConfig.custom().setSocketTimeout(5000).setConnectionRequestTimeout(5000).setConnectTimeout(5000).build();

protected SegmentInfo lastSegment;

Expand Down Expand Up @@ -89,6 +92,7 @@ protected String getNextSegmentUrl(HttpInterface httpInterface) {
* @return Input stream of the next segment.
*/
public InputStream getNextSegmentStream(HttpInterface httpInterface) {
httpInterface.getContext().setRequestConfig(streamingRequestConfig);
String url = getNextSegmentUrl(httpInterface);
if (url == null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

public class TwitchConstants {
static final String TWITCH_GRAPHQL_BASE_URL = "https://gql.twitch.tv/gql";
static final String DEFAULT_CLIENT_ID = "jzkbprff40iqj646a697cyrvl0zt2m6";
static final String METADATA_PAYLOAD = "{\"operationName\":\"StreamMetadata\",\"variables\":{\"channelLogin\":\"%s\"},\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"1c719a40e481453e5c48d9bb585d971b8b372f8ebb105b17076722264dfa5b3e\"}}}";
static final String ACCESS_TOKEN_PAYLOAD = "{\"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\"}}";
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager;
import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools;
import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
Expand All @@ -14,12 +15,15 @@
import com.sedmelluq.discord.lavaplayer.track.AudioReference;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import org.apache.http.Header;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.util.EntityUtils;

import java.io.DataInput;
import java.io.DataOutput;
Expand All @@ -31,7 +35,6 @@
import java.util.regex.Pattern;

import static com.sedmelluq.discord.lavaplayer.source.twitch.TwitchConstants.ACCESS_TOKEN_PAYLOAD;
import static com.sedmelluq.discord.lavaplayer.source.twitch.TwitchConstants.DEFAULT_CLIENT_ID;
import static com.sedmelluq.discord.lavaplayer.source.twitch.TwitchConstants.METADATA_PAYLOAD;
import static com.sedmelluq.discord.lavaplayer.source.twitch.TwitchConstants.TWITCH_GRAPHQL_BASE_URL;
import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;
Expand All @@ -44,26 +47,25 @@ public class TwitchStreamAudioSourceManager implements AudioSourceManager, HttpC
private static final Pattern streamNameRegex = Pattern.compile(STREAM_NAME_REGEX);

private final HttpInterfaceManager httpInterfaceManager;
private final String twitchClientId;
private String twitchClientId;
private String twitchDeviceId;

/**
* Create an instance.
*/
public TwitchStreamAudioSourceManager() { this(DEFAULT_CLIENT_ID); }

/**
* Create an instance.
* @param clientId The Twitch client id for your application.
*/
public TwitchStreamAudioSourceManager(String clientId) {
public TwitchStreamAudioSourceManager() {
httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager();
twitchClientId = clientId;
initRequestHeaders();
}

public String getClientId() {
return twitchClientId;
}

public String getDeviceId() {
return twitchDeviceId;
}

@Override
public String getSourceName() {
return "twitch";
Expand All @@ -76,23 +78,16 @@ public AudioItem loadItem(AudioPlayerManager manager, AudioReference reference)
return null;
}

JsonBrowser accessToken = fetchAccessToken(streamName);

if (accessToken == null || accessToken.get("data").get("streamPlaybackAccessToken").get("value").isNull()) {
return AudioReference.NO_TRACK;
}

JsonBrowser channelInfo = fetchStreamChannelInfo(streamName).get("data").get("user");

if (channelInfo == null || channelInfo.get("stream").get("type").isNull()) {
return AudioReference.NO_TRACK;
} else {
String displayName = streamName;
String title = channelInfo.get("lastBroadcast").get("title").text();

return new TwitchStreamAudioTrack(new AudioTrackInfo(
title,
displayName,
streamName,
Units.DURATION_MS_UNKNOWN,
reference.identifier,
true,
Expand Down Expand Up @@ -135,15 +130,15 @@ public static String getChannelIdentifierFromUrl(String url) {
* @return Request with necessary headers attached.
*/
public HttpUriRequest createGetRequest(String url) {
return addClientHeaders(new HttpGet(url), twitchClientId);
return addClientHeaders(new HttpGet(url), twitchClientId, twitchDeviceId);
}

/**
* @param url Request URL
* @return Request with necessary headers attached.
*/
public HttpUriRequest createGetRequest(URI url) {
return addClientHeaders(new HttpGet(url), twitchClientId);
return addClientHeaders(new HttpGet(url), twitchClientId, twitchDeviceId);
}

/**
Expand All @@ -163,15 +158,16 @@ public void configureBuilder(Consumer<HttpClientBuilder> configurator) {
httpInterfaceManager.configureBuilder(configurator);
}

private static HttpUriRequest addClientHeaders(HttpUriRequest request, String clientId) {
private static HttpUriRequest addClientHeaders(HttpUriRequest request, String clientId, String deviceId) {
request.setHeader("Client-ID", clientId);
request.setHeader("X-Device-ID", deviceId);
return request;
}

private JsonBrowser fetchAccessToken(String name) {
protected JsonBrowser fetchAccessToken(String name) {
try (HttpInterface httpInterface = getHttpInterface()) {
HttpPost post = new HttpPost(TWITCH_GRAPHQL_BASE_URL);
addClientHeaders(post, DEFAULT_CLIENT_ID);
addClientHeaders(post, twitchClientId, twitchDeviceId);
post.setEntity(new StringEntity(String.format(ACCESS_TOKEN_PAYLOAD, name)));
return HttpClientTools.fetchResponseAsJson(httpInterface, post);
} catch (IOException e) {
Expand All @@ -182,14 +178,34 @@ private JsonBrowser fetchAccessToken(String name) {
private JsonBrowser fetchStreamChannelInfo(String channelId) {
try (HttpInterface httpInterface = getHttpInterface()) {
HttpPost post = new HttpPost(TWITCH_GRAPHQL_BASE_URL);
addClientHeaders(post, DEFAULT_CLIENT_ID);
addClientHeaders(post, twitchClientId, twitchDeviceId);
post.setEntity(new StringEntity(String.format(METADATA_PAYLOAD, channelId)));
return HttpClientTools.fetchResponseAsJson(httpInterface, post);
} catch (IOException e) {
throw new FriendlyException("Loading Twitch channel information failed.", SUSPICIOUS, e);
}
}

private void initRequestHeaders() {
try (HttpInterface httpInterface = getHttpInterface()) {
HttpGet get = new HttpGet("https://www.twitch.tv");
get.setHeader("Accept", "text/html");
CloseableHttpResponse response = httpInterface.execute(get);
HttpClientTools.assertSuccessWithContent(response, "twitch main page");

String responseText = EntityUtils.toString(response.getEntity());
twitchClientId = DataFormatTools.extractBetween(responseText, "clientId=\"", "\"");

for (Header header : response.getAllHeaders()) {
if (header.getName().contains("Set-Cookie") && header.getValue().contains("unique_id=")) {
twitchDeviceId = DataFormatTools.extractBetween(header.toString(), "unique_id=", ";");
}
}
} catch (IOException e) {
throw new FriendlyException("Loading Twitch main page failed.", SUSPICIOUS, e);
}
}

@Override
public void shutdown() {
ExceptionTools.closeWithWarnings(httpInterfaceManager);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.utils.URIBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;

/**
* Provider for Twitch segment URLs from a channel.
*/
Expand Down Expand Up @@ -51,20 +51,25 @@ protected String fetchSegmentPlaylistUrl(HttpInterface httpInterface) throws IOE
return streamSegmentPlaylistUrl;
}

JsonBrowser token = loadAccessToken(httpInterface);
HttpUriRequest request = new HttpGet(getChannelStreamsUrl(token).toString());
JsonBrowser tokenJson = manager.fetchAccessToken(channelName);
AccessToken token = new AccessToken(
JsonBrowser.parse(tokenJson.get("data").get("streamPlaybackAccessToken").get("value").text()),
tokenJson.get("data").get("streamPlaybackAccessToken").get("signature").text()
);
String url = getChannelStreamsUrl(token).toString();
HttpUriRequest request = new HttpGet(url);
ChannelStreams streams = loadChannelStreamsInfo(HttpClientTools.fetchResponseLines(httpInterface, request, "channel streams list"));

if (streams.entries.isEmpty()) {
throw new IllegalStateException("No streams available on channel.");
}

ChannelStreamInfo stream = streams.entries.get(0);
ChannelStreamInfo stream = streams.entries.get(streams.entries.size() - 1);

log.debug("Chose stream with quality {} from url {}", stream.quality, stream.url);
streamSegmentPlaylistUrl = stream.url;

long tokenServerExpirationTime = JsonBrowser.parse(token.get(TOKEN_PARAMETER).text()).get("expires").as(Long.class) * 1000L;
long tokenServerExpirationTime = token.value.get("expires").as(Long.class) * 1000L;
tokenExpirationTime = System.currentTimeMillis() + (tokenServerExpirationTime - streams.serverTime) - 5000;

return streamSegmentPlaylistUrl;
Expand All @@ -75,20 +80,6 @@ protected HttpUriRequest createSegmentGetRequest(String url) {
return manager.createGetRequest(url);
}

private JsonBrowser loadAccessToken(HttpInterface httpInterface) throws IOException {
HttpUriRequest request = createSegmentGetRequest("https://api.twitch.tv/api/channels/" + channelName +
"/access_token?oauth_token=undefined&need_https=true&player_type=site&player_backend=mediaplayer");

try (CloseableHttpResponse response = httpInterface.execute(request)) {
int statusCode = response.getStatusLine().getStatusCode();
if (!HttpClientTools.isSuccessWithContent(statusCode)) {
throw new IOException("Unexpected response code from access token request: " + statusCode);
}

return JsonBrowser.parse(response.getEntity().getContent());
}
}

private ChannelStreams loadChannelStreamsInfo(String[] lines) {
List<ChannelStreamInfo> streams = loadChannelStreamsList(lines);
ExtendedM3uParser.Line twitchInfoLine = null;
Expand Down Expand Up @@ -117,13 +108,14 @@ private ChannelStreams buildChannelStreamsInfo(ExtendedM3uParser.Line twitchInfo
);
}

private URI getChannelStreamsUrl(JsonBrowser token) {
private URI getChannelStreamsUrl(AccessToken token) {
try {
return new URIBuilder("https://usher.ttvnw.net/api/channel/hls/" + channelName + ".m3u8")
.addParameter(TOKEN_PARAMETER, token.get(TOKEN_PARAMETER).text())
.addParameter("sig", token.get("sig").text())
.addParameter(TOKEN_PARAMETER, token.value.format())
.addParameter("sig", token.signature)
.addParameter("allow_source", "true")
.addParameter("allow_spectre", "true")
.addParameter("allow_audio_only", "true")
.addParameter("player_backend", "html5")
.addParameter("expgroup", "regular")
.build();
Expand All @@ -141,4 +133,14 @@ private ChannelStreams(long serverTime, List<ChannelStreamInfo> entries) {
this.entries = entries;
}
}
}

private static class AccessToken {
private final JsonBrowser value;
private final String signature;

private AccessToken(JsonBrowser value, String signature) {
this.value = value;
this.signature = signature;
}
}
}

0 comments on commit 45314fe

Please sign in to comment.