Implement follow / unfollow

This commit is contained in:
0xb00bface 2021-11-07 16:10:03 +01:00
parent 0d47952d3d
commit 18e4a43699
5 changed files with 158 additions and 112 deletions

View File

@ -102,7 +102,6 @@ public class CherryTvUpdateService extends PaginatedScheduledService {
for (int i = 0; i < broadcasts.length(); i++) { for (int i = 0; i < broadcasts.length(); i++) {
JSONObject broadcast = broadcasts.getJSONObject(i); JSONObject broadcast = broadcasts.getJSONObject(i);
CherryTvModel model = site.createModel(broadcast.optString("username")); CherryTvModel model = site.createModel(broadcast.optString("username"));
model.setId(broadcast.getString("id"));
model.setDisplayName(broadcast.optString("title")); model.setDisplayName(broadcast.optString("title"));
model.setDescription(broadcast.optString("description")); model.setDescription(broadcast.optString("description"));
model.setPreview(broadcast.optString("thumbnailUrl")); model.setPreview(broadcast.optString("thumbnailUrl"));

View File

@ -1,8 +1,16 @@
package ctbrec.io; package ctbrec.io;
import static ctbrec.io.HttpConstants.*; import com.squareup.moshi.JsonAdapter;
import static java.nio.charset.StandardCharsets.*; import com.squareup.moshi.Moshi;
import ctbrec.Config;
import ctbrec.LoggingInterceptor;
import ctbrec.Settings.ProxyType;
import okhttp3.*;
import okhttp3.OkHttpClient.Builder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.*;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
@ -14,44 +22,16 @@ import java.nio.file.Files;
import java.security.KeyManagementException; import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.zip.GZIPInputStream; import java.util.zip.GZIPInputStream;
import javax.net.ssl.KeyManager; import static ctbrec.io.HttpConstants.ACCEPT_ENCODING_GZIP;
import javax.net.ssl.SSLContext; import static ctbrec.io.HttpConstants.CONTENT_ENCODING;
import javax.net.ssl.SSLSocketFactory; import static java.nio.charset.StandardCharsets.UTF_8;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi;
import ctbrec.Config;
import ctbrec.Settings.ProxyType;
import okhttp3.ConnectionPool;
import okhttp3.Cookie;
import okhttp3.Credentials;
import okhttp3.OkHttpClient;
import okhttp3.OkHttpClient.Builder;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.Route;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
public abstract class HttpClient { public abstract class HttpClient {
private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class); private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class);
@ -59,11 +39,11 @@ public abstract class HttpClient {
private static final ConnectionPool GLOBAL_HTTP_CONN_POOL = new ConnectionPool(10, 2, TimeUnit.MINUTES); private static final ConnectionPool GLOBAL_HTTP_CONN_POOL = new ConnectionPool(10, 2, TimeUnit.MINUTES);
protected OkHttpClient client; protected OkHttpClient client;
protected CookieJarImpl cookieJar = new CookieJarImpl(); protected CookieJarImpl cookieJar;
protected Config config; protected Config config;
protected boolean loggedIn = false; protected boolean loggedIn = false;
protected int loginTries = 0; protected int loginTries = 0;
private String name; private final String name;
protected HttpClient(String name, Config config) { protected HttpClient(String name, Config config) {
this.name = name; this.name = name;
@ -145,8 +125,7 @@ public abstract class HttpClient {
.connectionPool(GLOBAL_HTTP_CONN_POOL) .connectionPool(GLOBAL_HTTP_CONN_POOL)
.connectTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS) .connectTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS)
.readTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS) .readTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS)
//.addNetworkInterceptor(new LoggingInterceptor()) .addNetworkInterceptor(new LoggingInterceptor());
;
ProxyType proxyType = config.getSettings().proxyType; ProxyType proxyType = config.getSettings().proxyType;
if (proxyType == ProxyType.HTTP) { if (proxyType == ProxyType.HTTP) {
@ -157,7 +136,7 @@ public abstract class HttpClient {
} }
} }
// if transport layer security (TLS) is switched on, accept the self signed cert from the server // if transport layer security (TLS) is switched on, accept the self-signed cert from the server
if (config.getSettings().transportLayerSecurity) { if (config.getSettings().transportLayerSecurity) {
acceptAllTlsCerts(builder); acceptAllTlsCerts(builder);
} }
@ -177,8 +156,8 @@ public abstract class HttpClient {
X509Certificate[] x509Certificates = new X509Certificate[0]; X509Certificate[] x509Certificates = new X509Certificate[0];
return x509Certificates; return x509Certificates;
} }
@Override public void checkServerTrusted(final X509Certificate[] chain, final String authType) throws CertificateException {} @Override public void checkServerTrusted(final X509Certificate[] chain, final String authType) { /* noop*/ }
@Override public void checkClientTrusted(final X509Certificate[] chain, final String authType) throws CertificateException {} @Override public void checkClientTrusted(final X509Certificate[] chain, final String authType) { /* noop*/ }
}; };
try { try {
@ -190,7 +169,7 @@ public abstract class HttpClient {
sslContext.init(keyManagers, trustManagers, secureRandom); sslContext.init(keyManagers, trustManagers, secureRandom);
SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
builder.sslSocketFactory(sslSocketFactory, x509TrustManager); builder.sslSocketFactory(sslSocketFactory, x509TrustManager);
builder.hostnameVerifier((name, sslSession) -> true); builder.hostnameVerifier((hostname, sslSession) -> true);
} catch (KeyManagementException | NoSuchAlgorithmException e) { } catch (KeyManagementException | NoSuchAlgorithmException e) {
LOG.error("Couldn't install trust managers for TLS connections"); LOG.error("Couldn't install trust managers for TLS connections");
} }
@ -254,17 +233,14 @@ public abstract class HttpClient {
} }
private okhttp3.Authenticator createHttpProxyAuthenticator(String username, String password) { private okhttp3.Authenticator createHttpProxyAuthenticator(String username, String password) {
return new okhttp3.Authenticator() { return (route, response) -> {
@Override
public Request authenticate(Route route, Response response) throws IOException {
String credential = Credentials.basic(username, password); String credential = Credentials.basic(username, password);
return response.request().newBuilder().header("Proxy-Authorization", credential).build(); return response.request().newBuilder().header("Proxy-Authorization", credential).build();
}
}; };
} }
public static class SocksProxyAuth extends Authenticator { public static class SocksProxyAuth extends Authenticator {
private PasswordAuthentication auth; private final PasswordAuthentication auth;
private SocksProxyAuth(String user, String password) { private SocksProxyAuth(String user, String password) {
auth = new PasswordAuthentication(user, password == null ? new char[]{} : password.toCharArray()); auth = new PasswordAuthentication(user, password == null ? new char[]{} : password.toCharArray());
@ -327,16 +303,16 @@ public abstract class HttpClient {
public static String gunzipBody(Response response) throws IOException { public static String gunzipBody(Response response) throws IOException {
if (Objects.equals(response.header(CONTENT_ENCODING), ACCEPT_ENCODING_GZIP)) { if (Objects.equals(response.header(CONTENT_ENCODING), ACCEPT_ENCODING_GZIP)) {
GZIPInputStream gzipIn = new GZIPInputStream(response.body().byteStream()); GZIPInputStream gzipIn = new GZIPInputStream(Objects.requireNonNull(response.body()).byteStream());
ByteArrayOutputStream bos = new ByteArrayOutputStream(); ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] b = new byte[1024]; byte[] b = new byte[1024];
int len = -1; int len;
while ((len = gzipIn.read(b)) >= 0) { while ((len = gzipIn.read(b)) >= 0) {
bos.write(b, 0, len); bos.write(b, 0, len);
} }
return bos.toString(StandardCharsets.UTF_8.toString()); return bos.toString(StandardCharsets.UTF_8.toString());
} else { } else {
return response.body().string(); return Objects.requireNonNull(response.body()).string();
} }
} }

View File

@ -69,7 +69,7 @@ public class CherryTv extends AbstractSite {
@Override @Override
public synchronized boolean login() throws IOException { public synchronized boolean login() throws IOException {
return false; return getHttpClient().login();
} }
@Override @Override
@ -99,7 +99,7 @@ public class CherryTv extends AbstractSite {
@Override @Override
public boolean supportsFollow() { public boolean supportsFollow() {
return false; return true;
} }
@Override @Override
@ -129,7 +129,7 @@ public class CherryTv extends AbstractSite {
try (Response response = getHttpClient().execute(req)) { try (Response response = getHttpClient().execute(req)) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
JSONObject json = new JSONObject(Objects.requireNonNull(response.body()).string()); JSONObject json = new JSONObject(Objects.requireNonNull(response.body()).string());
LOG.debug(json.toString(2)); LOG.trace(json.toString(2));
JSONObject data = json.getJSONObject("data"); JSONObject data = json.getJSONObject("data");
JSONObject searchResult = data.getJSONObject("searchResult"); JSONObject searchResult = data.getJSONObject("searchResult");
JSONArray streamers = searchResult.getJSONArray("streamers"); JSONArray streamers = searchResult.getJSONArray("streamers");
@ -159,7 +159,8 @@ public class CherryTv extends AbstractSite {
@Override @Override
public boolean credentialsAvailable() { public boolean credentialsAvailable() {
return false; String username = getConfig().getSettings().cherryTvUsername;
return username != null && !username.trim().isEmpty();
} }
@Override @Override

View File

@ -64,7 +64,7 @@ public class CherryTvHttpClient extends HttpClient {
try (Response response = execute(request)) { try (Response response = execute(request)) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
JSONObject resp = new JSONObject(Objects.requireNonNull(response.body()).string()); JSONObject resp = new JSONObject(Objects.requireNonNull(response.body()).string());
LOG.trace(resp.toString(2)); if (resp.has("data")) {
JSONObject data = resp.getJSONObject("data"); JSONObject data = resp.getJSONObject("data");
JSONObject login = data.getJSONObject("login"); JSONObject login = data.getJSONObject("login");
loggedIn = login.optBoolean("success"); loggedIn = login.optBoolean("success");
@ -72,6 +72,10 @@ public class CherryTvHttpClient extends HttpClient {
saveAsSessionCookie(jwt); saveAsSessionCookie(jwt);
LOG.debug("Login successful"); LOG.debug("Login successful");
return loggedIn; return loggedIn;
} else {
LOG.error(resp.toString(2));
return false;
}
} else { } else {
throw new HttpException(response.code(), response.message()); throw new HttpException(response.code(), response.message());
} }

View File

@ -9,9 +9,12 @@ import com.squareup.moshi.JsonWriter;
import ctbrec.AbstractModel; import ctbrec.AbstractModel;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.NotImplementedExcetion; import ctbrec.NotImplementedExcetion;
import ctbrec.StringUtil;
import ctbrec.io.HttpException; import ctbrec.io.HttpException;
import ctbrec.recorder.download.StreamSource; import ctbrec.recorder.download.StreamSource;
import okhttp3.MediaType;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response; import okhttp3.Response;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
@ -21,9 +24,6 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*; import java.util.*;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@ -55,26 +55,10 @@ public class CherryTvModel extends AbstractModel {
.build(); .build();
try (Response resp = site.getHttpClient().execute(req)) { try (Response resp = site.getHttpClient().execute(req)) {
String body = Objects.requireNonNull(resp.body()).string(); String body = Objects.requireNonNull(resp.body()).string();
Files.write(Paths.get("/tmp/mdl.html"), body.getBytes(StandardCharsets.UTF_8));
Matcher m = NEXT_DATA.matcher(body); Matcher m = NEXT_DATA.matcher(body);
if (m.find()) { if (m.find()) {
JSONObject json = new JSONObject(m.group(1)); JSONObject json = new JSONObject(m.group(1));
JSONObject apolloState = json.getJSONObject("props").getJSONObject("pageProps").getJSONObject("apolloState"); updateModelProperties(json);
online = false;
onlineState = OFFLINE;
for (Iterator<String> iter = apolloState.keys(); iter.hasNext();) {
String key = iter.next();
if (key.startsWith("Broadcast:")) {
JSONObject broadcast = apolloState.getJSONObject(key);
setDisplayName(broadcast.optString("title"));
id = broadcast.getString("id");
online = broadcast.optString("showStatus").equalsIgnoreCase("Public")
&& broadcast.optString("broadcastStatus").equalsIgnoreCase("Live");
onlineState = online ? ONLINE : OFFLINE;
masterPlaylistUrl = broadcast.optString("pullUrl", null);
break;
}
}
} else { } else {
LOG.error("NEXT_DATA not found in model page {}", getUrl()); LOG.error("NEXT_DATA not found in model page {}", getUrl());
return false; return false;
@ -86,6 +70,27 @@ public class CherryTvModel extends AbstractModel {
return online; return online;
} }
private void updateModelProperties(JSONObject json) {
LOG.trace(json.toString(2));
JSONObject apolloState = json.getJSONObject("props").getJSONObject("pageProps").getJSONObject("apolloState");
online = false;
onlineState = OFFLINE;
for (Iterator<String> iter = apolloState.keys(); iter.hasNext(); ) {
String key = iter.next();
if (key.startsWith("Broadcast:")) {
JSONObject broadcast = apolloState.getJSONObject(key);
setDisplayName(broadcast.optString("title"));
online = broadcast.optString("showStatus").equalsIgnoreCase("Public")
&& broadcast.optString("broadcastStatus").equalsIgnoreCase("Live");
onlineState = online ? ONLINE : OFFLINE;
masterPlaylistUrl = broadcast.optString("pullUrl", null);
} else if (key.startsWith("Streamer:")) {
JSONObject streamer = apolloState.getJSONObject(key);
id = streamer.getString("id");
}
}
}
public void setOnline(boolean online) { public void setOnline(boolean online) {
this.online = online; this.online = online;
} }
@ -125,7 +130,7 @@ public class CherryTvModel extends AbstractModel {
String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1); String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1);
String segmentUri = baseUrl + playlist.getUri(); String segmentUri = baseUrl + playlist.getUri();
src.mediaPlaylistUrl = segmentUri; src.mediaPlaylistUrl = segmentUri;
if(src.mediaPlaylistUrl.contains("?")) { if (src.mediaPlaylistUrl.contains("?")) {
src.mediaPlaylistUrl = src.mediaPlaylistUrl.substring(0, src.mediaPlaylistUrl.lastIndexOf('?')); src.mediaPlaylistUrl = src.mediaPlaylistUrl.substring(0, src.mediaPlaylistUrl.lastIndexOf('?'));
} }
LOG.trace("Media playlist {}", src.mediaPlaylistUrl); LOG.trace("Media playlist {}", src.mediaPlaylistUrl);
@ -172,18 +177,18 @@ public class CherryTvModel extends AbstractModel {
@Override @Override
public int[] getStreamResolution(boolean failFast) throws ExecutionException { public int[] getStreamResolution(boolean failFast) throws ExecutionException {
if(resolution == null) { if (resolution == null) {
if(failFast) { if (failFast) {
return new int[2]; return new int[2];
} }
try { try {
if(!isOnline()) { if (!isOnline()) {
return new int[2]; return new int[2];
} }
List<StreamSource> sources = getStreamSources(); List<StreamSource> sources = getStreamSources();
Collections.sort(sources); Collections.sort(sources);
StreamSource best = sources.get(sources.size()-1); StreamSource best = sources.get(sources.size() - 1);
resolution = new int[] {best.width, best.height}; resolution = new int[]{best.width, best.height};
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage());
@ -192,22 +197,81 @@ public class CherryTvModel extends AbstractModel {
LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage());
resolution = new int[2]; resolution = new int[2];
} }
return resolution;
} else {
return resolution;
} }
return resolution;
} }
@Override @Override
public boolean follow() throws IOException { public boolean follow() throws IOException {
// POST https://cherry.tv/graphql return followUnfollow("follow", "a7a8241014074f5c02ac83863a47f7579e5f03167a7ec424ff65ad045c7fcf6f");
// {"operationName":"follow","variables":{"userId":"1391"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"a7a8241014074f5c02ac83863a47f7579e5f03167a7ec424ff65ad045c7fcf6f"}}}
return false;
} }
@Override @Override
public boolean unfollow() throws IOException { public boolean unfollow() throws IOException {
return followUnfollow("unfollow", "e91f8f5a60d33efb2dfb3348b977b78358862d3a5cd5ef0011a6aa6bb65d0bd4");
}
private boolean followUnfollow(String action, String persistedQueryHash) throws IOException {
Request request = createFollowUnfollowRequest(action, persistedQueryHash);
LOG.debug("Sending follow request for model {} with ID {}", getName(), getId());
try (Response response = getSite().getHttpClient().execute(request)) {
if (response.isSuccessful()) {
String responseBody = Objects.requireNonNull(response.body(), "HTTP response body is null").string();
LOG.debug(responseBody);
JSONObject resp = new JSONObject(responseBody);
if (resp.has("data") && !resp.isNull("data")) {
JSONObject data = resp.getJSONObject("data");
if (data.has(action + "User")) {
return data.getJSONObject(action + "User").optBoolean("success");
}
} else if (resp.has("errors")) {
JSONObject first = resp.getJSONArray("errors").getJSONObject(0);
if (first.optString("message").matches("You have .*? the user")) {
return true;
}
}
LOG.debug(resp.toString(2));
return false; return false;
} else {
throw new HttpException(response.code(), response.message());
}
}
}
private Request createFollowUnfollowRequest(String action, String persistedQueryHash) throws IOException {
if (StringUtil.isBlank(id)) {
try {
// if the id is not set yet, we call isOnline(true), where it gets set
isOnline(true);
} catch (ExecutionException e) {
throw new IOException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException(e);
}
}
JSONObject body = new JSONObject()
.put("operationName", action)
.put("variables", new JSONObject()
.put("userId", Objects.requireNonNull(id, "Model ID is null"))
)
.put("query", "mutation " + action + "($userId: ID!) {\n " + action + "User(userId: $userId) {\n success\n __typename\n }\n}\n")
.put("extensions", new JSONObject()
.put("persistedQuery", new JSONObject()
.put("version", 1)
.put("sha256Hash", persistedQueryHash)
)
);
RequestBody requestBody = RequestBody.create(body.toString(), MediaType.parse("application/json"));
return new Request.Builder()
.url(CherryTv.BASE_URL + "/graphql")
.header(REFERER, CherryTv.BASE_URL)
.header(ORIGIN, CherryTv.BASE_URL)
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
.post(requestBody)
.build();
} }
public void mapOnlineState(String roomState) { public void mapOnlineState(String roomState) {
@ -237,9 +301,11 @@ public class CherryTvModel extends AbstractModel {
@Override @Override
public void readSiteSpecificData(JsonReader reader) throws IOException { public void readSiteSpecificData(JsonReader reader) throws IOException {
if (reader.hasNext()) {
reader.nextName(); reader.nextName();
id = reader.nextString(); id = reader.nextString();
} }
}
@Override @Override
public void writeSiteSpecificData(JsonWriter writer) throws IOException { public void writeSiteSpecificData(JsonWriter writer) throws IOException {