diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 831fbbf3..f83ed92d 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -23,6 +23,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import ctbrec.sites.secretfriends.SecretFriends; +import ctbrec.sites.cherrytv.CherryTv; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -175,6 +176,7 @@ public class CamrecApplication extends Application { sites.add(new Cam4()); sites.add(new Camsoda()); sites.add(new Chaturbate()); + sites.add(new CherryTv()); sites.add(new Fc2Live()); sites.add(new Flirt4Free()); sites.add(new LiveJasmin()); @@ -195,7 +197,7 @@ public class CamrecApplication extends Application { } private void initSites() { - sites.stream().forEach(site -> { + sites.forEach(site -> { try { site.setRecorder(recorder); site.setConfig(config); diff --git a/client/src/main/java/ctbrec/ui/SiteUiFactory.java b/client/src/main/java/ctbrec/ui/SiteUiFactory.java index b97c1760..36b27ed0 100644 --- a/client/src/main/java/ctbrec/ui/SiteUiFactory.java +++ b/client/src/main/java/ctbrec/ui/SiteUiFactory.java @@ -6,6 +6,7 @@ import ctbrec.sites.bonga.BongaCams; import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.sites.cherrytv.CherryTv; import ctbrec.sites.fc2live.Fc2Live; import ctbrec.sites.flirt4free.Flirt4Free; import ctbrec.sites.jasmin.LiveJasmin; @@ -21,6 +22,7 @@ import ctbrec.ui.sites.bonga.BongaCamsSiteUi; import ctbrec.ui.sites.cam4.Cam4SiteUi; import ctbrec.ui.sites.camsoda.CamsodaSiteUi; import ctbrec.ui.sites.chaturbate.ChaturbateSiteUi; +import ctbrec.ui.sites.cherrytv.CherryTvSiteUi; import ctbrec.ui.sites.fc2live.Fc2LiveSiteUi; import ctbrec.ui.sites.flirt4free.Flirt4FreeSiteUi; import ctbrec.ui.sites.jasmin.LiveJasminSiteUi; @@ -39,6 +41,7 @@ public class SiteUiFactory { private static Cam4SiteUi cam4SiteUi; private static CamsodaSiteUi camsodaSiteUi; private static ChaturbateSiteUi ctbSiteUi; + private static CherryTvSiteUi cherryTvSiteUi; private static Fc2LiveSiteUi fc2SiteUi; private static Flirt4FreeSiteUi flirt4FreeSiteUi; private static LiveJasminSiteUi jasminSiteUi; @@ -52,7 +55,7 @@ public class SiteUiFactory { private SiteUiFactory () {} - public static synchronized SiteUI getUi(Site site) { + public static synchronized SiteUI getUi(Site site) { // NOSONAR if (site instanceof AmateurTv) { if (amateurTvUi == null) { amateurTvUi = new AmateurTvSiteUi((AmateurTv) site); @@ -78,6 +81,11 @@ public class SiteUiFactory { ctbSiteUi = new ChaturbateSiteUi((Chaturbate) site); } return ctbSiteUi; + } else if (site instanceof CherryTv) { + if (cherryTvSiteUi == null) { + cherryTvSiteUi = new CherryTvSiteUi((CherryTv) site); + } + return cherryTvSiteUi; } else if (site instanceof Fc2Live) { if (fc2SiteUi == null) { fc2SiteUi = new Fc2LiveSiteUi((Fc2Live) site); diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvConfigUI.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvConfigUI.java new file mode 100644 index 00000000..82f0c8e4 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvConfigUI.java @@ -0,0 +1,88 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.Config; +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.settings.SettingsTab; +import ctbrec.ui.sites.AbstractConfigUI; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.*; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class CherryTvConfigUI extends AbstractConfigUI { + private final CherryTv site; + + public CherryTvConfigUI(CherryTv cherryTv) { + this.site = cherryTv; + } + + @Override + public Parent createConfigPanel() { + var layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var enabled = new CheckBox(); + enabled.setSelected(!settings.disabledSites.contains(site.getName())); + enabled.setOnAction(e -> { + if (enabled.isSelected()) { + settings.disabledSites.remove(site.getName()); + } else { + settings.disabledSites.add(site.getName()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + layout.add(new Label(site.getName() + " User"), 0, row); + var username = new TextField(Config.getInstance().getSettings().cherryTvUsername); + username.textProperty().addListener((ob, o, n) -> { + if (!n.equals(Config.getInstance().getSettings().cherryTvUsername)) { + Config.getInstance().getSettings().cherryTvUsername = username.getText(); + site.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(username, true); + GridPane.setHgrow(username, Priority.ALWAYS); + GridPane.setColumnSpan(username, 2); + layout.add(username, 1, row++); + + layout.add(new Label(site.getName() + " Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().cherryTvPassword); + password.textProperty().addListener((ob, o, n) -> { + if (!n.equals(Config.getInstance().getSettings().cherryTvPassword)) { + Config.getInstance().getSettings().cherryTvPassword = password.getText(); + site.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(site.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 2); + + GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedTab.java new file mode 100644 index 00000000..07bb6684 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedTab.java @@ -0,0 +1,90 @@ + +package ctbrec.ui.sites.cherrytv; + +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.concurrent.WorkerStateEvent; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; + +public class CherryTvFollowedTab extends ThumbOverviewTab implements FollowedTab { + private final Label status; + private ToggleGroup group; + + public CherryTvFollowedTab(String title, CherryTv site) { + super(title, new CherryTvFollowedUpdateService(site), site); + status = new Label("Logging in..."); + grid.getChildren().add(status); + } + + @Override + protected void createGui() { + super.createGui(); + group = new ToggleGroup(); + addOnlineOfflineSelector(); + setFilter(true); + } + + private void addOnlineOfflineSelector() { + var online = new RadioButton("online"); + online.setToggleGroup(group); + var offline = new RadioButton("offline"); + offline.setToggleGroup(group); + pagination.getChildren().add(online); + pagination.getChildren().add(offline); + HBox.setMargin(online, new Insets(5, 5, 5, 40)); + HBox.setMargin(offline, new Insets(5, 5, 5, 5)); + online.setSelected(true); + group.selectedToggleProperty().addListener(e -> { + setFilter(online.isSelected()); + queue.clear(); + updateService.restart(); + }); + } + + private void setFilter(boolean online) { + ((CherryTvUpdateService) updateService).setFilter(m -> { + try { + return m.isOnline(false) == online; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception ex) { + return false; + } + }); + } + + @Override + protected void onSuccess() { + grid.getChildren().remove(status); + super.onSuccess(); + } + + @Override + protected void onFail(WorkerStateEvent event) { + status.setText("Login failed"); + super.onFail(event); + } + + @Override + public void selected() { + status.setText("Logging in..."); + super.selected(); + } + + public void setScene(Scene scene) { + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (this.isSelected() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedUpdateService.java new file mode 100644 index 00000000..92ce5198 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedUpdateService.java @@ -0,0 +1,53 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.Model; +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.sites.cherrytv.CherryTvModel; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static ctbrec.Model.State.OFFLINE; +import static ctbrec.Model.State.ONLINE; + +public class CherryTvFollowedUpdateService extends CherryTvUpdateService { + + private static final Logger LOG = LoggerFactory.getLogger(CherryTvFollowedUpdateService.class); + + public CherryTvFollowedUpdateService(CherryTv site) { + super(site.getBaseUrl() + "/graphql?operationName=FindFollowings&variables={\"cursor\":${offset},\"limit\":${limit}}&extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"7d2cf16b113dc1d57af02685e249e28df9649ea598717dc2c877294529ae0cb3\"}}", + site,true); + } + + protected List parseModels(String body) throws IOException { + var json = new JSONObject(body); + if (json.has("errors")) { + JSONArray errors = json.getJSONArray("errors"); + JSONObject first = errors.getJSONObject(0); + throw new IOException(first.getString("message")); + } + List models = new ArrayList<>(); + try { + JSONArray followings = json.getJSONObject("data").getJSONObject("followinglist").getJSONArray("followings"); + for (int i = 0; i < followings.length(); i++) { + JSONObject following = followings.getJSONObject(i); + CherryTvModel model = site.createModel(following.optString("username")); + model.setId(following.getString("id")); + model.setPreview(following.optString("img")); + var online = following.optString("status").equalsIgnoreCase("Live"); + model.setOnline(online); + model.setOnlineState(online ? ONLINE : OFFLINE); + models.add(model); + } + } catch (JSONException e) { + LOG.error("Couldn't parse JSON, the structure might have changed", e); + } + return models; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java new file mode 100644 index 00000000..33c62dcf --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java @@ -0,0 +1,43 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class CherryTvSiteUi extends AbstractSiteUi { + private static final Logger LOG = LoggerFactory.getLogger(CherryTvSiteUi.class); + + private final CherryTv cherryTv; + private CherryTvTabProvider tabProvider; + private CherryTvConfigUI configUi; + + public CherryTvSiteUi(CherryTv cherryTv) { + this.cherryTv = cherryTv; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new CherryTvTabProvider(cherryTv); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new CherryTvConfigUI(cherryTv); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + return cherryTv.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvTabProvider.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvTabProvider.java new file mode 100644 index 00000000..6b283ffa --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvTabProvider.java @@ -0,0 +1,53 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.recorder.Recorder; +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.ui.tabs.TabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class CherryTvTabProvider implements TabProvider { + + private final CherryTv site; + private final Recorder recorder; + private final CherryTvFollowedTab followedTab; + + public CherryTvTabProvider(CherryTv cherryTv) { + this.site = cherryTv; + this.recorder = cherryTv.getRecorder(); + + followedTab = new CherryTvFollowedTab("Following", site); + followedTab.setImageAspectRatio(9.0 / 16.0); + followedTab.preserveAspectRatioProperty().set(false); + followedTab.setRecorder(recorder); + } + + @Override + public List getTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Female", site.getBaseUrl() + "/graphql?operationName=findBroadcastsByPage&variables={\"slug\":\"female\",\"tag\":null,\"following\":null,\"limit\":${limit},\"cursor\":${offset}}&extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"f1e214ca901e525301fcc6966cf081ee95ef777974ec184897c4a2cf15e9ac6f\"}}")); + tabs.add(createTab("Trans", site.getBaseUrl() + "/graphql?operationName=findBroadcastsByPage&variables={\"slug\":\"trans\",\"tag\":null,\"following\":null,\"limit\":${limit},\"cursor\":${offset}}&extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"f1e214ca901e525301fcc6966cf081ee95ef777974ec184897c4a2cf15e9ac6f\"}}")); + tabs.add(createTab("Group Show", site.getBaseUrl() + "/graphql?operationName=findBroadcastsByPage&variables={\"slug\":\"groupshow\",\"tag\":null,\"following\":null,\"limit\":${limit},\"cursor\":${offset}}&extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"f1e214ca901e525301fcc6966cf081ee95ef777974ec184897c4a2cf15e9ac6f\"}}")); + tabs.add(followedTab); + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + + private Tab createTab(String name, String url) { + var updateService = new CherryTvUpdateService(url, site, false); + var tab = new ThumbOverviewTab(name, updateService, site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.preserveAspectRatioProperty().set(false); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java new file mode 100644 index 00000000..f5af4965 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java @@ -0,0 +1,129 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.sites.cherrytv.CherryTvModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static ctbrec.Model.State.OFFLINE; +import static ctbrec.Model.State.ONLINE; +import static ctbrec.io.HttpConstants.ACCEPT_LANGUAGE; +import static ctbrec.io.HttpConstants.USER_AGENT; + +public class CherryTvUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(CherryTvUpdateService.class); + private static final int MODELS_PER_PAGE = 100; + + private final String url; + private final boolean loginRequired; + protected final CherryTv site; + private Predicate filter; + + public CherryTvUpdateService(String url, CherryTv site, boolean loginRequired) { + this.site = site; + this.url = url; + this.loginRequired = loginRequired; + + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setName("CherryTvUpdateService"); + return t; + }); + setExecutor(executor); + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (loginRequired && !site.getHttpClient().login()) { + throw new IOException("Login failed"); + } + + String pageUrl = CherryTvUpdateService.this.url; + pageUrl = pageUrl.replace("${limit}", String.valueOf(MODELS_PER_PAGE)); + pageUrl = pageUrl.replace("${offset}", String.valueOf((page - 1) * MODELS_PER_PAGE)); + LOG.debug("Fetching page {}", pageUrl); + + var request = new Request.Builder() + .url(pageUrl) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + try (var response = site.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + String body = Objects.requireNonNull(response.body()).string(); + Stream stream = parseModels(body).stream(); + if (filter != null) { + stream = stream.filter(filter); + } + return stream.collect(Collectors.toList()); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + }; + } + + protected List parseModels(String body) throws IOException { + var json = new JSONObject(body); + if (json.has("errors")) { + JSONArray errors = json.getJSONArray("errors"); + JSONObject first = errors.getJSONObject(0); + throw new IOException(first.getString("message")); + } + List models = new ArrayList<>(); + try { + JSONArray broadcasts = json.getJSONObject("data").getJSONObject("broadcasts").getJSONArray("broadcasts"); + for (int i = 0; i < broadcasts.length(); i++) { + JSONObject broadcast = broadcasts.getJSONObject(i); + CherryTvModel model = site.createModel(broadcast.optString("username")); + model.setDisplayName(broadcast.optString("title")); + model.setDescription(broadcast.optString("description")); + model.setPreview(broadcast.optString("thumbnailUrl")); + var online = broadcast.optString("showStatus").equalsIgnoreCase("Public") + && broadcast.optString("broadcastStatus").equalsIgnoreCase("Live"); + model.setOnline(online); + model.setOnlineState(online ? ONLINE : OFFLINE); + JSONArray tags = broadcast.optJSONArray("tags"); + if (tags != null) { + for (int j = 0; j < tags.length(); j++) { + model.getTags().add(tags.getString(j)); + } + } + models.add(model); + } + } catch (JSONException e) { + LOG.error("Couldn't parse JSON, the structure might have changed", e); + } + return models; + } + + public void setFilter(Predicate filter) { + this.filter = filter; + } +} diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java index 73deda30..7470de4f 100644 --- a/common/src/main/java/ctbrec/Config.java +++ b/common/src/main/java/ctbrec/Config.java @@ -49,9 +49,9 @@ public class Config { private static Config instance; private Settings settings; - private String filename; - private List sites; - private File configDir; + private final String filename; + private final List sites; + private final File configDir; /** * to temporarily disable saving of the config * this is useful for the SettingsTab, because setting the initial values of some components causes an immediate save @@ -96,7 +96,7 @@ public class Config { fileContent[2] = ' '; } String json = new String(fileContent, UTF_8).trim(); - settings = adapter.fromJson(json); + settings = Objects.requireNonNull(adapter.fromJson(json)); settings.httpTimeout = Math.max(settings.httpTimeout, 10_000); if (settings.recordingsDir.endsWith("/")) { settings.recordingsDir = settings.recordingsDir.substring(0, settings.recordingsDir.length() - 1); diff --git a/common/src/main/java/ctbrec/LoggingInterceptor.java b/common/src/main/java/ctbrec/LoggingInterceptor.java index b2a4457f..8a8a52dc 100644 --- a/common/src/main/java/ctbrec/LoggingInterceptor.java +++ b/common/src/main/java/ctbrec/LoggingInterceptor.java @@ -23,7 +23,7 @@ public class LoggingInterceptor implements Interceptor { } Response response = chain.proceed(request); long t2 = System.nanoTime(); - LOG.debug("OkHttp Received response for {} in {}\n{}", response.request().url(), (t2 - t1) / 1e6d, response.headers()); + LOG.debug("OkHttp Received {} response for {} in {}ms\n{}", response.code(), response.request().url(), (t2 - t1) / 1e6d, response.headers()); return response; } } diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 6d22966c..6c3c4e3a 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -19,7 +19,7 @@ public class Settings { ONE_PER_MODEL("one directory for each model"), ONE_PER_RECORDING("one directory for each recording"); - private String description; + private final String description; DirectoryStructure(String description) { this.description = description; } @@ -58,6 +58,8 @@ public class Settings { public String chaturbateUsername = ""; public String chaturbateBaseUrl = "https://chaturbate.com"; public int chaturbateMsBetweenRequests = 1000; + public String cherryTvPassword = ""; + public String cherryTvUsername = ""; public boolean chooseStreamQuality = false; public String colorAccent = "#FFFFFF"; public String colorBase = "#FFFFFF"; diff --git a/common/src/main/java/ctbrec/io/HttpClient.java b/common/src/main/java/ctbrec/io/HttpClient.java index 332469a8..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; @@ -144,8 +124,8 @@ public abstract class HttpClient { .cookieJar(cookieJar) .connectionPool(GLOBAL_HTTP_CONN_POOL) .connectTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS) - .readTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS); - //.addInterceptor(new LoggingInterceptor()); + .readTimeout(config.getSettings().httpTimeout, TimeUnit.MILLISECONDS) + .addNetworkInterceptor(new LoggingInterceptor()); ProxyType proxyType = config.getSettings().proxyType; if (proxyType == ProxyType.HTTP) { @@ -156,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); } @@ -176,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 { @@ -189,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"); } @@ -253,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()); @@ -326,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 new file mode 100644 index 00000000..3cdcda7a --- /dev/null +++ b/common/src/main/java/ctbrec/sites/cherrytv/CherryTv.java @@ -0,0 +1,184 @@ +package ctbrec.sites.cherrytv; + +import ctbrec.Model; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.sites.AbstractSite; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static ctbrec.Model.State.OFFLINE; +import static ctbrec.Model.State.ONLINE; +import static ctbrec.io.HttpConstants.*; + +public class CherryTv extends AbstractSite { + + private static final Logger LOG = LoggerFactory.getLogger(CherryTv.class); + + public static final String BASE_URL = "https://cherry.tv"; + + private CherryTvHttpClient httpClient; + + @Override + public String getName() { + return "CherryTV"; + } + + @Override + public String getBaseUrl() { + return BASE_URL; + } + + @Override + public String getAffiliateLink() { + return getBaseUrl(); + } + + @Override + public CherryTvModel createModel(String name) { + CherryTvModel model = new CherryTvModel(); + model.setName(name); + model.setUrl(getBaseUrl() + '/' + name); + model.setDescription(""); + model.setSite(this); + return model; + } + + @Override + public Double getTokenBalance() throws IOException { + return 0d; + } + + @Override + public String getBuyTokensLink() { + return getAffiliateLink(); + } + + @Override + public synchronized boolean login() throws IOException { + return getHttpClient().login(); + } + + @Override + public HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new CherryTvHttpClient(getConfig()); + } + return httpClient; + } + + @Override + public void init() throws IOException { + // nothing to do + } + + @Override + public void shutdown() { + if (httpClient != null) { + httpClient.shutdown(); + } + } + + @Override + public boolean supportsTips() { + return false; + } + + @Override + public boolean supportsFollow() { + return true; + } + + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public boolean searchRequiresLogin() { + return false; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + String url = "https://cherry.tv/graphql?operationName=findStreamersBySearch&variables=" + + "{\"limit\":6,\"slug\":\"" + URLEncoder.encode(q, "utf-8") + "\"}&extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"03d2f017fee32e1b6a1d3f816ce226c464a78d8dab28895c321980fbadffc1ef\"}}"; + Request req = new Request.Builder() + .url(url) + .header(USER_AGENT, getConfig().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(REFERER, getBaseUrl()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .build(); + LOG.debug("Search URL: {}", req.url()); + List result = new LinkedList<>(); + try (Response response = getHttpClient().execute(req)) { + if (response.isSuccessful()) { + JSONObject json = new JSONObject(Objects.requireNonNull(response.body()).string()); + LOG.trace(json.toString(2)); + JSONObject data = json.getJSONObject("data"); + JSONObject searchResult = data.getJSONObject("searchResult"); + JSONArray streamers = searchResult.getJSONArray("streamers"); + for (int i = 0; i < streamers.length(); i++) { + JSONObject hit = streamers.getJSONObject(i); + CherryTvModel model = createModel(hit.getString("username")); + model.setId(hit.getString("id")); + boolean online = hit.optString("showStatus").equalsIgnoreCase("Public") + && hit.optString("broadcastStatus").equalsIgnoreCase("Live"); + model.setOnline(online); + model.setOnlineState(online ? ONLINE : OFFLINE); + model.setDescription(hit.getString("description")); + model.setPreview(hit.getString("imageUrl")); + result.add(model); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + return result; + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof CherryTvModel; + } + + @Override + public boolean credentialsAvailable() { + String username = getConfig().getSettings().cherryTvUsername; + return username != null && !username.trim().isEmpty(); + } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://.*?cherry\\.tv/([^/]*?)/?").matcher(url); + if (m.matches()) { + String modelName = m.group(1); + CherryTvModel model = createModel(modelName); + try { + model.isOnline(true); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + LOG.warn("Couldn't determine model id. This could cause problems in the future", e); + } + return model; + } else { + return super.createModelFromUrl(url); + } + } +} diff --git a/common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java new file mode 100644 index 00000000..387cf1f9 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java @@ -0,0 +1,127 @@ +package ctbrec.sites.cherrytv; + +import ctbrec.Config; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import okhttp3.*; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Objects; + +import static ctbrec.io.HttpConstants.*; + +public class CherryTvHttpClient extends HttpClient { + + private static final Logger LOG = LoggerFactory.getLogger(CherryTvHttpClient.class); + + public CherryTvHttpClient(Config config) { + super("cherrytv", config); + } + + @Override + public synchronized boolean login() throws IOException { + if (loggedIn) { + return true; + } + + boolean cookiesWorked = checkLoginSuccess(); + if (cookiesWorked) { + loggedIn = true; + LOG.debug("Logged in with cookies"); + return true; + } + + JSONObject body = new JSONObject() + .put("operationName", "authenticateUser") + .put("variables", new JSONObject() + .put("username", config.getSettings().cherryTvUsername) + .put("password", config.getSettings().cherryTvPassword) + ) + .put("extensions", new JSONObject() + .put("persistedQuery", new JSONObject() + .put("version", 1) + .put("sha256Hash", "9c105878022f9a7d511c12527c70f125606dc25367a4dd56aa63a6af73579087") + ) + ); + + + RequestBody requestBody = RequestBody.create(body.toString(), MediaType.parse("application/json")); + Request request = 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(); + + LOG.debug("Logging in: {}", request.url()); + try (Response response = execute(request)) { + if (response.isSuccessful()) { + JSONObject resp = new JSONObject(Objects.requireNonNull(response.body()).string()); + 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()); + } + } + } + + private void saveAsSessionCookie(String jwt) { + HttpUrl url = HttpUrl.parse(CherryTv.BASE_URL); + Objects.requireNonNull(url); + long expiresAt = Instant.now().plus(1, ChronoUnit.DAYS).getEpochSecond(); + Cookie sessionCookie = new Cookie.Builder() + .name("session") + .value(jwt) + .expiresAt(expiresAt) + .domain(Objects.requireNonNull(url.topPrivateDomain())) + .path("/") + .secure().httpOnly() + .build(); + getCookieJar().saveFromResponse(url, Collections.singletonList(sessionCookie)); + } + + private boolean checkLoginSuccess() { + String url = CherryTv.BASE_URL + "/graphql?operationName=FindFollowings&variables={\"cursor\":\"0\",\"limit\":20}&extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"7d2cf16b113dc1d57af02685e249e28df9649ea598717dc2c877294529ae0cb3\"}}"; + Request request = new Request.Builder() + .url(url) + .header(REFERER, CherryTv.BASE_URL) + .header(ORIGIN, CherryTv.BASE_URL) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + + try (Response response = execute(request)) { + String body = Objects.requireNonNull(response.body()).string(); + LOG.debug("Login body: {}", body); + if (response.isSuccessful()) { + JSONObject json = new JSONObject(body); + if (json.has("errors")) { + LOG.error(json.toString(2)); + return false; + } else { + return json.optString("__typename").equals("FollowingList"); + } + } + return false; + } catch (Exception e) { + LOG.error("Login check failed", e); + return false; + } + } +} diff --git a/common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java new file mode 100644 index 00000000..ddb08ceb --- /dev/null +++ b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java @@ -0,0 +1,314 @@ +package ctbrec.sites.cherrytv; + +import com.iheartradio.m3u8.*; +import com.iheartradio.m3u8.data.MasterPlaylist; +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.PlaylistData; +import com.squareup.moshi.JsonReader; +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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static ctbrec.Model.State.*; +import static ctbrec.io.HttpConstants.*; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class CherryTvModel extends AbstractModel { + + private static final Logger LOG = LoggerFactory.getLogger(CherryTvModel.class); + private static final Pattern NEXT_DATA = Pattern.compile(""); + + private boolean online = false; + private int[] resolution; + private String masterPlaylistUrl; + private String id; + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + if (ignoreCache) { + String url = getUrl(); + Request req = new Request.Builder().url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, "*") + .header(ACCEPT_LANGUAGE, "en") + .header(REFERER, getSite().getBaseUrl()) + .build(); + try (Response resp = site.getHttpClient().execute(req)) { + String body = Objects.requireNonNull(resp.body()).string(); + Matcher m = NEXT_DATA.matcher(body); + if (m.find()) { + JSONObject json = new JSONObject(m.group(1)); + updateModelProperties(json); + } else { + LOG.error("NEXT_DATA not found in model page {}", getUrl()); + return false; + } + } catch (JSONException e) { + LOG.error("Unable to determine online state for {}. Probably the JSON structure in NEXT_DATA changed", getName()); + } + } + 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; + } + + @Override + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if (!failFast) { + try { + isOnline(true); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + onlineState = OFFLINE; + } catch (IOException | ExecutionException e) { + onlineState = OFFLINE; + } + } + return onlineState; + } + + @Override + public void setOnlineState(State onlineState) { + this.onlineState = onlineState; + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { + try { + isOnline(true); + MasterPlaylist masterPlaylist = getMasterPlaylist(); + List sources = new ArrayList<>(); + for (PlaylistData playlist : masterPlaylist.getPlaylists()) { + if (playlist.hasStreamInfo()) { + StreamSource src = new StreamSource(); + src.bandwidth = playlist.getStreamInfo().getBandwidth(); + src.height = playlist.getStreamInfo().getResolution().height; + String masterUrl = masterPlaylistUrl; + String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1); + String segmentUri = baseUrl + playlist.getUri(); + src.mediaPlaylistUrl = segmentUri; + if (src.mediaPlaylistUrl.contains("?")) { + src.mediaPlaylistUrl = src.mediaPlaylistUrl.substring(0, src.mediaPlaylistUrl.lastIndexOf('?')); + } + LOG.trace("Media playlist {}", src.mediaPlaylistUrl); + sources.add(src); + } + } + return sources; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ExecutionException(e); + } + } + + private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException { + LOG.trace("Loading master playlist {}", masterPlaylistUrl); + Request req = new Request.Builder() + .url(masterPlaylistUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + try (Response response = getSite().getHttpClient().execute(req)) { + if (response.isSuccessful()) { + String body = Objects.requireNonNull(response.body()).string(); + LOG.trace(body); + InputStream inputStream = new ByteArrayInputStream(body.getBytes(UTF_8)); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); + Playlist playlist = parser.parse(); + MasterPlaylist master = playlist.getMasterPlaylist(); + return master; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + @Override + public void invalidateCacheEntries() { + resolution = null; + } + + @Override + public void receiveTip(Double tokens) throws IOException { + throw new NotImplementedExcetion(); + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + if (resolution == null) { + if (failFast) { + return new int[2]; + } + try { + 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}; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); + resolution = new int[2]; + } catch (ExecutionException | IOException | ParseException | PlaylistException e) { + LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); + resolution = new int[2]; + } + } + return resolution; + } + + @Override + public boolean follow() throws IOException { + return followUnfollow("follow", "a7a8241014074f5c02ac83863a47f7579e5f03167a7ec424ff65ad045c7fcf6f"); + } + + @Override + 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; + } 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); + } + } + + public void setId(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + @Override + public void readSiteSpecificData(JsonReader reader) throws IOException { + if (reader.hasNext()) { + reader.nextName(); + id = reader.nextString(); + } + } + + @Override + public void writeSiteSpecificData(JsonWriter writer) throws IOException { + writer.name("id").value(id); + } +} diff --git a/server/src/main/java/ctbrec/recorder/server/HttpServer.java b/server/src/main/java/ctbrec/recorder/server/HttpServer.java index e62a17e5..df325a4d 100644 --- a/server/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/server/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -1,59 +1,5 @@ package ctbrec.recorder.server; -import static java.nio.charset.StandardCharsets.*; -import static javax.servlet.http.HttpServletResponse.*; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Writer; -import java.net.BindException; -import java.net.URL; -import java.util.ArrayList; -import java.util.EnumSet; -import java.util.List; -import java.util.Optional; - -import javax.servlet.DispatcherType; -import javax.servlet.Filter; -import javax.servlet.FilterChain; -import javax.servlet.FilterConfig; -import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -import ctbrec.sites.secretfriends.SecretFriends; -import org.eclipse.jetty.security.ConstraintMapping; -import org.eclipse.jetty.security.ConstraintSecurityHandler; -import org.eclipse.jetty.security.HashLoginService; -import org.eclipse.jetty.security.SecurityHandler; -import org.eclipse.jetty.security.UserStore; -import org.eclipse.jetty.security.authentication.BasicAuthenticator; -import org.eclipse.jetty.server.Handler; -import org.eclipse.jetty.server.HttpConfiguration; -import org.eclipse.jetty.server.HttpConnectionFactory; -import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.ErrorHandler; -import org.eclipse.jetty.server.handler.HandlerList; -import org.eclipse.jetty.server.handler.SecuredRedirectHandler; -import org.eclipse.jetty.servlet.FilterHolder; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; -import org.eclipse.jetty.util.security.Constraint; -import org.eclipse.jetty.util.security.Credential; -import org.eclipse.jetty.util.ssl.SslContextFactory; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.common.base.Objects; - import ctbrec.Config; import ctbrec.GlobalThreadPool; import ctbrec.NotLoggedInExcetion; @@ -71,26 +17,56 @@ import ctbrec.sites.bonga.BongaCams; import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.sites.cherrytv.CherryTv; import ctbrec.sites.fc2live.Fc2Live; import ctbrec.sites.flirt4free.Flirt4Free; import ctbrec.sites.jasmin.LiveJasmin; import ctbrec.sites.manyvids.MVLive; import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.sites.secretfriends.SecretFriends; import ctbrec.sites.showup.Showup; import ctbrec.sites.streamate.Streamate; import ctbrec.sites.stripchat.Stripchat; import ctbrec.sites.xlovecam.XloveCam; +import org.eclipse.jetty.security.*; +import org.eclipse.jetty.security.authentication.BasicAuthenticator; +import org.eclipse.jetty.server.*; +import org.eclipse.jetty.server.handler.ErrorHandler; +import org.eclipse.jetty.server.handler.HandlerList; +import org.eclipse.jetty.server.handler.SecuredRedirectHandler; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.util.security.Constraint; +import org.eclipse.jetty.util.security.Credential; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.*; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.*; +import java.net.BindException; +import java.net.URL; +import java.util.*; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static javax.servlet.http.HttpServletResponse.SC_MOVED_PERMANENTLY; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; public class HttpServer { private static final Logger LOG = LoggerFactory.getLogger(HttpServer.class); - private Recorder recorder; - private OnlineMonitor onlineMonitor; - private Config config; + private final Recorder recorder; + private final OnlineMonitor onlineMonitor; + private final Config config; + private final List sites = new ArrayList<>(); private Server server = new Server(); - private List sites = new ArrayList<>(); - public HttpServer() throws Exception { + public HttpServer() throws IOException { logEnvironment(); createSites(); System.setProperty("ctbrec.server.mode", "1"); @@ -157,6 +133,7 @@ public class HttpServer { sites.add(new Cam4()); sites.add(new Camsoda()); sites.add(new Chaturbate()); + sites.add(new CherryTv()); sites.add(new Fc2Live()); sites.add(new Flirt4Free()); sites.add(new LiveJasmin()); @@ -170,32 +147,29 @@ public class HttpServer { } private void addShutdownHook() { - Runtime.getRuntime().addShutdownHook(new Thread() { - @Override - public void run() { - LOG.info("Shutting down"); - if (onlineMonitor != null) { - onlineMonitor.shutdown(); - } - if (recorder != null) { - recorder.shutdown(false); - } - try { - server.stop(); - } catch (Exception e) { - LOG.error("Couldn't stop HTTP server", e); - } - try { - Config.getInstance().save(); - } catch (IOException e) { - LOG.error("Couldn't save configuration", e); - } - LOG.info("Goodbye!"); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + LOG.info("Shutting down"); + if (onlineMonitor != null) { + onlineMonitor.shutdown(); } - }); + if (recorder != null) { + recorder.shutdown(false); + } + try { + server.stop(); + } catch (Exception e) { + LOG.error("Couldn't stop HTTP server", e); + } + try { + Config.getInstance().save(); + } catch (IOException e) { + LOG.error("Couldn't save configuration", e); + } + LOG.info("Goodbye!"); + })); } - private void startHttpServer() throws Exception { + private void startHttpServer() { server = new Server(); HttpConfiguration httpConfig = new HttpConfiguration(); @@ -206,7 +180,7 @@ public class HttpServer { SslContextFactory sslContextFactory = new SslContextFactory.Server(); URL keyStoreUrl = getClass().getResource("/keystore.pkcs12"); - String keyStoreSrc = System.getProperty("keystore.file", keyStoreUrl.toExternalForm()); + String keyStoreSrc = System.getProperty("keystore.file", Objects.requireNonNull(keyStoreUrl).toExternalForm()); String keyStorePassword = System.getProperty("keystore.password", "ctbrecsucks"); sslContextFactory.setKeyStorePath(keyStoreSrc); sslContextFactory.setKeyStorePassword(keyStorePassword); @@ -246,35 +220,7 @@ public class HttpServer { defaultContext.addServlet(holder, "/hls/*"); if (this.config.getSettings().webinterface) { - StaticFileServlet staticFileServlet = new StaticFileServlet("/html"); - holder = new ServletHolder(staticFileServlet); - String staticFileContext = "/static/*"; - defaultContext.addServlet(holder, staticFileContext); - LOG.info("Register static file servlet under {}", defaultContext.getContextPath()+staticFileContext); - - // servlet to retrieve the HMAC (secured by basic auth if an hmac key is set in the config) - String username = this.config.getSettings().webinterfaceUsername; - String password = this.config.getSettings().webinterfacePassword; - if (config.getSettings().key != null && config.getSettings().key.length > 0) { - basicAuthContext.setSecurityHandler(basicAuth(username, password, "CTB Recorder")); - } - basicAuthContext.addServlet(new ServletHolder(new HttpServlet() { - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException { - if (Objects.equal(username, req.getRemoteUser())) { - resp.setStatus(HttpServletResponse.SC_OK); - resp.setContentType("application/json"); - byte[] hmac = Optional.ofNullable(HttpServer.this.config.getSettings().key).orElse(new byte[0]); - try { - JSONObject response = new JSONObject(); - response.put("hmac", new String(hmac, UTF_8)); - resp.getOutputStream().println(response.toString()); - } catch (Exception e) { - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - } - } - } - }), "/hmac"); + startWebInterface(defaultContext, basicAuthContext); } server.addConnector(http); @@ -293,6 +239,10 @@ public class HttpServer { } catch (BindException e) { LOG.error("Port {} is already in use", http.getPort(), e); System.exit(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.error("Server start failed", e); + System.exit(1); } catch (Exception e) { LOG.error("Server start failed", e); System.exit(1); @@ -300,6 +250,38 @@ public class HttpServer { } } + private void startWebInterface(ServletContextHandler defaultContext, ServletContextHandler basicAuthContext) { + StaticFileServlet staticFileServlet = new StaticFileServlet("/html"); + ServletHolder holder = new ServletHolder(staticFileServlet); + String staticFileContext = "/static/*"; + defaultContext.addServlet(holder, staticFileContext); + LOG.info("Register static file servlet under {}", defaultContext.getContextPath()+staticFileContext); + + // servlet to retrieve the HMAC (secured by basic auth if a hmac key is set in the config) + String username = this.config.getSettings().webinterfaceUsername; + String password = this.config.getSettings().webinterfacePassword; + if (config.getSettings().key != null && config.getSettings().key.length > 0) { + basicAuthContext.setSecurityHandler(basicAuth(username, password)); + } + basicAuthContext.addServlet(new ServletHolder(new HttpServlet() { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { + if (Objects.equals(username, req.getRemoteUser())) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/json"); + byte[] hmac = Optional.ofNullable(HttpServer.this.config.getSettings().key).orElse(new byte[0]); + try { + JSONObject response = new JSONObject(); + response.put("hmac", new String(hmac, UTF_8)); + resp.getOutputStream().println(response.toString()); + } catch (Exception e) { + resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + } + }), "/hmac"); + } + private ErrorHandler createErrorHandler(String contextPath) { return new ErrorHandler() { @Override @@ -327,7 +309,7 @@ public class HttpServer { private void addHttpHeaderFilter(ServletContextHandler defaultContext) { FilterHolder httpHeaderFilter = new FilterHolder(new Filter() { @Override - public void init(FilterConfig filterConfig) throws ServletException { + public void init(FilterConfig filterConfig) { // noop } @@ -345,7 +327,8 @@ public class HttpServer { defaultContext.addFilter(httpHeaderFilter, "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.INCLUDE)); } - private static final SecurityHandler basicAuth(String username, String password, String realm) { + private static SecurityHandler basicAuth(String username, String password) { + String realm = "CTB Recorder"; UserStore userStore = new UserStore(); userStore.addUser(username, Credential.getCredential(password), new String[] { "user" }); HashLoginService l = new HashLoginService(); @@ -363,7 +346,7 @@ public class HttpServer { ConstraintSecurityHandler csh = new ConstraintSecurityHandler(); csh.setAuthenticator(new BasicAuthenticator()); - csh.setRealmName("myrealm"); + csh.setRealmName(realm); csh.addConstraintMapping(cm); csh.setLoginService(l); @@ -391,7 +374,7 @@ public class HttpServer { private Version getVersion() throws IOException { try (InputStream is = getClass().getClassLoader().getResourceAsStream("version")) { - BufferedReader reader = new BufferedReader(new InputStreamReader(is)); + BufferedReader reader = new BufferedReader(new InputStreamReader(Objects.requireNonNull(is))); String versionString = reader.readLine(); Version version = Version.of(versionString); return version; diff --git a/server/src/main/resources/html/static/index.html b/server/src/main/resources/html/static/index.html index a4c18513..51e2262c 100644 --- a/server/src/main/resources/html/static/index.html +++ b/server/src/main/resources/html/static/index.html @@ -162,13 +162,14 @@ - + - + @@ -259,8 +260,8 @@ let observableRecordingsArray = ko.observableArray(); let observableSettingsArray = ko.observableArray(); let space = { - free: ko.observable(0), - total: ko.observable(0), + free: ko.observable(0), + total: ko.observable(0), percent: ko.observable(0), text: ko.observable('') }; @@ -280,8 +281,8 @@ }); } else { $('#addModelByUrl').autocomplete({ - source: ["BongaCams:", "Cam4:", "Camsoda:", "Chaturbate:", "Fc2Live:", "Flirt4Free:", "LiveJasmin:", "MyFreeCams:", "MVLive:", "Showup:", "Streamate:", "Stripchat:"] - }); + source: ["BongaCams:", "Cam4:", "Camsoda:", "Chaturbate:", "CherryTv:", "Fc2Live:", "Flirt4Free:", "LiveJasmin:", "MyFreeCams:", "MVLive:", "Showup:", "Streamate:", "Stripchat:"] + }); } } @@ -291,7 +292,7 @@ let model = { type: null, name: '', - url: input + url: input }; if(console) console.log(model); @@ -442,7 +443,7 @@ $(document).ready(function() { if (localStorage !== undefined && localStorage.hmac !== undefined) { if(console) console.log('using hmac from local storage'); - hmac = localStorage.hmac; + hmac = localStorage.hmac; } else { if(console) console.log('hmac not found in local storage. requesting hmac from server'); $.ajax({ @@ -461,7 +462,7 @@ }) .fail(function(jqXHR, textStatus, errorThrown) { if(console) console.log(textStatus, errorThrown); - $.notify('Couldn\'t get HMAC', 'error'); + $.notify('Could not get HMAC', 'error'); hmac = ''; }); }