diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java index c0442391..f5af4965 100644 --- a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java @@ -102,7 +102,6 @@ public class CherryTvUpdateService extends PaginatedScheduledService { for (int i = 0; i < broadcasts.length(); i++) { JSONObject broadcast = broadcasts.getJSONObject(i); CherryTvModel model = site.createModel(broadcast.optString("username")); - model.setId(broadcast.getString("id")); model.setDisplayName(broadcast.optString("title")); model.setDescription(broadcast.optString("description")); model.setPreview(broadcast.optString("thumbnailUrl")); diff --git a/common/src/main/java/ctbrec/io/HttpClient.java b/common/src/main/java/ctbrec/io/HttpClient.java index da4b4262..bbfc4bd2 100644 --- a/common/src/main/java/ctbrec/io/HttpClient.java +++ b/common/src/main/java/ctbrec/io/HttpClient.java @@ -1,8 +1,16 @@ package ctbrec.io; -import static ctbrec.io.HttpConstants.*; -import static java.nio.charset.StandardCharsets.*; +import com.squareup.moshi.JsonAdapter; +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.File; import java.io.FileOutputStream; @@ -14,44 +22,16 @@ import java.nio.file.Files; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; -import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; 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.stream.Collectors; import java.util.zip.GZIPInputStream; -import javax.net.ssl.KeyManager; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocketFactory; -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; +import static ctbrec.io.HttpConstants.ACCEPT_ENCODING_GZIP; +import static ctbrec.io.HttpConstants.CONTENT_ENCODING; +import static java.nio.charset.StandardCharsets.UTF_8; public abstract class HttpClient { 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); protected OkHttpClient client; - protected CookieJarImpl cookieJar = new CookieJarImpl(); + protected CookieJarImpl cookieJar; protected Config config; protected boolean loggedIn = false; protected int loginTries = 0; - private String name; + private final String name; protected HttpClient(String name, Config config) { this.name = name; @@ -145,8 +125,7 @@ public abstract class HttpClient { .connectionPool(GLOBAL_HTTP_CONN_POOL) .connectTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS) .readTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS) - //.addNetworkInterceptor(new LoggingInterceptor()) - ; + .addNetworkInterceptor(new LoggingInterceptor()); ProxyType proxyType = config.getSettings().proxyType; 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) { acceptAllTlsCerts(builder); } @@ -177,8 +156,8 @@ public abstract class HttpClient { X509Certificate[] x509Certificates = new X509Certificate[0]; return x509Certificates; } - @Override public void checkServerTrusted(final X509Certificate[] chain, final String authType) throws CertificateException {} - @Override public void checkClientTrusted(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) { /* noop*/ } }; try { @@ -190,7 +169,7 @@ public abstract class HttpClient { sslContext.init(keyManagers, trustManagers, secureRandom); SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); builder.sslSocketFactory(sslSocketFactory, x509TrustManager); - builder.hostnameVerifier((name, sslSession) -> true); + builder.hostnameVerifier((hostname, sslSession) -> true); } catch (KeyManagementException | NoSuchAlgorithmException e) { 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) { - return new okhttp3.Authenticator() { - @Override - public Request authenticate(Route route, Response response) throws IOException { - String credential = Credentials.basic(username, password); - return response.request().newBuilder().header("Proxy-Authorization", credential).build(); - } + return (route, response) -> { + String credential = Credentials.basic(username, password); + return response.request().newBuilder().header("Proxy-Authorization", credential).build(); }; } public static class SocksProxyAuth extends Authenticator { - private PasswordAuthentication auth; + private final PasswordAuthentication auth; private SocksProxyAuth(String user, String password) { 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 { 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(); byte[] b = new byte[1024]; - int len = -1; + int len; while ((len = gzipIn.read(b)) >= 0) { bos.write(b, 0, len); } return bos.toString(StandardCharsets.UTF_8.toString()); } else { - return response.body().string(); + return Objects.requireNonNull(response.body()).string(); } } diff --git a/common/src/main/java/ctbrec/sites/cherrytv/CherryTv.java b/common/src/main/java/ctbrec/sites/cherrytv/CherryTv.java index 87c5811f..3cdcda7a 100644 --- a/common/src/main/java/ctbrec/sites/cherrytv/CherryTv.java +++ b/common/src/main/java/ctbrec/sites/cherrytv/CherryTv.java @@ -69,7 +69,7 @@ public class CherryTv extends AbstractSite { @Override public synchronized boolean login() throws IOException { - return false; + return getHttpClient().login(); } @Override @@ -99,7 +99,7 @@ public class CherryTv extends AbstractSite { @Override public boolean supportsFollow() { - return false; + return true; } @Override @@ -129,7 +129,7 @@ public class CherryTv extends AbstractSite { try (Response response = getHttpClient().execute(req)) { if (response.isSuccessful()) { 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 searchResult = data.getJSONObject("searchResult"); JSONArray streamers = searchResult.getJSONArray("streamers"); @@ -159,7 +159,8 @@ public class CherryTv extends AbstractSite { @Override public boolean credentialsAvailable() { - return false; + String username = getConfig().getSettings().cherryTvUsername; + return username != null && !username.trim().isEmpty(); } @Override diff --git a/common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java index 503bc951..387cf1f9 100644 --- a/common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java +++ b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java @@ -64,14 +64,18 @@ public class CherryTvHttpClient extends HttpClient { try (Response response = execute(request)) { if (response.isSuccessful()) { JSONObject resp = new JSONObject(Objects.requireNonNull(response.body()).string()); - LOG.trace(resp.toString(2)); - JSONObject data = resp.getJSONObject("data"); - JSONObject login = data.getJSONObject("login"); - loggedIn = login.optBoolean("success"); - String jwt = login.optString("token"); - saveAsSessionCookie(jwt); - LOG.debug("Login successful"); - return loggedIn; + if (resp.has("data")) { + JSONObject data = resp.getJSONObject("data"); + JSONObject login = data.getJSONObject("login"); + loggedIn = login.optBoolean("success"); + String jwt = login.optString("token"); + saveAsSessionCookie(jwt); + LOG.debug("Login successful"); + return loggedIn; + } else { + LOG.error(resp.toString(2)); + return false; + } } else { throw new HttpException(response.code(), response.message()); } diff --git a/common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java index f0cf72dc..ddb08ceb 100644 --- a/common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java +++ b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java @@ -9,9 +9,12 @@ import com.squareup.moshi.JsonWriter; import ctbrec.AbstractModel; import ctbrec.Config; import ctbrec.NotImplementedExcetion; +import ctbrec.StringUtil; import ctbrec.io.HttpException; import ctbrec.recorder.download.StreamSource; +import okhttp3.MediaType; import okhttp3.Request; +import okhttp3.RequestBody; import okhttp3.Response; import org.json.JSONException; import org.json.JSONObject; @@ -21,9 +24,6 @@ import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.io.IOException; 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.concurrent.ExecutionException; import java.util.regex.Matcher; @@ -55,26 +55,10 @@ public class CherryTvModel extends AbstractModel { .build(); try (Response resp = site.getHttpClient().execute(req)) { 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); if (m.find()) { JSONObject json = new JSONObject(m.group(1)); - JSONObject apolloState = json.getJSONObject("props").getJSONObject("pageProps").getJSONObject("apolloState"); - online = false; - onlineState = OFFLINE; - for (Iterator 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; - } - } + updateModelProperties(json); } else { LOG.error("NEXT_DATA not found in model page {}", getUrl()); return false; @@ -86,6 +70,27 @@ public class CherryTvModel extends AbstractModel { 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 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) { this.online = online; } @@ -125,7 +130,7 @@ public class CherryTvModel extends AbstractModel { String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1); String segmentUri = baseUrl + playlist.getUri(); src.mediaPlaylistUrl = segmentUri; - if(src.mediaPlaylistUrl.contains("?")) { + if (src.mediaPlaylistUrl.contains("?")) { src.mediaPlaylistUrl = src.mediaPlaylistUrl.substring(0, src.mediaPlaylistUrl.lastIndexOf('?')); } LOG.trace("Media playlist {}", src.mediaPlaylistUrl); @@ -172,18 +177,18 @@ public class CherryTvModel extends AbstractModel { @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { - if(resolution == null) { - if(failFast) { + if (resolution == null) { + if (failFast) { return new int[2]; } try { - if(!isOnline()) { + if (!isOnline()) { return new int[2]; } List sources = getStreamSources(); Collections.sort(sources); - StreamSource best = sources.get(sources.size()-1); - resolution = new int[] {best.width, best.height}; + StreamSource best = sources.get(sources.size() - 1); + resolution = new int[]{best.width, best.height}; } catch (InterruptedException e) { Thread.currentThread().interrupt(); LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); @@ -192,38 +197,97 @@ public class CherryTvModel extends AbstractModel { LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); resolution = new int[2]; } - return resolution; - } else { - return resolution; } + return resolution; } @Override public boolean follow() throws IOException { - // POST https://cherry.tv/graphql - // {"operationName":"follow","variables":{"userId":"1391"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"a7a8241014074f5c02ac83863a47f7579e5f03167a7ec424ff65ad045c7fcf6f"}}} - return false; + return followUnfollow("follow", "a7a8241014074f5c02ac83863a47f7579e5f03167a7ec424ff65ad045c7fcf6f"); } @Override public boolean unfollow() throws IOException { - return false; + 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; + } 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) { switch (roomState) { - case "private": - case "fullprivate": - setOnlineState(PRIVATE); - break; - case "group": - case "public": - setOnlineState(ONLINE); - setOnline(true); - break; - default: - LOG.debug(roomState); - setOnlineState(OFFLINE); + case "private": + case "fullprivate": + setOnlineState(PRIVATE); + break; + case "group": + case "public": + setOnlineState(ONLINE); + setOnline(true); + break; + default: + LOG.debug(roomState); + setOnlineState(OFFLINE); } } @@ -237,8 +301,10 @@ public class CherryTvModel extends AbstractModel { @Override public void readSiteSpecificData(JsonReader reader) throws IOException { - reader.nextName(); - id = reader.nextString(); + if (reader.hasNext()) { + reader.nextName(); + id = reader.nextString(); + } } @Override