From 5a86cfa85e0119732d1284fd9cb0bd0c05c8d130 Mon Sep 17 00:00:00 2001 From: 0xb00bface <0xboobface@gmail.com> Date: Sat, 23 Oct 2021 17:19:44 +0200 Subject: [PATCH] Add initial implementation for cherry.tv --- .../java/ctbrec/ui/CamrecApplication.java | 4 +- .../main/java/ctbrec/ui/SiteUiFactory.java | 10 +- .../ui/sites/cherrytv/CherryTvConfigUI.java | 89 +++++++ .../ui/sites/cherrytv/CherryTvSiteUi.java | 46 ++++ .../sites/cherrytv/CherryTvTabProvider.java | 50 ++++ .../sites/cherrytv/CherryTvUpdateService.java | 115 +++++++++ common/pom.xml | 5 + common/src/main/java/ctbrec/Config.java | 8 +- common/src/main/java/ctbrec/Settings.java | 2 +- .../java/ctbrec/sites/cherrytv/CherryTv.java | 162 +++++++++++++ .../sites/cherrytv/CherryTvHttpClient.java | 18 ++ .../ctbrec/sites/cherrytv/CherryTvModel.java | 225 ++++++++++++++++++ .../ctbrec/recorder/server/HttpServer.java | 213 ++++++++--------- .../src/main/resources/html/static/index.html | 19 +- 14 files changed, 835 insertions(+), 131 deletions(-) create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java create mode 100644 common/src/main/java/ctbrec/sites/cherrytv/CherryTv.java create mode 100644 common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index ca40f8d6..84d3d1e9 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -22,6 +22,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import ctbrec.sites.cherrytv.CherryTv; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -174,6 +175,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()); @@ -193,7 +195,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 b9056890..a9a64d62 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; @@ -20,6 +21,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; @@ -37,6 +39,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; @@ -49,7 +52,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); @@ -75,6 +78,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..9ddfe293 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvConfigUI.java @@ -0,0 +1,89 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.Config; +import ctbrec.sites.cam4.Cam4; +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("Cam4 User"), 0, row); + var username = new TextField(Config.getInstance().getSettings().cam4Username); + username.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().cam4Username)) { + Config.getInstance().getSettings().cam4Username = 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("Cam4 Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().cam4Password); + password.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().cam4Password)) { + Config.getInstance().getSettings().cam4Password = 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(Cam4.AFFILIATE_LINK)); + 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/CherryTvSiteUi.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java new file mode 100644 index 00000000..58844da8 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java @@ -0,0 +1,46 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.sites.cam4.Cam4; +import ctbrec.sites.cam4.Cam4HttpClient; +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.ui.controls.Dialogs; +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..3bc63f66 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvTabProvider.java @@ -0,0 +1,50 @@ +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; + + public CherryTvTabProvider(CherryTv cherryTv) { + this.site = cherryTv; + this.recorder = cherryTv.getRecorder(); + } + + @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\":50}&extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"f1e214ca901e525301fcc6966cf081ee95ef777974ec184897c4a2cf15e9ac6f\"}}")); +// tabs.add(createTab("Male", site.getBaseUrl() + "/directoryResults?online=true&gender=male&orderBy=MOST_VIEWERS")); +// tabs.add(createTab("Couples", site.getBaseUrl() + "/directoryResults?online=true&broadcastType=male_group&broadcastType=female_group&broadcastType=male_female_group&orderBy=MOST_VIEWERS")); +// tabs.add(createTab("HD", site.getBaseUrl() + "/directoryResults?online=true&hd=true&orderBy=MOST_VIEWERS")); +// tabs.add(createTab("New", site.getBaseUrl() + "/directoryResults?online=true&gender=female&orderBy=MOST_VIEWERS&newPerformer=true")); + + return tabs; + } + + @Override + public Tab getFollowedTab() { + return null; + } + + private Tab createTab(String name, String url) { + var updateService = new CherryTvUpdateService(url, site); + 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..491337cf --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java @@ -0,0 +1,115 @@ +package ctbrec.ui.sites.cherrytv; + +import com.apollographql.apollo.ApolloClient; +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HtmlParser; +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.jsoup.nodes.Element; +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 static ctbrec.Model.State.*; +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 String url; + private final CherryTv site; + private ApolloClient apolloClient; + + public CherryTvUpdateService(String url, CherryTv site) { + this.site = site; + this.url = url; + + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setName("CherryTvUpdateService"); + return t; + }); + setExecutor(executor); + + apolloClient = ApolloClient.builder() + .serverUrl(site.getBaseUrl() + "/graphql") + .build(); + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + String pageUrl = CherryTvUpdateService.this.url; + LOG.debug("Fetching page {}", pageUrl); + apolloClient. + + 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()) { + return parseModels(Objects.requireNonNull(response.body()).string()); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + }; + } + + private List parseModels(String body) { + var json = new JSONObject(body); + // LOG.debug(json.toString(2)); + 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 setUrl(String url) { + this.url = url; + } + +} diff --git a/common/pom.xml b/common/pom.xml index 24285590..877d8644 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -50,6 +50,11 @@ commons-io commons-io + + com.apollographql.apollo + apollo-runtime + 2.5.9 + javax.servlet javax.servlet-api 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/Settings.java b/common/src/main/java/ctbrec/Settings.java index 6d22966c..9d46397d 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; } 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..b8174de5 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/cherrytv/CherryTv.java @@ -0,0 +1,162 @@ +package ctbrec.sites.cherrytv; + +import ctbrec.Model; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.sites.AbstractSite; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Collections; +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.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 false; + } + + @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 false; + } + + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public boolean searchRequiresLogin() { + return false; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + JSONObject variables = new JSONObject().put("slug", q).put("limit", 10); + JSONObject persistedQuery = new JSONObject().put("persistedQuery", new JSONObject().put("version", 1).put("sha256Hash", "")); + Request req = new Request.Builder() + .url(new HttpUrl.Builder() + .scheme("https") + .host("cherry.tv") + .addPathSegment("qraphql") + .addQueryParameter("operationName", "Search") + .addQueryParameter("variables", variables.toString()) + .addQueryParameter("extensions", persistedQuery.toString()) + .build() + ) + .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(); + try (Response response = getHttpClient().execute(req)) { + if (response.isSuccessful()) { + LOG.debug("Response: {}", Objects.requireNonNull(response.body()).string()); + } else { + throw new HttpException(response.code(), response.message()); + } + } + return Collections.emptyList(); + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof CherryTvModel; + } + + @Override + public boolean credentialsAvailable() { + return false; + } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://.*?cherry\\.tv/([^/]*?)/?").matcher(url); + if (m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } 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..ff402c32 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java @@ -0,0 +1,18 @@ +package ctbrec.sites.cherrytv; + +import ctbrec.Config; +import ctbrec.io.HttpClient; + +import java.io.IOException; + +public class CherryTvHttpClient extends HttpClient { + + public CherryTvHttpClient(Config config) { + super("cherrytv", config); + } + + @Override + public synchronized boolean login() throws IOException { + 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..7fcfe3c4 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java @@ -0,0 +1,225 @@ +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 ctbrec.AbstractModel; +import ctbrec.Config; +import ctbrec.NotImplementedExcetion; +import ctbrec.io.HttpException; +import ctbrec.recorder.download.StreamSource; +import okhttp3.Request; +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.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; +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; + + @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(); + 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"); + // roomId = broadcast.getString("roomId"); + online = broadcast.optString("showStatus").equalsIgnoreCase("Public") + && broadcast.optString("broadcastStatus").equalsIgnoreCase("Live"); + onlineState = online ? ONLINE : OFFLINE; + masterPlaylistUrl = broadcast.optString("pullUrl", null); + break; + } + } + } 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; + } + + 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; + } else { + return resolution; + } + } + + @Override + public boolean follow() throws IOException { + return false; + } + + @Override + public boolean unfollow() throws IOException { + return false; + } + + 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); + } + } +} diff --git a/server/src/main/java/ctbrec/recorder/server/HttpServer.java b/server/src/main/java/ctbrec/recorder/server/HttpServer.java index 1f9d650f..bd46a495 100644 --- a/server/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/server/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -1,58 +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 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; @@ -70,6 +17,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; @@ -79,17 +27,45 @@ 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"); @@ -156,6 +132,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()); @@ -168,32 +145,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(); @@ -204,7 +178,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); @@ -244,35 +218,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); @@ -291,6 +237,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); @@ -298,6 +248,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 @@ -325,7 +307,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 } @@ -343,7 +325,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(); @@ -361,7 +344,7 @@ public class HttpServer { ConstraintSecurityHandler csh = new ConstraintSecurityHandler(); csh.setAuthenticator(new BasicAuthenticator()); - csh.setRealmName("myrealm"); + csh.setRealmName(realm); csh.addConstraintMapping(cm); csh.setLoginService(l); @@ -389,7 +372,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 = ''; }); }