From addbeab76e6dad636b523b1bdfffedb438a72d46 Mon Sep 17 00:00:00 2001 From: 0xb00bface <0xboobface@gmail.com> Date: Sun, 29 Oct 2023 15:05:41 +0100 Subject: [PATCH] Integrate DramCam, Streamray and WinkTv by @winkru --- .../java/ctbrec/ui/CamrecApplication.java | 10 +- .../main/java/ctbrec/ui/SiteUiFactory.java | 27 +- .../ui/sites/dreamcam/DreamcamConfigUI.java | 66 ++++ .../ui/sites/dreamcam/DreamcamSiteUi.java | 40 ++ .../sites/dreamcam/DreamcamTabProvider.java | 37 ++ .../sites/dreamcam/DreamcamUpdateService.java | 86 +++++ .../ui/sites/streamray/StreamrayConfigUI.java | 67 ++++ .../StreamrayElectronLoginDialog.java | 83 +++++ .../streamray/StreamrayFavoritesService.java | 132 +++++++ .../streamray/StreamrayFavoritesTab.java | 77 ++++ .../ui/sites/streamray/StreamraySiteUi.java | 44 +++ .../sites/streamray/StreamrayTabProvider.java | 49 +++ .../streamray/StreamrayUpdateService.java | 146 ++++++++ .../ui/sites/winktv/WinkTvConfigUI.java | 55 +++ .../ctbrec/ui/sites/winktv/WinkTvSiteUi.java | 40 ++ .../ui/sites/winktv/WinkTvTabProvider.java | 37 ++ .../ui/sites/winktv/WinkTvUpdateService.java | 127 +++++++ .../src/main/java/ctbrec/AbstractModel.java | 34 +- common/src/main/java/ctbrec/Model.java | 31 +- common/src/main/java/ctbrec/Settings.java | 5 + .../recorder/download/AbstractDownload.java | 85 +++-- .../download/hls/FfmpegHlsDownload.java | 325 +++++++++++++++++ .../ctbrec/sites/chaturbate/Chaturbate.java | 2 +- .../chaturbate/ChaturbateHttpClient.java | 21 +- .../java/ctbrec/sites/dreamcam/Dreamcam.java | 127 +++++++ .../sites/dreamcam/DreamcamDownload.java | 341 ++++++++++++++++++ .../sites/dreamcam/DreamcamHttpClient.java | 17 + .../ctbrec/sites/dreamcam/DreamcamModel.java | 205 +++++++++++ .../ctbrec/sites/streamray/Streamray.java | 178 +++++++++ .../sites/streamray/StreamrayHttpClient.java | 79 ++++ .../sites/streamray/StreamrayModel.java | 197 ++++++++++ .../main/java/ctbrec/sites/winktv/WinkTv.java | 182 ++++++++++ .../ctbrec/sites/winktv/WinkTvHttpClient.java | 17 + .../java/ctbrec/sites/winktv/WinkTvModel.java | 270 ++++++++++++++ .../ctbrec/recorder/server/HttpServer.java | 22 +- 35 files changed, 3186 insertions(+), 75 deletions(-) create mode 100644 client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayElectronLoginDialog.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamraySiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/winktv/WinkTvConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/winktv/WinkTvSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/winktv/WinkTvTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/winktv/WinkTvUpdateService.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/FfmpegHlsDownload.java create mode 100644 common/src/main/java/ctbrec/sites/dreamcam/Dreamcam.java create mode 100644 common/src/main/java/ctbrec/sites/dreamcam/DreamcamDownload.java create mode 100644 common/src/main/java/ctbrec/sites/dreamcam/DreamcamHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/dreamcam/DreamcamModel.java create mode 100644 common/src/main/java/ctbrec/sites/streamray/Streamray.java create mode 100644 common/src/main/java/ctbrec/sites/streamray/StreamrayHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/streamray/StreamrayModel.java create mode 100644 common/src/main/java/ctbrec/sites/winktv/WinkTv.java create mode 100644 common/src/main/java/ctbrec/sites/winktv/WinkTvHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/winktv/WinkTvModel.java diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index d8bf8e23..19e1bf6b 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -27,6 +27,7 @@ import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.sites.dreamcam.Dreamcam; import ctbrec.sites.fc2live.Fc2Live; import ctbrec.sites.flirt4free.Flirt4Free; import ctbrec.sites.jasmin.LiveJasmin; @@ -35,7 +36,9 @@ import ctbrec.sites.mfc.MyFreeCams; import ctbrec.sites.secretfriends.SecretFriends; import ctbrec.sites.showup.Showup; import ctbrec.sites.streamate.Streamate; +import ctbrec.sites.streamray.Streamray; import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.winktv.WinkTv; import ctbrec.sites.xlovecam.XloveCam; import ctbrec.ui.controls.Dialogs; import ctbrec.ui.news.NewsTab; @@ -123,7 +126,9 @@ public class CamrecApplication extends Application { initSites(); startOnlineMonitor(); createGui(primaryStage); - checkForUpdates(); + if (config.getSettings().checkForUpdates) { + checkForUpdates(); + } registerClipboardListener(); registerTrayIconListener(); } @@ -162,6 +167,7 @@ public class CamrecApplication extends Application { sites.add(new Camsoda()); sites.add(new Chaturbate()); sites.add(new CherryTv()); + sites.add(new Dreamcam()); sites.add(new Fc2Live()); sites.add(new Flirt4Free()); sites.add(new LiveJasmin()); @@ -171,6 +177,8 @@ public class CamrecApplication extends Application { sites.add(new Showup()); sites.add(new Streamate()); sites.add(new Stripchat()); + sites.add(new Streamray()); + sites.add(new WinkTv()); sites.add(new XloveCam()); } diff --git a/client/src/main/java/ctbrec/ui/SiteUiFactory.java b/client/src/main/java/ctbrec/ui/SiteUiFactory.java index 36b27ed0..187ccb0d 100644 --- a/client/src/main/java/ctbrec/ui/SiteUiFactory.java +++ b/client/src/main/java/ctbrec/ui/SiteUiFactory.java @@ -7,6 +7,7 @@ import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.sites.dreamcam.Dreamcam; import ctbrec.sites.fc2live.Fc2Live; import ctbrec.sites.flirt4free.Flirt4Free; import ctbrec.sites.jasmin.LiveJasmin; @@ -15,7 +16,9 @@ import ctbrec.sites.mfc.MyFreeCams; import ctbrec.sites.secretfriends.SecretFriends; import ctbrec.sites.showup.Showup; import ctbrec.sites.streamate.Streamate; +import ctbrec.sites.streamray.Streamray; import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.winktv.WinkTv; import ctbrec.sites.xlovecam.XloveCam; import ctbrec.ui.sites.amateurtv.AmateurTvSiteUi; import ctbrec.ui.sites.bonga.BongaCamsSiteUi; @@ -23,6 +26,7 @@ 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.dreamcam.DreamcamSiteUi; import ctbrec.ui.sites.fc2live.Fc2LiveSiteUi; import ctbrec.ui.sites.flirt4free.Flirt4FreeSiteUi; import ctbrec.ui.sites.jasmin.LiveJasminSiteUi; @@ -31,7 +35,9 @@ import ctbrec.ui.sites.myfreecams.MyFreeCamsSiteUi; import ctbrec.ui.sites.secretfriends.SecretFriendsSiteUi; import ctbrec.ui.sites.showup.ShowupSiteUi; import ctbrec.ui.sites.streamate.StreamateSiteUi; +import ctbrec.ui.sites.streamray.StreamraySiteUi; import ctbrec.ui.sites.stripchat.StripchatSiteUi; +import ctbrec.ui.sites.winktv.WinkTvSiteUi; import ctbrec.ui.sites.xlovecam.XloveCamSiteUi; public class SiteUiFactory { @@ -52,8 +58,12 @@ public class SiteUiFactory { private static StreamateSiteUi streamateSiteUi; private static StripchatSiteUi stripchatSiteUi; private static XloveCamSiteUi xloveCamSiteUi; + private static StreamraySiteUi streamraySiteUi; + private static WinkTvSiteUi winkTvSiteUi; + private static DreamcamSiteUi dreamcamSiteUi; - private SiteUiFactory () {} + private SiteUiFactory() { + } public static synchronized SiteUI getUi(Site site) { // NOSONAR if (site instanceof AmateurTv) { @@ -86,6 +96,11 @@ public class SiteUiFactory { cherryTvSiteUi = new CherryTvSiteUi((CherryTv) site); } return cherryTvSiteUi; + } else if (site instanceof Dreamcam) { + if (dreamcamSiteUi == null) { + dreamcamSiteUi = new DreamcamSiteUi((Dreamcam) site); + } + return dreamcamSiteUi; } else if (site instanceof Fc2Live) { if (fc2SiteUi == null) { fc2SiteUi = new Fc2LiveSiteUi((Fc2Live) site); @@ -131,6 +146,16 @@ public class SiteUiFactory { stripchatSiteUi = new StripchatSiteUi((Stripchat) site); } return stripchatSiteUi; + } else if (site instanceof Streamray) { + if (streamraySiteUi == null) { + streamraySiteUi = new StreamraySiteUi((Streamray) site); + } + return streamraySiteUi; + } else if (site instanceof WinkTv) { + if (winkTvSiteUi == null) { + winkTvSiteUi = new WinkTvSiteUi((WinkTv) site); + } + return winkTvSiteUi; } else if (site instanceof XloveCam) { if (xloveCamSiteUi == null) { xloveCamSiteUi = new XloveCamSiteUi((XloveCam) site); diff --git a/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamConfigUI.java b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamConfigUI.java new file mode 100644 index 00000000..44fcf407 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamConfigUI.java @@ -0,0 +1,66 @@ +package ctbrec.ui.sites.dreamcam; + +import ctbrec.Config; +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.ui.settings.SettingsTab; +import ctbrec.ui.sites.AbstractConfigUI; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.RadioButton; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; + +public class DreamcamConfigUI extends AbstractConfigUI { + private final Dreamcam site; + + public DreamcamConfigUI(Dreamcam site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + GridPane 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++); + + row++; + l = new Label("VR Mode"); + layout.add(l, 0, row); + var vr = new CheckBox(); + vr.setSelected(settings.dreamcamVR); + vr.setOnAction(e -> { + settings.dreamcamVR = vr.isSelected(); + save(); + }); + GridPane.setMargin(vr, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(vr, 1, row++); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 2); + + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamSiteUi.java b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamSiteUi.java new file mode 100644 index 00000000..cde8f98a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamSiteUi.java @@ -0,0 +1,40 @@ +package ctbrec.ui.sites.dreamcam; + +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +import java.io.IOException; + +public class DreamcamSiteUi extends AbstractSiteUi { + + private DreamcamTabProvider tabProvider; + private DreamcamConfigUI configUi; + private final Dreamcam site; + + public DreamcamSiteUi(Dreamcam site) { + this.site = site; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new DreamcamTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new DreamcamConfigUI(site); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + return site.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamTabProvider.java b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamTabProvider.java new file mode 100644 index 00000000..17226cc0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamTabProvider.java @@ -0,0 +1,37 @@ +package ctbrec.ui.sites.dreamcam; + +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.sites.dreamcam.DreamcamModel; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import java.util.ArrayList; +import java.util.List; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +public class DreamcamTabProvider extends AbstractTabProvider { + private final static String API_URL = "https://bss.dreamcamtrue.com/api/clients/v1/broadcasts?partnerId=dreamcam_oauth2&show-offline=false&stream-types=video2D&include-tags=false&include-tip-menu=false"; + + public DreamcamTabProvider(Dreamcam site) { + super(site); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Girls", API_URL + "&tag-categories=girls")); + tabs.add(createTab("Boys", API_URL + "&tag-categories=men")); + tabs.add(createTab("Couples", API_URL + "&tag-categories=couples")); + tabs.add(createTab("Trans", API_URL + "&tag-categories=trans")); + return tabs; + } + + private Tab createTab(String title, String url) { + var updateService = new DreamcamUpdateService((Dreamcam) site, url); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setImageAspectRatio(10.0 / 16.0); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamUpdateService.java b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamUpdateService.java new file mode 100644 index 00000000..b413b8aa --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamUpdateService.java @@ -0,0 +1,86 @@ +package ctbrec.ui.sites.dreamcam; + +import static ctbrec.io.HttpConstants.*; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.sites.dreamcam.DreamcamModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DreamcamUpdateService extends PaginatedScheduledService { + private static final Logger LOG = LoggerFactory.getLogger(DreamcamUpdateService.class); + private static final String API_URL = "https://api.dreamcam.co.kr/v1/live"; + private static final int modelsPerPage = 64; + private Dreamcam site; + private String url; + + public DreamcamUpdateService(Dreamcam site, String url) { + this.site = site; + this.url = url; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return loadModelList(); + } + }; + } + + private List loadModelList() throws IOException { + int offset = (getPage() - 1) * modelsPerPage; + int limit = modelsPerPage; + String paginatedUrl = url + "&offset=" + offset + "&limit=" + limit; + LOG.debug("Fetching page {}", paginatedUrl); + Request req = new Request.Builder() + .url(paginatedUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(REFERER, site.getBaseUrl() + "/") + .header(ORIGIN, site.getBaseUrl()) + .build(); + try (Response response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + String content = response.body().string(); + JSONObject json = new JSONObject(content); + if (json.has("pageItems")) { + JSONArray modelNodes = json.getJSONArray("pageItems"); + parseModels(modelNodes, models); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + for (int i = 0; i < jsonModels.length(); i++) { + JSONObject m = jsonModels.getJSONObject(i); + String name = m.optString("modelNickname"); + DreamcamModel model = (DreamcamModel) site.createModel(name); + model.setDisplayName(name); + model.setPreview(m.optString("modelProfilePhotoUrl")); + model.setDescription(m.optString("broadcastTextStatus")); + models.add(model); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayConfigUI.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayConfigUI.java new file mode 100644 index 00000000..3cc33e35 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayConfigUI.java @@ -0,0 +1,67 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.Config; +import ctbrec.sites.streamray.Streamray; +import ctbrec.ui.settings.SettingsTab; +import ctbrec.ui.sites.AbstractConfigUI; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.RadioButton; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; + +public class StreamrayConfigUI extends AbstractConfigUI { + private final Streamray site; + + public StreamrayConfigUI(Streamray site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + GridPane 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++); + row++; + + l = new Label("Record Goal/Tipping shows"); + layout.add(l, 0, row); + var cb = new CheckBox(); + cb.setSelected(settings.streamrayRecordGoalShows); + cb.setOnAction(e -> { + settings.streamrayRecordGoalShows = cb.isSelected(); + save(); + }); + GridPane.setMargin(cb, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(cb, 1, row++); + row++; + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 2); + + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayElectronLoginDialog.java new file mode 100644 index 00000000..2d727ee0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayElectronLoginDialog.java @@ -0,0 +1,83 @@ +package ctbrec.ui.sites.streamray; + +import java.io.IOException; +import java.util.Collections; +import java.util.function.Consumer; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.sites.streamray.Streamray; +import ctbrec.ui.ExternalBrowser; +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; + +public class StreamrayElectronLoginDialog { + + private static final Logger LOG = LoggerFactory.getLogger(StreamrayElectronLoginDialog.class); + public static final String DOMAIN = "streamray.com"; + public static final String URL = "https://streamray.com/"; + private CookieJar cookieJar; + private ExternalBrowser browser; + private boolean firstCall = true; + private final static Streamray site = new Streamray(); + + public StreamrayElectronLoginDialog(CookieJar cookieJar) throws IOException { + this.cookieJar = cookieJar; + browser = ExternalBrowser.getInstance(); + try { + var config = new JSONObject(); + config.put("url", URL); + config.put("w", 800); + config.put("h", 600); + config.put("userAgent", Config.getInstance().getSettings().httpUserAgent); + var msg = new JSONObject(); + msg.put("config", config); + browser.run(msg, msgHandler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Couldn't wait for login dialog", e); + } finally { + browser.close(); + } + } + + private Consumer msgHandler = line -> { + if (!line.startsWith("{")) return; + JSONObject json = new JSONObject(line); + boolean loginCookie = false; + if (json.has("cookies")) { + var cookies = json.getJSONArray("cookies"); + for (var i = 0; i < cookies.length(); i++) { + var cookie = cookies.getJSONObject(i); + if (cookie.getString("domain").contains(DOMAIN)) { + if (cookie.optString("name").equals("memberToken")) { + loginCookie = true; + } + Builder b = new Cookie.Builder() + .path(cookie.getString("path")) + .domain(DOMAIN) + .name(cookie.optString("name")) + .value(cookie.optString("value")) + .expiresAt(Double.valueOf(cookie.optDouble("expirationDate")).longValue()); // NOSONAR + if (cookie.optBoolean("hostOnly")) { + b.hostOnlyDomain(DOMAIN); + } + if (cookie.optBoolean("httpOnly")) { + b.httpOnly(); + } + if (cookie.optBoolean("secure")) { + b.secure(); + } + Cookie c = b.build(); + LOG.trace("Adding cookie {}={}", c.name(), c.value()); + cookieJar.saveFromResponse(HttpUrl.parse(URL), Collections.singletonList(c)); + } // if + } // for + } + }; +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesService.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesService.java new file mode 100644 index 00000000..64ab32aa --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesService.java @@ -0,0 +1,132 @@ +package ctbrec.ui.sites.streamray; + +import static ctbrec.io.HttpConstants.*; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HttpException; +import ctbrec.sites.streamray.*; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import java.io.IOException; +import java.net.URLEncoder; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import javafx.concurrent.Task; +import okhttp3.*; +import org.json.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class StreamrayFavoritesService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(StreamrayFavoritesService.class); + private static final String API_URL = "https://beta-api.cams.com/won/compressed/"; + + private Streamray site; + private static List modelsList; + private static JSONArray mapping; + protected int modelsPerPage = 48; + public boolean loggedIn = false; + + public StreamrayFavoritesService(Streamray site) { + this.site = site; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return getModelList().stream() + .skip((page - 1) * (long) modelsPerPage) + .limit(modelsPerPage) + .collect(Collectors.toList()); // NOSONAR + } + }; + } + + private List getModelList() throws IOException { + modelsList = loadModelList(); + if (modelsList == null) { + modelsList = Collections.emptyList(); + } + return modelsList; + } + + private List loadModelList() throws IOException { + LOG.debug("Fetching page {}", API_URL); + StreamrayHttpClient client = (StreamrayHttpClient) site.getHttpClient(); + String token = ""; + if (site.login()) { + loggedIn = true; + token = client.getUserToken(); + } else { + loggedIn = false; + return Collections.emptyList(); + } + Request req = new Request.Builder() + .url(API_URL) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(REFERER, site.getBaseUrl() + "/") + .header(ORIGIN, site.getBaseUrl()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(AUTHORIZATION, "Bearer " + token) + .build(); + try (Response response = client.execute(req)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + String content = response.body().string(); + JSONObject json = new JSONObject(content); + JSONArray modelNodes = json.getJSONArray("models"); + mapping = json.getJSONArray("mapping"); + parseModels(modelNodes, models); + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + int name_idx = mapping_index("stream_name"); + int fav_idx = mapping_index("is_favorite"); + for (int i = 0; i < jsonModels.length(); i++) { + JSONArray m = jsonModels.getJSONArray(i); + String name = m.optString(name_idx); + boolean favorite = m.optBoolean(fav_idx); + if (favorite) { + StreamrayModel model = (StreamrayModel) site.createModel(name); + String preview = getPreviewURL(name); + model.setPreview(preview); + models.add(model); + } + } + } + + private String getPreviewURL(String name) { + String lname = name.toLowerCase(); + String url = MessageFormat.format("https://images4.streamray.com/images/streamray/won/jpg/{0}/{1}/{2}_640.jpg", lname.substring(0,1), lname.substring(lname.length()-1), lname); + try { + return MessageFormat.format("https://dynimages.securedataimages.com/unsigned/rs:fill:640::0/g:no/plain/{0}@jpg", URLEncoder.encode(url, "utf-8")); + } catch (Exception ex) { + return url; + } + } + + private int mapping_index(String s) { + for (var i = 0; i < mapping.length(); i++) { + if (Objects.equals(s, mapping.get(i))) return i; + } + return -1; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesTab.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesTab.java new file mode 100644 index 00000000..abb1f59f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesTab.java @@ -0,0 +1,77 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.sites.streamray.Streamray; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.concurrent.WorkerStateEvent; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class StreamrayFavoritesTab extends ThumbOverviewTab implements FollowedTab { + private static final Logger LOG = LoggerFactory.getLogger(StreamrayFavoritesTab.class); + private Label status; + private Button loginButton; + private Streamray site; + private StreamrayFavoritesService updateService; + + public StreamrayFavoritesTab(String title, StreamrayFavoritesService updateService, Streamray site) { + super(title, updateService, site); + this.site = site; + this.updateService = updateService; + + status = new Label("Logging in..."); + grid.getChildren().addAll(status); + + loginButton = new Button("Login"); + loginButton.setPadding(new Insets(20)); + loginButton.setOnAction(e -> { + try { + new StreamrayElectronLoginDialog(site.getHttpClient().getCookieJar()); + updateService.restart(); + } catch (Exception ex) {} + }); + } + + protected void addLoginButton() { + grid.getChildren().clear(); + grid.setAlignment(Pos.CENTER); + grid.getChildren().add(loginButton); + } + + @Override + protected void onSuccess() { + grid.getChildren().removeAll(status, loginButton); + grid.setAlignment(Pos.TOP_LEFT); + if (updateService.loggedIn == false) { + addLoginButton(); + } else { + 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/streamray/StreamraySiteUi.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamraySiteUi.java new file mode 100644 index 00000000..ff3e25ee --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamraySiteUi.java @@ -0,0 +1,44 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.sites.streamray.Streamray; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +import java.io.IOException; + +public class StreamraySiteUi extends AbstractSiteUi { + + private StreamrayTabProvider tabProvider; + private StreamrayConfigUI configUi; + private final Streamray site; + + public StreamraySiteUi(Streamray site) { + this.site = site; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new StreamrayTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new StreamrayConfigUI(site); + } + return configUi; + } + + @Override + public boolean login() throws IOException { + return site.login(); + } + + public synchronized boolean checkLogin() throws IOException { + return site.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayTabProvider.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayTabProvider.java new file mode 100644 index 00000000..8f2d1b86 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayTabProvider.java @@ -0,0 +1,49 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.sites.streamray.Streamray; +import ctbrec.sites.streamray.StreamrayModel; + +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +public class StreamrayTabProvider extends AbstractTabProvider { + + public StreamrayTabProvider(Streamray site) { + super(site); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Girls", m -> Objects.equals("F", m.getGender()))); + tabs.add(createTab("Boys", m -> Objects.equals("M", m.getGender()))); + tabs.add(createTab("Trans", m -> Objects.equals("TS", m.getGender()))); + tabs.add(createTab("New", m -> m.isNew())); + tabs.add(favoritesTab()); + return tabs; + } + + private Tab createTab(String title, Predicate filter) { + var updateService = new StreamrayUpdateService((Streamray) site, filter); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.setRecorder(recorder); + return tab; + } + + private Tab favoritesTab() { + var updateService = new StreamrayFavoritesService((Streamray) site); + var tab = new StreamrayFavoritesTab("Favorites", updateService, (Streamray) site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayUpdateService.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayUpdateService.java new file mode 100644 index 00000000..347913b0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayUpdateService.java @@ -0,0 +1,146 @@ +package ctbrec.ui.sites.streamray; + +import static ctbrec.io.HttpConstants.*; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HttpException; +import ctbrec.sites.streamray.*; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import java.io.IOException; +import java.net.URLEncoder; +import java.text.MessageFormat; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class StreamrayUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(StreamrayUpdateService.class); + private static final String API_URL = "https://beta-api.cams.com/won/compressed/"; + + private Streamray site; + private static List modelsList; + private static Instant lastListInfoRequest = Instant.EPOCH; + private static JSONArray mapping; + protected int modelsPerPage = 48; + protected Predicate filter; + + public StreamrayUpdateService(Streamray site, Predicate filter) { + this.site = site; + this.filter = filter; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return getModelList().stream() + .filter(filter) + .skip((page - 1) * (long) modelsPerPage) + .limit(modelsPerPage) + .collect(Collectors.toList()); // NOSONAR + } + }; + } + + private List getModelList() throws IOException { + if (Duration.between(lastListInfoRequest, Instant.now()).getSeconds() < 30) { + return Optional.ofNullable(modelsList).orElse(loadModelList()); + } + modelsList = loadModelList(); + return Optional.ofNullable(modelsList).orElse(Collections.emptyList()); + } + + private List loadModelList() throws IOException { + LOG.debug("Fetching page {}", API_URL); + lastListInfoRequest = Instant.now(); + StreamrayHttpClient client = (StreamrayHttpClient) site.getHttpClient(); + Request req = new Request.Builder() + .url(API_URL) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(REFERER, site.getBaseUrl() + "/") + .header(ORIGIN, site.getBaseUrl()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .build(); + try (Response response = client.execute(req)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + String content = response.body().string(); + JSONObject json = new JSONObject(content); + JSONArray modelNodes = json.getJSONArray("models"); + mapping = json.getJSONArray("mapping"); + parseModels(modelNodes, models); + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + int name_idx = mapping_index("stream_name"); + int date_idx = mapping_index("create_date"); + int gen_idx = mapping_index("gender"); + for (var i = 0; i < jsonModels.length(); i++) { + var m = jsonModels.getJSONArray(i); + String name = m.optString(name_idx); + String gender = m.optString(gen_idx); + String reg = m.optString(date_idx); + StreamrayModel model = (StreamrayModel) site.createModel(name); + try { + LocalDate regDate = LocalDate.parse(reg, DateTimeFormatter.BASIC_ISO_DATE); + model.setRegDate(regDate); + } catch (DateTimeParseException e) { + model.setRegDate(LocalDate.EPOCH); + } + String preview = getPreviewURL(name); + model.setPreview(preview); + model.setGender(gender); + models.add(model); + } + } + + private String getPreviewURL(String name) { + String lname = name.toLowerCase(); + String url = MessageFormat.format("https://images4.streamray.com/images/streamray/won/jpg/{0}/{1}/{2}_640.jpg", lname.substring(0,1), lname.substring(lname.length()-1), lname); + try { + return MessageFormat.format("https://dynimages.securedataimages.com/unsigned/rs:fill:640::0/g:no/plain/{0}@jpg", URLEncoder.encode(url, "utf-8")); + } catch (Exception ex) { + return url; + } + } + + public void setFilter(Predicate filter) { + this.filter = filter; + } + + private int mapping_index(String s) { + for (var i = 0; i < mapping.length(); i++) { + if (Objects.equals(s, mapping.get(i))) return i; + } + return -1; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvConfigUI.java b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvConfigUI.java new file mode 100644 index 00000000..d3c3cc42 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvConfigUI.java @@ -0,0 +1,55 @@ +package ctbrec.ui.sites.winktv; + +import ctbrec.Config; +import ctbrec.sites.winktv.WinkTv; +import ctbrec.ui.settings.SettingsTab; +import ctbrec.ui.sites.AbstractConfigUI; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.RadioButton; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; + +public class WinkTvConfigUI extends AbstractConfigUI { + private final WinkTv site; + + public WinkTvConfigUI(WinkTv site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + GridPane 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++); + row++; + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 2); + + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvSiteUi.java b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvSiteUi.java new file mode 100644 index 00000000..d87d3c99 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvSiteUi.java @@ -0,0 +1,40 @@ +package ctbrec.ui.sites.winktv; + +import ctbrec.sites.winktv.WinkTv; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +import java.io.IOException; + +public class WinkTvSiteUi extends AbstractSiteUi { + + private WinkTvTabProvider tabProvider; + private WinkTvConfigUI configUi; + private final WinkTv site; + + public WinkTvSiteUi(WinkTv site) { + this.site = site; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new WinkTvTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new WinkTvConfigUI(site); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + return site.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvTabProvider.java b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvTabProvider.java new file mode 100644 index 00000000..8bfdc6d7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvTabProvider.java @@ -0,0 +1,37 @@ +package ctbrec.ui.sites.winktv; + +import ctbrec.sites.winktv.WinkTv; +import ctbrec.sites.winktv.WinkTvModel; + +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +public class WinkTvTabProvider extends AbstractTabProvider { + + public WinkTvTabProvider(WinkTv site) { + super(site); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Live", m -> !m.isAdult())); + return tabs; + } + + private Tab createTab(String title, Predicate filter) { + var updateService = new WinkTvUpdateService((WinkTv) site, filter); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvUpdateService.java b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvUpdateService.java new file mode 100644 index 00000000..6efe43ea --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvUpdateService.java @@ -0,0 +1,127 @@ +package ctbrec.ui.sites.winktv; + +import static ctbrec.io.HttpConstants.*; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.winktv.WinkTv; +import ctbrec.sites.winktv.WinkTvModel; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import java.io.IOException; +import java.text.MessageFormat; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import javafx.concurrent.Task; +import okhttp3.FormBody; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class WinkTvUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(WinkTvUpdateService.class); + private static final String API_URL = "https://api.winktv.co.kr/v1/live"; + + private WinkTv site; + private String url; + private static List modelsList; + private static Instant lastListInfoRequest = Instant.EPOCH; + protected int modelsPerPage = 48; + protected Predicate filter; + + public WinkTvUpdateService(WinkTv site, Predicate filter) { + this.site = site; + this.filter = filter; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return getModelList().stream() + .filter(filter) + .skip((page - 1) * (long) modelsPerPage) + .limit(modelsPerPage) + .collect(Collectors.toList()); // NOSONAR + } + }; + } + + private List getModelList() throws IOException { + if (Duration.between(lastListInfoRequest, Instant.now()).getSeconds() < 30) { + return Optional.ofNullable(modelsList).orElse(loadModelList()); + } + modelsList = loadModelList(); + return Optional.ofNullable(modelsList).orElse(Collections.emptyList()); + } + + private List loadModelList() throws IOException { + LOG.debug("Fetching page {}", API_URL); + lastListInfoRequest = Instant.now(); + FormBody body = new FormBody.Builder() + .add("offset", "0") + .add("limit", "500") + .add("orderBy", "hot") + .build(); + Request req = new Request.Builder() + .url(API_URL) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(REFERER, site.getBaseUrl() + "/") + .header(ORIGIN, site.getBaseUrl()) + .post(body) + .build(); + try (var response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + var content = response.body().string(); + var json = new JSONObject(content); + if (json.optBoolean("result")) { + var modelNodes = json.getJSONArray("list"); + parseModels(modelNodes, models); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + for (var i = 0; i < jsonModels.length(); i++) { + var m = jsonModels.getJSONObject(i); + String name = m.optString("userId"); + WinkTvModel model = (WinkTvModel) site.createModel(name); + model.setDisplayName(m.getString("userNick")); + boolean isAdult = m.optBoolean("isAdult"); + model.setAdult(isAdult); + if (isAdult && m.has("ivsThumbnail")) { + model.setPreview(m.optString("ivsThumbnail")); + } else { + model.setPreview(m.optString("thumbUrl")); + } + boolean isLive = m.optBoolean("isLive"); + if (isLive) models.add(model); + } + } + + public void setFilter(Predicate filter) { + this.filter = filter; + } + +} diff --git a/common/src/main/java/ctbrec/AbstractModel.java b/common/src/main/java/ctbrec/AbstractModel.java index c21b3f03..5c7489b0 100644 --- a/common/src/main/java/ctbrec/AbstractModel.java +++ b/common/src/main/java/ctbrec/AbstractModel.java @@ -1,18 +1,7 @@ package ctbrec; -import static ctbrec.io.HttpConstants.*; - -import java.io.IOException; -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.ExecutionException; - import com.squareup.moshi.JsonReader; import com.squareup.moshi.JsonWriter; - import ctbrec.recorder.download.Download; import ctbrec.recorder.download.HttpHeaderFactory; import ctbrec.recorder.download.HttpHeaderFactoryImpl; @@ -23,6 +12,16 @@ import ctbrec.sites.Site; import okhttp3.Request; import okhttp3.Response; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + +import static ctbrec.io.HttpConstants.USER_AGENT; + public abstract class AbstractModel implements Model { private String url; @@ -42,6 +41,7 @@ public abstract class AbstractModel implements Model { private Instant lastRecorded; private Instant recordUntil; private Instant addedTimestamp = Instant.EPOCH; + private transient Instant delayUntil = Instant.EPOCH; private SubsequentAction recordUntilSubsequentAction; @Override @@ -72,7 +72,7 @@ public abstract class AbstractModel implements Model { @Override public String getDisplayName() { - if(displayName != null) { + if (displayName != null) { return displayName; } else { return getName(); @@ -149,6 +149,16 @@ public abstract class AbstractModel implements Model { this.suspended = suspended; } + @Override + public void delay() { + this.delayUntil = Instant.now().plusSeconds(120); + } + + @Override + public boolean isDelayed() { + return this.delayUntil.isAfter(Instant.now()); + } + @Override public boolean isMarkedForLaterRecording() { return markedForLaterRecording; diff --git a/common/src/main/java/ctbrec/Model.java b/common/src/main/java/ctbrec/Model.java index dd13beb6..a0aac827 100644 --- a/common/src/main/java/ctbrec/Model.java +++ b/common/src/main/java/ctbrec/Model.java @@ -1,23 +1,21 @@ package ctbrec; -import java.io.IOException; -import java.io.Serializable; -import java.time.Instant; -import java.util.List; -import java.util.concurrent.ExecutionException; - -import javax.xml.bind.JAXBException; - import com.iheartradio.m3u8.ParseException; import com.iheartradio.m3u8.PlaylistException; import com.squareup.moshi.JsonReader; import com.squareup.moshi.JsonWriter; - import ctbrec.recorder.download.Download; import ctbrec.recorder.download.HttpHeaderFactory; import ctbrec.recorder.download.StreamSource; import ctbrec.sites.Site; +import javax.xml.bind.JAXBException; +import java.io.IOException; +import java.io.Serializable; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.ExecutionException; + public interface Model extends Comparable, Serializable { long RECORD_INDEFINITELY = 9000000000000000000L; @@ -32,6 +30,7 @@ public interface Model extends Comparable, Serializable { UNKNOWN("unknown"); final String display; + State(String display) { this.display = display; } @@ -102,10 +101,8 @@ public interface Model extends Comparable, Serializable { /** * Determines the stream resolution for this model * - * @param failFast - * If set to true, the method returns immediately, even if the resolution is unknown. If - * the resolution is unknown, the array contains 0,0 - * + * @param failFast If set to true, the method returns immediately, even if the resolution is unknown. If + * the resolution is unknown, the array contains 0,0 * @return a tupel of width and height represented by an int[2] * @throws ExecutionException */ @@ -127,6 +124,10 @@ public interface Model extends Comparable, Serializable { void setSuspended(boolean suspended); + void delay(); + + boolean isDelayed(); + boolean isMarkedForLaterRecording(); void setMarkedForLaterRecording(boolean marked); @@ -140,14 +141,18 @@ public interface Model extends Comparable, Serializable { HttpHeaderFactory getHttpHeaderFactory(); boolean isRecordingTimeLimited(); + Instant getRecordUntil(); + void setRecordUntil(Instant instant); SubsequentAction getRecordUntilSubsequentAction(); + void setRecordUntilSubsequentAction(SubsequentAction action); /** * Check, if this model account exists + * * @return true, if it exists, false otherwise * @throws IOException */ diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 28083645..1cab26a2 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -206,4 +206,9 @@ public class Settings { public String webinterfacePassword = "sucks"; public String xlovecamUsername = ""; public String xlovecamPassword = ""; + public boolean stripchatVR = false; + public boolean streamrayRecordGoalShows = false; + public boolean checkForUpdates = true; + public int thumbCacheSize = 16; + public boolean dreamcamVR = false; } diff --git a/common/src/main/java/ctbrec/recorder/download/AbstractDownload.java b/common/src/main/java/ctbrec/recorder/download/AbstractDownload.java index c260fb7f..f79471b8 100644 --- a/common/src/main/java/ctbrec/recorder/download/AbstractDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/AbstractDownload.java @@ -1,18 +1,21 @@ package ctbrec.recorder.download; -import java.io.IOException; -import java.time.Instant; -import java.util.concurrent.ExecutorService; - import ctbrec.Config; import ctbrec.Model; import ctbrec.Settings; import ctbrec.UnknownModel; -import ctbrec.recorder.download.hls.CombinedSplittingStrategy; -import ctbrec.recorder.download.hls.NoopSplittingStrategy; -import ctbrec.recorder.download.hls.SizeSplittingStrategy; -import ctbrec.recorder.download.hls.TimeSplittingStrategy; +import ctbrec.recorder.download.hls.*; +import lombok.extern.slf4j.Slf4j; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; + +import static ctbrec.recorder.download.StreamSource.UNKNOWN; + +@Slf4j public abstract class AbstractDownload implements Download { protected Instant startTime; @@ -22,6 +25,7 @@ public abstract class AbstractDownload implements Download { protected Config config; protected SplittingStrategy splittingStrategy; protected ExecutorService downloadExecutor; + protected int selectedResolution = UNKNOWN; @Override public void init(Config config, Model model, Instant startTime, ExecutorService executorService) throws IOException { @@ -45,21 +49,21 @@ public abstract class AbstractDownload implements Download { protected SplittingStrategy initSplittingStrategy(Settings settings) { SplittingStrategy strategy; switch (settings.splitStrategy) { - case TIME: - strategy = new TimeSplittingStrategy(); - break; - case SIZE: - strategy = new SizeSplittingStrategy(); - break; - case TIME_OR_SIZE: - SplittingStrategy timeSplittingStrategy = new TimeSplittingStrategy(); - SplittingStrategy sizeSplittingStrategy = new SizeSplittingStrategy(); - strategy = new CombinedSplittingStrategy(timeSplittingStrategy, sizeSplittingStrategy); - break; - case DONT: - default: - strategy = new NoopSplittingStrategy(); - break; + case TIME: + strategy = new TimeSplittingStrategy(); + break; + case SIZE: + strategy = new SizeSplittingStrategy(); + break; + case TIME_OR_SIZE: + SplittingStrategy timeSplittingStrategy = new TimeSplittingStrategy(); + SplittingStrategy sizeSplittingStrategy = new SizeSplittingStrategy(); + strategy = new CombinedSplittingStrategy(timeSplittingStrategy, sizeSplittingStrategy); + break; + case DONT: + default: + strategy = new NoopSplittingStrategy(); + break; } strategy.init(settings); return strategy; @@ -72,6 +76,39 @@ public abstract class AbstractDownload implements Download { @Override public int getSelectedResolution() { - return StreamSource.UNKNOWN; + return selectedResolution; + } + + public void awaitEnd() { + // do nothing per default + } + + protected StreamSource selectStreamSource(List streamSources) throws ExecutionException { + if (model.getStreamUrlIndex() >= 0 && model.getStreamUrlIndex() < streamSources.size()) { + // TODO don't use the index, but the bandwidth. if the bandwidth does not match, take the closest one + log.debug("Model stream index: {}", model.getStreamUrlIndex()); + streamSources.forEach(ss -> log.debug(ss.toString())); + StreamSource source = streamSources.get(model.getStreamUrlIndex()); + log.debug("{} selected {}", model.getName(), source); + selectedResolution = source.height; + return source; + } else { + // filter out stream resolutions, which are out of range of the configured min and max + int minRes = Config.getInstance().getSettings().minimumResolution; + int maxRes = Config.getInstance().getSettings().maximumResolution; + List filteredStreamSources = streamSources.stream() + .filter(src -> src.height == 0 || src.height == UNKNOWN || minRes <= src.height) + .filter(src -> src.height == 0 || src.height == UNKNOWN || maxRes >= src.height) + .toList(); + + if (filteredStreamSources.isEmpty()) { + throw new ExecutionException(new NoStreamFoundException("No stream left in playlist")); + } else { + StreamSource source = filteredStreamSources.get(filteredStreamSources.size() - 1); + log.debug("{} selected {}", model.getName(), source); + selectedResolution = source.height; + return source; + } + } } } diff --git a/common/src/main/java/ctbrec/recorder/download/hls/FfmpegHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/FfmpegHlsDownload.java new file mode 100644 index 00000000..e028fd2f --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/hls/FfmpegHlsDownload.java @@ -0,0 +1,325 @@ +package ctbrec.recorder.download.hls; + +import com.iheartradio.m3u8.*; +import com.iheartradio.m3u8.data.MediaPlaylist; +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.TrackData; +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.OS; +import ctbrec.Recording; +import ctbrec.io.BandwidthMeter; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.recorder.FFmpeg; +import ctbrec.recorder.InvalidPlaylistException; +import ctbrec.recorder.download.AbstractDownload; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.ProcessExitedUncleanException; +import ctbrec.recorder.download.StreamSource; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.xml.bind.JAXBException; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.nio.file.Files; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; + +import static ctbrec.io.HttpConstants.*; + +public class FfmpegHlsDownload extends AbstractDownload { + + private static final Logger LOG = LoggerFactory.getLogger(FfmpegHlsDownload.class); + private static final int MAX_SECONDS_WITHOUT_TRANSFER = 20; + + private final HttpClient httpClient; + private Instant timeOfLastTransfer = Instant.MAX; + + protected File targetFile; + protected FFmpeg ffmpeg; + protected Process ffmpegProcess; + protected OutputStream ffmpegStdIn; + protected Lock ffmpegStreamLock = new ReentrantLock(); + protected String mediaUrl = null; + + private volatile boolean running; + private volatile boolean started; + private int selectedResolution = 0; + + public FfmpegHlsDownload(HttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public void init(Config config, Model model, Instant startTime, ExecutorService executorService) throws IOException { + super.init(config, model, startTime, executorService); + + timeOfLastTransfer = Instant.now(); + String fileSuffix = config.getSettings().ffmpegFileSuffix; + + targetFile = config.getFileForRecording(model, fileSuffix, startTime); + createTargetDirectory(); + startFfmpegProcess(targetFile); + if (ffmpegProcess == null) { + throw new ProcessExitedUncleanException("Couldn't spawn FFmpeg"); + } + } + + @Override + public int getSelectedResolution() { + return selectedResolution; + } + + @Override + public void stop() { + if (running) { + internalStop(); + } + } + + private synchronized void internalStop() { + running = false; + if (ffmpegStdIn != null) { + try { + ffmpegStdIn.close(); + } catch (IOException e) { + LOG.error("Couldn't terminate FFmpeg by closing stdin", e); + } + } + if (ffmpegProcess != null) { + try { + boolean waitFor = ffmpegProcess.waitFor(45, TimeUnit.SECONDS); + if (!waitFor && ffmpegProcess.isAlive()) { + ffmpegProcess.destroy(); + if (ffmpegProcess.isAlive()) { + LOG.info("FFmpeg didn't terminate. Destroying the process with force!"); + ffmpegProcess.destroyForcibly(); + ffmpegProcess = null; + } + } + } catch (InterruptedException e) { + LOG.error("Interrupted while waiting for FFmpeg to terminate"); + Thread.currentThread().interrupt(); + } + } + } + + private void startFfmpegProcess(File target) { + try { + String[] cmdline = prepareCommandLine(target); + ffmpeg = new FFmpeg.Builder() + .logOutput(config.getSettings().logFFmpegOutput) + .onStarted(p -> { + ffmpegProcess = p; + ffmpegStdIn = ffmpegProcess.getOutputStream(); + }) + .build(); + ffmpeg.exec(cmdline, new String[0], target.getParentFile()); + } catch (IOException | ProcessExitedUncleanException e) { + LOG.error("Error in FFmpeg thread", e); + } + } + + private String[] prepareCommandLine(File target) { + String[] args = config.getSettings().ffmpegMergedDownloadArgs.split(" "); + String[] argsPlusFile = new String[args.length + 3]; + int i = 0; + argsPlusFile[i++] = "-i"; + argsPlusFile[i++] = "-"; + System.arraycopy(args, 0, argsPlusFile, i, args.length); + argsPlusFile[argsPlusFile.length - 1] = target.getAbsolutePath(); + return OS.getFFmpegCommand(argsPlusFile); + } + + @Override + public void finalizeDownload() { + internalStop(); + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public void postprocess(Recording recording) { + // nothing to do + } + + @Override + public File getTarget() { + return targetFile; + } + + @Override + public String getPath(Model model) { + String absolutePath = targetFile.getAbsolutePath(); + String recordingsDir = Config.getInstance().getSettings().recordingsDir; + String relativePath = absolutePath.replaceFirst(Pattern.quote(recordingsDir), ""); + return relativePath; + } + + @Override + public boolean isSingleFile() { + return true; + } + + @Override + public long getSizeInByte() { + return getTarget().length(); + } + + @Override + public Download call() throws Exception { + try { + if (!ffmpegProcess.isAlive()) { + running = false; + int exitValue = ffmpegProcess.exitValue(); + ffmpeg.shutdown(exitValue); + } + } catch (ProcessExitedUncleanException e) { + LOG.error("FFmpeg exited unclean", e); + internalStop(); + } + try { + if (!started) { + started = true; + startDownload(); + } + } catch (Exception e) { + LOG.error("Error while downloading MP4", e); + stop(); + } + if (!model.isOnline()) { + LOG.debug("Model {} not online. Stop recording.", model); + stop(); + } + if (splittingStrategy.splitNecessary(this)) { + LOG.debug("Split necessary for model {}. Stop recording.", model); + internalStop(); + } else { + rescheduleTime = Instant.now().plusSeconds(5); + } + if (Duration.between(timeOfLastTransfer, Instant.now()).getSeconds() > MAX_SECONDS_WITHOUT_TRANSFER) { + LOG.debug("No video data received for {} seconds. Stopping recording for model {}", MAX_SECONDS_WITHOUT_TRANSFER, model); + stop(); + } + return this; + } + + private String getMediaUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException, InvalidPlaylistException, JAXBException { + List streamSources = model.getStreamSources(); + Collections.sort(streamSources); + for (StreamSource streamSource : streamSources) { + LOG.debug("{}:{} src {}", model.getSite().getName(), model.getName(), streamSource); + } + StreamSource selectedStreamSource = selectStreamSource(streamSources); + String playlistUrl = selectedStreamSource.getMediaPlaylistUrl(); + selectedResolution = selectedStreamSource.height; + + Request req = new Request.Builder() + .url(playlistUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en") + .header(ORIGIN, model.getSite().getBaseUrl()) + .header(REFERER, model.getSite().getBaseUrl()) + .build(); + try (Response response = model.getSite().getHttpClient().execute(req)) { + if (response.isSuccessful()) { + InputStream inputStream = Objects.requireNonNull(response.body()).byteStream(); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); + Playlist playlist = parser.parse(); + MediaPlaylist media = playlist.getMediaPlaylist(); + if (media.hasTracks()) { + TrackData firstTrack = media.getTracks().get(0); + if (firstTrack.isEncrypted()) { + LOG.warn("Video track is encrypted. Playlist URL: {}", playlistUrl); + } + String uri = firstTrack.getUri(); + if (!uri.startsWith("http")) { + URL context = new URL(playlistUrl); + uri = new URL(context, uri).toExternalForm(); + } + LOG.debug("Media url {}", uri); + return uri; + } else { + throw new InvalidPlaylistException("Playlist has no media"); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void startDownload() { + downloadExecutor.submit(() -> { + running = true; + ffmpegStreamLock.lock(); + try { + if (mediaUrl == null) { + mediaUrl = getMediaUrl(model); + } + Request request = new Request.Builder() + .url(mediaUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en") + .header(ORIGIN, model.getSite().getBaseUrl()) + .header(REFERER, model.getSite().getBaseUrl()) + .build(); + try (Response resp = httpClient.execute(request)) { + if (resp.isSuccessful()) { + LOG.debug("Recording video stream to {}", targetFile); + InputStream in = Objects.requireNonNull(resp.body()).byteStream(); + byte[] b = new byte[1024 * 4]; + int len; + while (running && !Thread.currentThread().isInterrupted() && (len = in.read(b)) >= 0) { + ffmpegStdIn.write(b, 0, len); + timeOfLastTransfer = Instant.now(); + BandwidthMeter.add(len); + } + } else { + throw new HttpException(resp.code(), resp.message()); + } + } + } catch (SocketTimeoutException e) { + LOG.debug("Socket timeout while downloading MP4 for {}. Stop recording.", model.getName()); + model.delay(); + stop(); + } catch (IOException e) { + LOG.debug("IO error while downloading MP4 for {}. Stop recording.", model.getName()); + model.delay(); + stop(); + } catch (Exception e) { + LOG.error("Error while downloading MP4", e); + stop(); + } finally { + ffmpegStreamLock.unlock(); + } + LOG.debug("Record finished for model {}", model); + running = false; + }); + } + + protected void createTargetDirectory() throws IOException { + Files.createDirectories(targetFile.getParentFile().toPath()); + } +} diff --git a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java index 929cea72..a6bfaf62 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java @@ -115,7 +115,7 @@ public class Chaturbate extends AbstractSite { @Override public boolean supportsTips() { - return true; + return false; } @Override diff --git a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java index bcc54505..3ea2559d 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java @@ -4,14 +4,12 @@ import ctbrec.Config; import ctbrec.io.HtmlParser; import ctbrec.io.HttpClient; import okhttp3.*; -import org.jsoup.nodes.Element; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InterruptedIOException; import java.util.NoSuchElementException; -import java.util.Objects; import java.util.concurrent.Semaphore; import static ctbrec.io.HttpConstants.REFERER; @@ -98,6 +96,8 @@ public class ChaturbateHttpClient extends HttpClient { } } response.close(); + } catch (Exception ex) { + LOG.debug("Login failed: {}", ex.getMessage()); } finally { loginTries = 0; } @@ -105,24 +105,15 @@ public class ChaturbateHttpClient extends HttpClient { } public boolean checkLogin() throws IOException { - String url = "https://chaturbate.com/p/" + Config.getInstance().getSettings().chaturbateUsername + "/"; + String url = "https://chaturbate.com/api/ts/chatmessages/pm_users/?offset=0"; Request req = new Request.Builder() .url(url) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response resp = execute(req)) { - if (resp.isSuccessful()) { - String profilePage = resp.body().string(); - try { - Element userIcon = HtmlParser.getTag(profilePage, "img.user_information_header_icon"); - return !Objects.equals("Anonymous Icon", userIcon.attr("alt")); - } catch (Exception e) { - LOG.debug("Token tag not found. Login failed"); - return false; - } - } else { - throw new IOException("HTTP response: " + resp.code() + " - " + resp.message()); - } + return (resp.isSuccessful() && !resp.isRedirect()); + } catch (Exception ex) { + return false; } } diff --git a/common/src/main/java/ctbrec/sites/dreamcam/Dreamcam.java b/common/src/main/java/ctbrec/sites/dreamcam/Dreamcam.java new file mode 100644 index 00000000..93717dd7 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/dreamcam/Dreamcam.java @@ -0,0 +1,127 @@ +package ctbrec.sites.dreamcam; + +import static ctbrec.io.HttpConstants.*; + +import ctbrec.Model; +import ctbrec.io.HttpClient; +import ctbrec.sites.AbstractSite; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Dreamcam extends AbstractSite { + + public static String domain = "dreamcam.com"; + public static String baseUri = "https://dreamcam.com"; + private HttpClient httpClient; + + @Override + public void init() throws IOException { + } + + @Override + public String getName() { + return "DreamCam"; + } + + @Override + public String getBaseUrl() { + return baseUri; + } + + @Override + public String getAffiliateLink() { + return baseUri; + } + + @Override + public String getBuyTokensLink() { + return getAffiliateLink(); + } + + @Override + public DreamcamModel createModel(String name) { + DreamcamModel model = new DreamcamModel(); + model.setName(name); + model.setUrl(getBaseUrl() + "/models/" + name); + model.setSite(this); + return model; + } + + @Override + public Double getTokenBalance() throws IOException { + return 0d; + } + + @Override + public synchronized boolean login() throws IOException { + return credentialsAvailable() && getHttpClient().login(); + } + + @Override + public HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new DreamcamHttpClient(getConfig()); + } + return httpClient; + } + + @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 false; + } + + @Override + public boolean searchRequiresLogin() { + return false; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + return Collections.emptyList(); + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof DreamcamModel; + } + + @Override + public boolean credentialsAvailable() { + return false; + } + + @Override + public Model createModelFromUrl(String url) { + String[] patterns = { + "https://.*?dreamcam.com/models/([_a-zA-Z0-9]+)", + }; + for (String p : patterns) { + Matcher m = Pattern.compile(p).matcher(url); + if (m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } + } + return super.createModelFromUrl(url); + } +} diff --git a/common/src/main/java/ctbrec/sites/dreamcam/DreamcamDownload.java b/common/src/main/java/ctbrec/sites/dreamcam/DreamcamDownload.java new file mode 100644 index 00000000..2558bd09 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/dreamcam/DreamcamDownload.java @@ -0,0 +1,341 @@ +package ctbrec.sites.dreamcam; + +import ctbrec.Config; +import ctbrec.StringUtil; +import ctbrec.Model; +import ctbrec.OS; +import ctbrec.Recording; +import ctbrec.io.BandwidthMeter; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.recorder.FFmpeg; +import ctbrec.recorder.InvalidPlaylistException; +import ctbrec.recorder.download.AbstractDownload; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.ProcessExitedUncleanException; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.EOFException; +import java.nio.file.Files; +import java.net.SocketTimeoutException; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static ctbrec.io.HttpConstants.*; + +public class DreamcamDownload extends AbstractDownload { + + private static final Logger LOG = LoggerFactory.getLogger(DreamcamDownload.class); + private static final int MAX_SECONDS_WITHOUT_TRANSFER = 30; + + private final HttpClient httpClient; + private Instant timeOfLastTransfer = Instant.MAX; + + protected File targetFile; + protected FFmpeg ffmpeg; + protected Process ffmpegProcess; + protected OutputStream ffmpegStdIn; + protected Lock ffmpegStreamLock = new ReentrantLock(); + protected String wsUrl; + + private volatile boolean running; + private volatile boolean started; + private final transient Object monitor = new Object(); + private WebSocket ws; + private DreamcamModel model; + + public DreamcamDownload(HttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public void init(Config config, Model model, Instant startTime, ExecutorService executorService) throws IOException { + super.init(config, model, startTime, executorService); + this.model = (DreamcamModel) model; + timeOfLastTransfer = startTime; + String fileSuffix = config.getSettings().ffmpegFileSuffix; + targetFile = config.getFileForRecording(model, fileSuffix, startTime); + createTargetDirectory(); + startFfmpegProcess(targetFile); + if (ffmpegProcess == null) { + throw new ProcessExitedUncleanException("Couldn't spawn FFmpeg"); + } + } + + @Override + public int getSelectedResolution() { + return 0; + } + + @Override + public void stop() { + if (running) { + internalStop(); + } + } + + private synchronized void internalStop() { + running = false; + if (ws != null) { + ws.close(1000, null); + ws = null; + } + if (ffmpegStdIn != null) { + try { + ffmpegStdIn.close(); + } catch (IOException e) { + LOG.error("Couldn't terminate FFmpeg by closing stdin", e); + } + } + if (ffmpegProcess != null) { + try { + boolean waitFor = ffmpegProcess.waitFor(45, TimeUnit.SECONDS); + if (!waitFor && ffmpegProcess.isAlive()) { + ffmpegProcess.destroy(); + if (ffmpegProcess.isAlive()) { + LOG.info("FFmpeg didn't terminate. Destroying the process with force!"); + ffmpegProcess.destroyForcibly(); + ffmpegProcess = null; + } + } + } catch (InterruptedException e) { + LOG.error("Interrupted while waiting for FFmpeg to terminate"); + Thread.currentThread().interrupt(); + } + } + } + + private void startFfmpegProcess(File target) { + try { + String[] cmdline = prepareCommandLine(target); + ffmpeg = new FFmpeg.Builder() + .logOutput(config.getSettings().logFFmpegOutput) + .onStarted(p -> { + ffmpegProcess = p; + ffmpegStdIn = ffmpegProcess.getOutputStream(); + }) + .build(); + ffmpeg.exec(cmdline, new String[0], target.getParentFile()); + } catch (IOException | ProcessExitedUncleanException e) { + LOG.error("Error in FFmpeg thread", e); + } + } + + private String[] prepareCommandLine(File target) { + String[] args = config.getSettings().ffmpegMergedDownloadArgs.split(" "); + String[] argsPlusFile = new String[args.length + 3]; + int i = 0; + argsPlusFile[i++] = "-i"; + argsPlusFile[i++] = "-"; + System.arraycopy(args, 0, argsPlusFile, i, args.length); + argsPlusFile[argsPlusFile.length - 1] = target.getAbsolutePath(); + return OS.getFFmpegCommand(argsPlusFile); + } + + @Override + public void finalizeDownload() { + internalStop(); + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public void postprocess(Recording recording) { + // nothing to do + } + + @Override + public File getTarget() { + return targetFile; + } + + @Override + public String getPath(Model model) { + String absolutePath = targetFile.getAbsolutePath(); + String recordingsDir = Config.getInstance().getSettings().recordingsDir; + String relativePath = absolutePath.replaceFirst(Pattern.quote(recordingsDir), ""); + return relativePath; + } + + @Override + public boolean isSingleFile() { + return true; + } + + @Override + public long getSizeInByte() { + return getTarget().length(); + } + + @Override + public Download call() throws Exception { + try { + if (!ffmpegProcess.isAlive()) { + running = false; + int exitValue = ffmpegProcess.exitValue(); + ffmpeg.shutdown(exitValue); + } + } catch (ProcessExitedUncleanException e) { + LOG.error("FFmpeg exited unclean", e); + internalStop(); + } + try { + if (!started) { + started = true; + startDownload(); + } + } catch (Exception e) { + LOG.error("Error while downloading", e); + stop(); + } + if (!model.isOnline()) { + LOG.debug("Model {} not online. Stop recording.", model); + stop(); + } + if (splittingStrategy.splitNecessary(this)) { + LOG.debug("Split necessary for model {}. Stop recording.", model); + internalStop(); + rescheduleTime = Instant.now(); + } else { + rescheduleTime = Instant.now().plusSeconds(5); + } + if (Duration.between(timeOfLastTransfer, Instant.now()).getSeconds() > MAX_SECONDS_WITHOUT_TRANSFER) { + LOG.debug("No video data received for {} seconds. Stopping recording for model {}", MAX_SECONDS_WITHOUT_TRANSFER, model); + stop(); + } + return this; + } + + private void startDownload() { + downloadExecutor.submit(() -> { + running = true; + ffmpegStreamLock.lock(); + try { + wsUrl = model.getWsUrl(); + LOG.debug("{} ws url: {}", model.getName(), wsUrl); + if (StringUtil.isBlank(wsUrl)) { + LOG.error("{}: Stream URL not found", model); + stop(); + return; + } + Request request = new Request.Builder() + .url(wsUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "en") + .header(ORIGIN, model.getSite().getBaseUrl()) + .header(REFERER, model.getSite().getBaseUrl() + "/") + .build(); + + ws = httpClient.newWebSocket(request, new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, Response response) { + super.onOpen(webSocket, response); + LOG.debug("{}: Websocket open", model); + if (response != null) { + response.close(); + } + JSONObject msg = new JSONObject(); + msg.put("url", "stream/hello"); + msg.put("version", "0.0.1"); + webSocket.send(msg.toString()); + } // onOpen + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + super.onClosed(webSocket, code, reason); + LOG.trace("{}: Websocket closed", model); + stop(); + synchronized (monitor) { + monitor.notifyAll(); + } + } // onClosed + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + super.onFailure(webSocket, t, response); + LOG.debug("{}: Websocket failed: {}", model, t.getMessage()); + if (response != null) { + response.close(); + } + stop(); + synchronized (monitor) { + monitor.notifyAll(); + } + } // onFailure + + @Override + public void onMessage(WebSocket webSocket, String text) { + super.onMessage(webSocket, text); + LOG.trace("{} ws message: {}", model, text); + JSONObject message = new JSONObject(text); + if (message.optString("url").equals("stream/qual")) { + JSONObject msg = new JSONObject(); + msg.put("quality", "test"); + msg.put("url", "stream/play"); + msg.put("version", "0.0.1"); + webSocket.send(msg.toString()); + } + } // onMessage + + @Override + public void onMessage(WebSocket webSocket, ByteString bytes) { + super.onMessage(webSocket, bytes); + timeOfLastTransfer = Instant.now(); + try { + if (running) { + byte[] videoData = bytes.toByteArray(); + ffmpegStdIn.write(videoData); + BandwidthMeter.add(videoData.length); + } + } catch (IOException e) { + if (running) { + LOG.error("Couldn't write video stream to file", e); + stop(); + } + } + } // onMessage + + }); // websocket + + synchronized (monitor) { + try { + monitor.wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Interrupted while waiting for the download to terminate"); + } + } + } catch (IOException ex) { + if (running) { + LOG.error("Error while downloading: {}", ex.getMessage()); + stop(); + } + } finally { + ffmpegStreamLock.unlock(); + running = false; + } + }); // submit + } + + protected void createTargetDirectory() throws IOException { + Files.createDirectories(targetFile.getParentFile().toPath()); + } +} diff --git a/common/src/main/java/ctbrec/sites/dreamcam/DreamcamHttpClient.java b/common/src/main/java/ctbrec/sites/dreamcam/DreamcamHttpClient.java new file mode 100644 index 00000000..b67e329b --- /dev/null +++ b/common/src/main/java/ctbrec/sites/dreamcam/DreamcamHttpClient.java @@ -0,0 +1,17 @@ +package ctbrec.sites.dreamcam; + +import ctbrec.Config; +import ctbrec.io.HttpClient; +import java.io.IOException; + +public class DreamcamHttpClient extends HttpClient { + + public DreamcamHttpClient(Config config) { + super("dreamcam", config); + } + + @Override + public boolean login() throws IOException { + return false; + } +} diff --git a/common/src/main/java/ctbrec/sites/dreamcam/DreamcamModel.java b/common/src/main/java/ctbrec/sites/dreamcam/DreamcamModel.java new file mode 100644 index 00000000..cf07878f --- /dev/null +++ b/common/src/main/java/ctbrec/sites/dreamcam/DreamcamModel.java @@ -0,0 +1,205 @@ +package ctbrec.sites.dreamcam; + +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.StringUtil; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.recorder.InvalidPlaylistException; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.StreamSource; +import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.text.MessageFormat; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import javax.xml.bind.JAXBException; +import okhttp3.FormBody; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static ctbrec.Model.State.*; +import static ctbrec.io.HttpConstants.*; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class DreamcamModel extends AbstractModel { + + private static final Logger LOG = LoggerFactory.getLogger(DreamcamModel.class); + private static final String API_URL = "https://bss.dreamcamtrue.com"; + private int[] resolution = new int[2]; + private JSONObject modelInfo; + private boolean VRMode = false; + + private transient Instant lastInfoRequest = Instant.EPOCH; + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + if (ignoreCache) { + try { + JSONObject json = getModelInfo(); + mapOnlineState(json.optString("broadcastStatus")); + } catch (Exception e) { + setOnlineState(OFFLINE); + } + } + return onlineState == ONLINE; + } + + private void mapOnlineState(String status) { + switch (status) { + case "public" -> setOnlineState(ONLINE); + case "private" -> setOnlineState(PRIVATE); + case "offline" -> setOnlineState(OFFLINE); + default -> setOnlineState(OFFLINE); + } + } + + @Override + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if (failFast && onlineState != UNKNOWN) { + return onlineState; + } else { + try { + JSONObject json = getModelInfo(); + mapOnlineState(json.optString("broadcastStatus")); + } catch (Exception ex) { + setOnlineState(OFFLINE); + } + return onlineState; + } + } + + @Override + public List getStreamSources() throws InvalidPlaylistException { + List sources = new ArrayList<>(); + try { + StreamSource src = new StreamSource(); + src.mediaPlaylistUrl = getPlaylistUrl(); + sources.add(src); + } catch (Exception e) { + LOG.error("Can not get stream sources for {}: {}", getName(), e.getMessage()); + throw new InvalidPlaylistException(e.getMessage()); + } + return sources; + } + + private String getPlaylistUrl() throws IOException, InvalidPlaylistException { + JSONObject json = getModelInfo(); + String mediaUrl = ""; + if (json.has("streams")) { + JSONArray streams = json.getJSONArray("streams"); + for (int i=0; i < streams.length(); i++) { + JSONObject s = streams.getJSONObject(i); + if (s.has("streamType") && s.has("url")) { + String streamType = s.getString("streamType"); + if (streamType.equals("video2D")) { + mediaUrl = s.optString("url"); + LOG.trace("PlaylistUrl for {}: {}", getName(), mediaUrl); + } + } + } + } + if (StringUtil.isBlank(mediaUrl)) { + throw new InvalidPlaylistException("Playlist has no media"); + } + return mediaUrl; + } + + public String getWsUrl() throws IOException { + JSONObject json = getModelInfo(); + return json.optString("streamUrl").replace("fmp4s://", "wss://"); + } + + public String getChatId() throws IOException { + JSONObject json = getModelInfo(); + return json.optString("roomChatId"); + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + return new int[2]; + } + + private JSONObject getModelInfo() throws IOException { + if (Duration.between(lastInfoRequest, Instant.now()).getSeconds() < 5) { + modelInfo = Optional.ofNullable(modelInfo).orElse(loadModelInfo()); + } else { + modelInfo = loadModelInfo(); + } + return modelInfo; + } + + private JSONObject loadModelInfo() throws IOException { + lastInfoRequest = Instant.now(); + String url = MessageFormat.format(API_URL + "/api/clients/v1/broadcasts/models/{0}?partnerId=dreamcam_oauth2&show-hidden=true&stream-types=video2D,video3D", getName()); + Request req = new Request.Builder() + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(REFERER, getUrl()) + .header(ORIGIN, getSite().getBaseUrl()) + .build(); + try (Response response = getSite().getHttpClient().execute(req)) { + if (response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + return json; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + public String getPreviewURL() throws IOException { + JSONObject json = getModelInfo(); + return json.optString("modelProfilePhotoUrl"); + } + + @Override + public boolean follow() throws IOException { + return false; + } + + @Override + public boolean unfollow() throws IOException { + return false; + } + + @Override + public void receiveTip(Double tokens) throws IOException { + // not implemented + } + + @Override + public void invalidateCacheEntries() { + resolution = new int[]{0, 0}; + lastInfoRequest = Instant.EPOCH; + modelInfo = null; + } + + @Override + public Download createDownload() { + if (Config.getInstance().getSettings().dreamcamVR) { + return new DreamcamDownload(getSite().getHttpClient()); + } else { + return new MergedFfmpegHlsDownload(getSite().getHttpClient()); + } + } +} diff --git a/common/src/main/java/ctbrec/sites/streamray/Streamray.java b/common/src/main/java/ctbrec/sites/streamray/Streamray.java new file mode 100644 index 00000000..8f0ed763 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamray/Streamray.java @@ -0,0 +1,178 @@ +package ctbrec.sites.streamray; + +import static ctbrec.io.HttpConstants.*; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.json.JSONArray; +import org.json.JSONObject; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.sites.AbstractSite; + + +public class Streamray extends AbstractSite { + + private static final Logger LOG = LoggerFactory.getLogger(Streamray.class); + + private StreamrayHttpClient httpClient; + public static String domain = "streamray.com"; + public static String baseUri = "https://streamray.com"; + public static String apiURL = "https://beta-api.cams.com"; + + @Override + public void init() throws IOException {} + + @Override + public String getName() { + return "Streamray"; + } + + @Override + public String getBaseUrl() { + return baseUri; + } + + public String getApiUrl() { + return apiURL; + } + + @Override + public StreamrayModel createModel(String name) { + StreamrayModel model = new StreamrayModel(); + 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 getBaseUrl(); + } + + @Override + public synchronized boolean login() throws IOException { + boolean result = getHttpClient().login(); + LOG.debug("Streamray site login call result: {}", result); + return result; + } + + @Override + public HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new StreamrayHttpClient(getConfig()); + } + return httpClient; + } + + @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 { + if (StringUtil.isBlank(q)) { + return Collections.emptyList(); + } + String url = getApiUrl() + "/models/new/?limit=30&search=" + URLEncoder.encode(q, "utf-8") + "&order=is_online"; + Request req = new Request.Builder() + .url(url) + .header(USER_AGENT, getConfig().getSettings().httpUserAgent) + .build(); + try (Response response = getHttpClient().execute(req)) { + if (response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if (json.has("results")) { + List models = new ArrayList<>(); + JSONArray results = json.getJSONArray("results"); + if (results.length() == 0) { + return Collections.emptyList(); + } + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + StreamrayModel model = createModel(result.getString("stream_name")); + String image = result.optString("profile_image"); + if (StringUtil.isBlank(image)) { + image = model.getPreviewURL(); + } + model.setPreview(image); + models.add(model); + } + return models; + } else { + return Collections.emptyList(); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof StreamrayModel; + } + + @Override + public boolean credentialsAvailable() { + return true; + } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https://(streamray|cams).com/([_a-zA-Z0-9]+)").matcher(url); + if (m.matches()) { + String modelName = m.group(2); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } + + @Override + public String getAffiliateLink() { + return getBaseUrl(); + } +} diff --git a/common/src/main/java/ctbrec/sites/streamray/StreamrayHttpClient.java b/common/src/main/java/ctbrec/sites/streamray/StreamrayHttpClient.java new file mode 100644 index 00000000..de20170a --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamray/StreamrayHttpClient.java @@ -0,0 +1,79 @@ +package ctbrec.sites.streamray; + +import static ctbrec.io.HttpConstants.*; + +import ctbrec.Config; +import ctbrec.io.HttpClient; +import ctbrec.StringUtil; +import java.io.IOException; +import java.util.Locale; +import org.json.JSONObject; +import okhttp3.*; + + +public class StreamrayHttpClient extends HttpClient { + + public StreamrayHttpClient(Config config) { + super("streamray", config); + } + + @Override + public boolean login() throws IOException { + String token = getUserToken(); + if (StringUtil.isBlank(token)) { + return false; + } else { + boolean isSuccess = checkLoginSuccess(); + if (isSuccess) { + return true; + } else { + updateToken(); + return checkLoginSuccess(); + } + } + } + + private void updateToken() { + Request req = new Request.Builder() + .url(Streamray.baseUri) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .build(); + try (Response response = execute(req)) {} catch (Exception ex) {} + } + + private boolean checkLoginSuccess() { + String token = getUserToken(); + Request req = new Request.Builder() + .url(Streamray.apiURL + "/members/me/balance/") + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(AUTHORIZATION, "Bearer " + token) + .header(REFERER, Streamray.baseUri + "/") + .header(ORIGIN, Streamray.baseUri) + .build(); + try (Response response = execute(req)) { + if (response.isSuccessful()) { + String content = response.body().string(); + JSONObject json = new JSONObject(content); + return json.has("balance"); + } else { + return false; + } + } catch (Exception ex) { + return false; + } + } + + public String getUserToken() { + try { + Cookie cookie = getCookieJar().getCookie(HttpUrl.parse(Streamray.baseUri), "memberToken"); + String token = cookie.value(); + return token; + } catch (Exception e) { + return ""; + } + } +} diff --git a/common/src/main/java/ctbrec/sites/streamray/StreamrayModel.java b/common/src/main/java/ctbrec/sites/streamray/StreamrayModel.java new file mode 100644 index 00000000..60c73869 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamray/StreamrayModel.java @@ -0,0 +1,197 @@ +package ctbrec.sites.streamray; + +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; +import ctbrec.AbstractModel; +import ctbrec.Config; +import ctbrec.io.HttpException; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.StreamSource; +import ctbrec.recorder.download.hls.FfmpegHlsDownload; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.xml.bind.JAXBException; +import java.io.IOException; +import java.net.URLEncoder; +import java.text.MessageFormat; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + +import static ctbrec.Model.State.*; +import static ctbrec.io.HttpConstants.*; + +public class StreamrayModel extends AbstractModel { + + private static final Logger LOG = LoggerFactory.getLogger(StreamrayModel.class); + private String status = null; + private String gender = null; + private LocalDate regDate = LocalDate.EPOCH; + private JSONObject modelInfo; + + private transient Instant lastInfoRequest = Instant.EPOCH; + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + if (ignoreCache) { + try { + JSONObject json = getModelInfo(); + if (json.has("online")) { + status = json.optString("online"); + mapOnlineState(status); + } + } catch (Exception e) { + setOnlineState(UNKNOWN); + } + } + return onlineState == ONLINE; + } + + private void mapOnlineState(String status) { + boolean goalShows = Config.getInstance().getSettings().streamrayRecordGoalShows; + switch (status) { + case "0" -> setOnlineState(OFFLINE); + case "1" -> setOnlineState(ONLINE); + case "6" -> setOnlineState(goalShows ? ONLINE : PRIVATE); + case "2", "3", "4", "7", "10", "11", "12", "13", "14" -> setOnlineState(PRIVATE); + default -> setOnlineState(OFFLINE); + } + } + + @Override + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if (failFast && onlineState != UNKNOWN) { + return onlineState; + } else { + try { + onlineState = isOnline(true) ? ONLINE : OFFLINE; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + onlineState = OFFLINE; + } catch (IOException | ExecutionException e) { + onlineState = OFFLINE; + } + return onlineState; + } + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException { + List sources = new ArrayList<>(); + try { + String url = getMasterPlaylistUrl(); + StreamSource src = new StreamSource(); + src.mediaPlaylistUrl = url; + src.height = 0; + src.width = 0; + src.bandwidth = 0; + sources.add(src); + } catch (IOException e) { + LOG.error("Can not get stream sources for {}", getName()); + } + return sources; + } + + private String getMasterPlaylistUrl() throws IOException { + JSONObject json = getModelInfo(); + String mpp = json.getString("mpp"); + String lname = getName().toLowerCase(); + return MessageFormat.format("https://stream14.cams.com/h5live/http/playlist.m3u8?url=rtmp%3A%2F%2F{0}%3A1935%2Fcams%2F{1}%3Fcams%2F{1}_720p&stream={2}", mpp, lname, getName()); + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + return new int[]{0, 0}; + } + + private JSONObject getModelInfo() throws IOException { + if (Duration.between(lastInfoRequest, Instant.now()).getSeconds() < 5) { + modelInfo = Optional.ofNullable(modelInfo).orElse(loadModelInfo()); + } else { + modelInfo = loadModelInfo(); + } + return modelInfo; + } + + private JSONObject loadModelInfo() throws IOException { + lastInfoRequest = Instant.now(); + String url = "https://beta-api.cams.com/models/stream/" + getName() + "/"; + Request req = new Request.Builder().url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, "en") + .header(REFERER, getSite().getBaseUrl() + '/' + getName()) + .build(); + try (Response response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + JSONObject jsonResponse = new JSONObject(response.body().string()); + return jsonResponse; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + public String getPreviewURL() { + String lname = getName().toLowerCase(); + String url = MessageFormat.format("https://images4.streamray.com/images/streamray/won/jpg/{0}/{1}/{2}_640.jpg", lname.substring(0, 1), lname.substring(lname.length() - 1), lname); + try { + return MessageFormat.format("https://dynimages.securedataimages.com/unsigned/rs:fill:320::0/g:no/plain/{0}@jpg", URLEncoder.encode(url, "utf-8")); + } catch (Exception ex) { + return url; + } + } + + @Override + public Download createDownload() { + return new FfmpegHlsDownload(getSite().getHttpClient()); + } + + @Override + public boolean follow() throws IOException { + return false; + } + + @Override + public boolean unfollow() throws IOException { + return false; + } + + @Override + public void receiveTip(Double tokens) throws IOException { + // not implemented + } + + @Override + public void invalidateCacheEntries() { + status = null; + lastInfoRequest = Instant.EPOCH; + modelInfo = null; + } + + public String getGender() { + return gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public void setRegDate(LocalDate reg) { + this.regDate = reg; + } + + public boolean isNew() { + return ChronoUnit.DAYS.between(this.regDate, LocalDate.now()) < 30; + } + +} diff --git a/common/src/main/java/ctbrec/sites/winktv/WinkTv.java b/common/src/main/java/ctbrec/sites/winktv/WinkTv.java new file mode 100644 index 00000000..29200bad --- /dev/null +++ b/common/src/main/java/ctbrec/sites/winktv/WinkTv.java @@ -0,0 +1,182 @@ +package ctbrec.sites.winktv; + +import static ctbrec.io.HttpConstants.*; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.Locale; +import org.json.JSONArray; +import org.json.JSONObject; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.FormBody; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.sites.AbstractSite; + +public class WinkTv extends AbstractSite { + + public static String domain = "www.winktv.co.kr"; + public static String baseUri = "https://www.winktv.co.kr"; + private HttpClient httpClient; + + @Override + public void init() throws IOException { + } + + @Override + public String getName() { + return "WinkTv"; + } + + @Override + public String getBaseUrl() { + return baseUri; + } + + @Override + public String getAffiliateLink() { + return baseUri; + } + + @Override + public String getBuyTokensLink() { + return getAffiliateLink(); + } + + @Override + public WinkTvModel createModel(String name) { + WinkTvModel model = new WinkTvModel(); + model.setName(name); + model.setUrl(getBaseUrl() + "/live/play/" + name); + model.setSite(this); + return model; + } + + @Override + public Double getTokenBalance() throws IOException { + return 0d; + } + + @Override + public synchronized boolean login() throws IOException { + return credentialsAvailable() && getHttpClient().login(); + } + + @Override + public HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new WinkTvHttpClient(getConfig()); + } + return httpClient; + } + + @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 { + if (StringUtil.isBlank(q)) { + return Collections.emptyList(); + } + String url = "https://api.winktv.co.kr/v1/live"; + FormBody body = new FormBody.Builder() + .add("offset", "0") + .add("limit", "30") + .add("orderBy", "user") + .add("searchVal", URLEncoder.encode(q, "utf-8")) + .build(); + 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(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(REFERER, getBaseUrl() + "/") + .header(ORIGIN, getBaseUrl()) + .post(body) + .build(); + try (Response response = getHttpClient().execute(req)) { + if (response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if (json.optBoolean("result") && json.has("list")) { + List models = new ArrayList<>(); + JSONArray results = json.getJSONArray("list"); + if (results.length() == 0) { + return Collections.emptyList(); + } + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + WinkTvModel model = createModel(result.optString("userId")); + model.setPreview(result.optString("thumbUrl")); + models.add(model); + } + return models; + } else { + return Collections.emptyList(); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof WinkTvModel; + } + + @Override + public boolean credentialsAvailable() { + return false; + } + + @Override + public Model createModelFromUrl(String url) { + String[] patterns = { + "https://.*?winktv.co.kr/live/play/([_a-zA-Z0-9]+)", + "https://.*?winktv.co.kr/channel/([_a-zA-Z0-9]+)", + "https://.*?pandalive.co.kr/live/play/([_a-zA-Z0-9]+)", + "https://.*?pandalive.co.kr/channel/([_a-zA-Z0-9]+)" + }; + for (String p : patterns) { + Matcher m = Pattern.compile(p).matcher(url); + if (m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } + } + return super.createModelFromUrl(url); + } +} diff --git a/common/src/main/java/ctbrec/sites/winktv/WinkTvHttpClient.java b/common/src/main/java/ctbrec/sites/winktv/WinkTvHttpClient.java new file mode 100644 index 00000000..c0d8c1bf --- /dev/null +++ b/common/src/main/java/ctbrec/sites/winktv/WinkTvHttpClient.java @@ -0,0 +1,17 @@ +package ctbrec.sites.winktv; + +import ctbrec.Config; +import ctbrec.io.HttpClient; +import java.io.IOException; + +public class WinkTvHttpClient extends HttpClient { + + public WinkTvHttpClient(Config config) { + super("winktv", config); + } + + @Override + public boolean login() throws IOException { + return false; + } +} diff --git a/common/src/main/java/ctbrec/sites/winktv/WinkTvModel.java b/common/src/main/java/ctbrec/sites/winktv/WinkTvModel.java new file mode 100644 index 00000000..049abc5c --- /dev/null +++ b/common/src/main/java/ctbrec/sites/winktv/WinkTvModel.java @@ -0,0 +1,270 @@ +package ctbrec.sites.winktv; + +import okhttp3.FormBody; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONObject; +import org.json.JSONArray; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.xml.bind.JAXBException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.time.Duration; +import java.time.Instant; + +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.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.StreamSource; +import ctbrec.recorder.download.hls.HlsdlDownload; +import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; + +import static ctbrec.Model.State.*; +import static ctbrec.io.HttpConstants.*; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class WinkTvModel extends AbstractModel { + + private static final Logger LOG = LoggerFactory.getLogger(WinkTvModel.class); + private int[] resolution = new int[]{0, 0}; + private boolean adult = false; + private JSONObject modelInfo; + + private transient Instant lastInfoRequest = Instant.EPOCH; + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + if (ignoreCache) { + try { + JSONObject json = getModelInfo(); + if (json.has("media")) { + JSONObject media = json.getJSONObject("media"); + boolean isLive = media.optBoolean("isLive"); + String meType = media.optString("type"); + if (isLive && meType.equals("free")) { + setOnlineState(ONLINE); + } else { + setOnlineState(PRIVATE); + } + } else { + setOnlineState(OFFLINE); + } + } catch (Exception e) { + setOnlineState(UNKNOWN); + } + } + return onlineState == ONLINE; + } + + @Override + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if (failFast && onlineState != UNKNOWN) { + return onlineState; + } else { + try { + onlineState = isOnline(true) ? ONLINE : OFFLINE; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + onlineState = OFFLINE; + } catch (IOException | ExecutionException e) { + onlineState = OFFLINE; + } + return onlineState; + } + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { + String url = getMasterPlaylistUrl(); + MasterPlaylist masterPlaylist = getMasterPlaylist(url); + List streamSources = extractStreamSources(masterPlaylist); + return streamSources; + } + + private List extractStreamSources(MasterPlaylist masterPlaylist) { + 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; + src.mediaPlaylistUrl = playlist.getUri(); + LOG.trace("Media playlist {}", src.mediaPlaylistUrl); + sources.add(src); + } + } + return sources; + } + + private MasterPlaylist getMasterPlaylist(String url) throws IOException, ParseException, PlaylistException { + LOG.trace("Loading master playlist {}", url); + Request req = new Request.Builder() + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + try (Response response = getSite().getHttpClient().execute(req)) { + if (response.isSuccessful()) { + String body = response.body().string(); + 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()); + } + } + } + + private String getMasterPlaylistUrl() throws IOException { + JSONObject json = getModelInfo(); + JSONObject info = json.getJSONObject("bjInfo"); + long userIdx = info.optLong("idx"); + String url = "https://api.winktv.co.kr/v1/live/play"; + FormBody body = new FormBody.Builder() + .add("action", "watch") + .add("userIdx", String.valueOf(userIdx)) + .add("password", "") + .build(); + Request req = new Request.Builder() + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(REFERER, getUrl()) + .header(ORIGIN, getSite().getBaseUrl()) + .post(body) + .build(); + try (Response response = getSite().getHttpClient().execute(req)) { + if (response.isSuccessful()) { + JSONObject jsonResponse = new JSONObject(response.body().string()); + JSONObject playlist = jsonResponse.getJSONObject("PlayList"); + JSONObject hls = playlist.getJSONArray("hls").getJSONObject(0); + String hlsUrl = hls.optString("url"); + return hlsUrl; + } else { + LOG.debug("Error while get master playlist url for {}: {}", getName(), response.body().string()); + throw new HttpException(response.code(), response.message()); + } + } + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + if (!failFast) { + try { + List sources = getStreamSources(); + if (!sources.isEmpty()) { + StreamSource best = sources.get(sources.size() - 1); + resolution = new int[]{best.getWidth(), best.getHeight()}; + } + } catch (IOException | ParseException | PlaylistException e) { + throw new ExecutionException(e); + } + } + return resolution; + } + + private JSONObject getModelInfo() throws IOException { + if (Duration.between(lastInfoRequest, Instant.now()).getSeconds() < 5) { + return Optional.ofNullable(modelInfo).orElse(new JSONObject()) ; + } + lastInfoRequest = Instant.now(); + modelInfo = loadModelInfo(); + return modelInfo; + } + + private JSONObject loadModelInfo() throws IOException { + String url = "https://api.winktv.co.kr/v1/member/bj"; + FormBody body = new FormBody.Builder() + .add("userId", getName()) + .add("info", "media") + .build(); + Request req = new Request.Builder() + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(REFERER, getUrl()) + .header(ORIGIN, getSite().getBaseUrl()) + .post(body) + .build(); + try (Response response = getSite().getHttpClient().execute(req)) { + if (response.isSuccessful()) { + JSONObject jsonResponse = new JSONObject(response.body().string()); + return jsonResponse; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + public String getPreviewURL() throws IOException { + JSONObject json = getModelInfo(); + if (json.has("media")) { + JSONObject media = json.getJSONObject("media"); + return media.optString("ivsThumbnail"); + } + if (json.has("bjInfo")) { + JSONObject info = json.getJSONObject("bjInfo"); + return info.optString("thumbUrl"); + } + return ""; + } + + @Override + public boolean follow() throws IOException { + return false; + } + + @Override + public boolean unfollow() throws IOException { + return false; + } + + public boolean isAdult() { + return adult; + } + + public void setAdult(boolean a) { + this.adult = a; + } + + @Override + public void receiveTip(Double tokens) throws IOException { + // not implemented + } + + @Override + public void invalidateCacheEntries() { + resolution = new int[]{0, 0}; + lastInfoRequest = Instant.EPOCH; + modelInfo = null; + } + + @Override + public Download createDownload() { + if (Config.getInstance().getSettings().useHlsdl) { + return new HlsdlDownload(); + } else { + return new MergedFfmpegHlsDownload(getSite().getHttpClient()); + } + } +} diff --git a/server/src/main/java/ctbrec/recorder/server/HttpServer.java b/server/src/main/java/ctbrec/recorder/server/HttpServer.java index df325a4d..df1f1ec3 100644 --- a/server/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/server/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -18,6 +18,7 @@ import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.sites.dreamcam.Dreamcam; import ctbrec.sites.fc2live.Fc2Live; import ctbrec.sites.flirt4free.Flirt4Free; import ctbrec.sites.jasmin.LiveJasmin; @@ -26,7 +27,9 @@ import ctbrec.sites.mfc.MyFreeCams; import ctbrec.sites.secretfriends.SecretFriends; import ctbrec.sites.showup.Showup; import ctbrec.sites.streamate.Streamate; +import ctbrec.sites.streamray.Streamray; import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.winktv.WinkTv; import ctbrec.sites.xlovecam.XloveCam; import org.eclipse.jetty.security.*; import org.eclipse.jetty.security.authentication.BasicAuthenticator; @@ -134,6 +137,7 @@ public class HttpServer { sites.add(new Camsoda()); sites.add(new Chaturbate()); sites.add(new CherryTv()); + sites.add(new Dreamcam()); sites.add(new Fc2Live()); sites.add(new Flirt4Free()); sites.add(new LiveJasmin()); @@ -143,7 +147,9 @@ public class HttpServer { sites.add(new Showup()); sites.add(new Streamate()); sites.add(new Stripchat()); + sites.add(new Streamray()); sites.add(new XloveCam()); + sites.add(new WinkTv()); } private void addShutdownHook() { @@ -188,7 +194,7 @@ public class HttpServer { sslContextFactory.setTrustStorePassword(keyStorePassword); try (ServerConnector http = new ServerConnector(server, httpConnectionFactory); - ServerConnector https = new ServerConnector(server, sslContextFactory, httpConnectionFactory)) { + ServerConnector https = new ServerConnector(server, sslContextFactory, httpConnectionFactory)) { // connector for http http.setPort(this.config.getSettings().httpPort); @@ -227,9 +233,9 @@ public class HttpServer { HandlerList handlers = new HandlerList(); if (this.config.getSettings().transportLayerSecurity) { server.addConnector(https); - handlers.setHandlers(new Handler[] { new SecuredRedirectHandler(), basicAuthContext, defaultContext }); + handlers.setHandlers(new Handler[]{new SecuredRedirectHandler(), basicAuthContext, defaultContext}); } else { - handlers.setHandlers(new Handler[] { basicAuthContext, defaultContext }); + handlers.setHandlers(new Handler[]{basicAuthContext, defaultContext}); } server.setHandler(handlers); @@ -255,7 +261,7 @@ public class HttpServer { ServletHolder holder = new ServletHolder(staticFileServlet); String staticFileContext = "/static/*"; defaultContext.addServlet(holder, staticFileContext); - LOG.info("Register static file servlet under {}", defaultContext.getContextPath()+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; @@ -298,7 +304,7 @@ public class HttpServer { @Override protected void handleErrorPage(HttpServletRequest request, Writer writer, int code, String message) throws IOException { if (code == 404) { - writer.write("404

404

Looking for CTB Recorder?

"); + writer.write("404

404

Looking for CTB Recorder?

"); } else { super.handleErrorPage(request, writer, code, message); } @@ -315,7 +321,7 @@ public class HttpServer { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - ((HttpServletResponse)response).addHeader("Server", "CTB Recorder/" + getVersion()); + ((HttpServletResponse) response).addHeader("Server", "CTB Recorder/" + getVersion()); chain.doFilter(request, response); } @@ -330,14 +336,14 @@ public class HttpServer { 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" }); + userStore.addUser(username, Credential.getCredential(password), new String[]{"user"}); HashLoginService l = new HashLoginService(); l.setUserStore(userStore); l.setName(realm); Constraint constraint = new Constraint(); constraint.setName(Constraint.__BASIC_AUTH); - constraint.setRoles(new String[] { "user" }); + constraint.setRoles(new String[]{"user"}); constraint.setAuthenticate(true); ConstraintMapping cm = new ConstraintMapping();