diff --git a/CHANGELOG.md b/CHANGELOG.md
index 857a6a63..e3b95ef4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+1.6.1
+========================
+* Fixed UI freeze, which occured for a high number of recorded models
+* Added Cam4
+* Updated the embedded JRE for the Windows bundles to 8u192
+
1.6.0
========================
* Added support for multiple cam sites
diff --git a/ctbrec-macos.sh b/ctbrec-macos.sh
new file mode 100755
index 00000000..f28903cc
--- /dev/null
+++ b/ctbrec-macos.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+DIR=$(dirname $0)
+pushd $DIR
+JAVA_HOME="$DIR/jre/Contents/Home"
+JAVA="$JAVA_HOME/bin/java"
+$JAVA -version
+$JAVA -cp ${name.final}.jar ctbrec.ui.Launcher
+popd
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 9a768d26..77b1df4a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
4.0.0
ctbrec
ctbrec
- 1.6.0
+ 1.6.1
UTF-8
@@ -66,6 +66,7 @@
src/assembly/win64-jre.xml
src/assembly/win32-jre.xml
src/assembly/linux.xml
+ src/assembly/macos-jre.xml
@@ -77,7 +78,7 @@
1.7.22
- l4j-clui
+ l4j-win
package
launch4j
diff --git a/server-macos.sh b/server-macos.sh
new file mode 100755
index 00000000..7ab059e4
--- /dev/null
+++ b/server-macos.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+DIR=$(dirname $0)
+pushd $DIR
+JAVA_HOME="$DIR/jre/Contents/Home"
+JAVA="$JAVA_HOME/bin/java"
+$JAVA -version
+$JAVA -cp ${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer
+popd
diff --git a/src/assembly/macos-jre.xml b/src/assembly/macos-jre.xml
new file mode 100644
index 00000000..c8a1c261
--- /dev/null
+++ b/src/assembly/macos-jre.xml
@@ -0,0 +1,34 @@
+
+
+ macos-jre
+
+ zip
+
+ false
+
+
+ ${project.basedir}/ctbrec-macos.sh
+ ctbrec
+ true
+
+
+ ${project.basedir}/server-macos.sh
+ ctbrec
+ true
+
+
+ ${project.build.directory}/${name.final}.jar
+ ctbrec
+
+
+
+
+ jre/jre1.8.0_192_macos
+
+ **/*
+
+ ctbrec/jre
+ false
+
+
+
diff --git a/src/assembly/win32-jre.xml b/src/assembly/win32-jre.xml
index b9fba758..7efe1c9d 100644
--- a/src/assembly/win32-jre.xml
+++ b/src/assembly/win32-jre.xml
@@ -22,7 +22,7 @@
- jre/jre1.8.0_181_win32
+ jre/jre1.8.0_192_win32
**/*
diff --git a/src/assembly/win64-jre.xml b/src/assembly/win64-jre.xml
index d81bd0ce..65b22409 100644
--- a/src/assembly/win64-jre.xml
+++ b/src/assembly/win64-jre.xml
@@ -22,7 +22,7 @@
- jre/jre1.8.0_181_win64
+ jre/jre1.8.0_192_win64
**/*
diff --git a/src/main/java/ctbrec/Settings.java b/src/main/java/ctbrec/Settings.java
index d1f41691..db2cbf0c 100644
--- a/src/main/java/ctbrec/Settings.java
+++ b/src/main/java/ctbrec/Settings.java
@@ -26,6 +26,8 @@ public class Settings {
public String mfcPassword = "";
public String camsodaUsername = "";
public String camsodaPassword = "";
+ public String cam4Username;
+ public String cam4Password;
public String lastDownloadDir = "";
public List models = new ArrayList();
diff --git a/src/main/java/ctbrec/io/ModelJsonAdapter.java b/src/main/java/ctbrec/io/ModelJsonAdapter.java
index 0c6d8129..fdaefc39 100644
--- a/src/main/java/ctbrec/io/ModelJsonAdapter.java
+++ b/src/main/java/ctbrec/io/ModelJsonAdapter.java
@@ -69,7 +69,7 @@ public class ModelJsonAdapter extends JsonAdapter {
}
return model;
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) {
- throw new IOException("Couldn't instantiate mode class [" + type + "]", e);
+ throw new IOException("Couldn't instantiate model class [" + type + "]", e);
}
}
diff --git a/src/main/java/ctbrec/recorder/LocalRecorder.java b/src/main/java/ctbrec/recorder/LocalRecorder.java
index 4f3f983b..e1916d93 100644
--- a/src/main/java/ctbrec/recorder/LocalRecorder.java
+++ b/src/main/java/ctbrec/recorder/LocalRecorder.java
@@ -166,7 +166,12 @@ public class LocalRecorder implements Recorder {
@Override
public List getModelsRecording() {
- return Collections.unmodifiableList(new ArrayList<>(models));
+ lock.lock();
+ try {
+ return Collections.unmodifiableList(new ArrayList<>(models));
+ } finally {
+ lock.unlock();
+ }
}
@Override
@@ -308,24 +313,19 @@ public class LocalRecorder implements Recorder {
public void run() {
running = true;
while (running) {
- lock.lock();
- try {
- for (Model model : getModelsRecording()) {
- try {
- if (!recordingProcesses.containsKey(model)) {
- boolean isOnline = model.isOnline(IGNORE_CACHE);
- LOG.trace("Checking online state for {}: {}", model, (isOnline ? "online" : "offline"));
- if (isOnline) {
- LOG.info("Model {}'s room back to public. Starting recording", model);
- startRecordingProcess(model);
- }
+ for (Model model : getModelsRecording()) {
+ try {
+ if (!recordingProcesses.containsKey(model)) {
+ boolean isOnline = model.isOnline(IGNORE_CACHE);
+ LOG.trace("Checking online state for {}: {}", model, (isOnline ? "online" : "offline"));
+ if (isOnline) {
+ LOG.info("Model {}'s room back to public. Starting recording", model);
+ startRecordingProcess(model);
}
- } catch (Exception e) {
- LOG.error("Couldn't check if model {} is online", model.getName(), e);
}
+ } catch (Exception e) {
+ LOG.error("Couldn't check if model {} is online", model.getName(), e);
}
- } finally {
- lock.unlock();
}
try {
diff --git a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java
index 89a1600b..787ca938 100644
--- a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java
+++ b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java
@@ -13,6 +13,7 @@ import java.util.concurrent.Executors;
import com.iheartradio.m3u8.Encoding;
import com.iheartradio.m3u8.Format;
import com.iheartradio.m3u8.ParseException;
+import com.iheartradio.m3u8.ParsingMode;
import com.iheartradio.m3u8.PlaylistException;
import com.iheartradio.m3u8.PlaylistParser;
import com.iheartradio.m3u8.data.MediaPlaylist;
@@ -41,7 +42,7 @@ public abstract class AbstractHlsDownload implements Download {
Response response = client.execute(request);
try {
InputStream inputStream = response.body().byteStream();
- PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8);
+ PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT);
Playlist playlist = parser.parse();
if(playlist.hasMediaPlaylist()) {
MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist();
diff --git a/src/main/java/ctbrec/recorder/server/HttpServer.java b/src/main/java/ctbrec/recorder/server/HttpServer.java
index a83c0025..00537280 100644
--- a/src/main/java/ctbrec/recorder/server/HttpServer.java
+++ b/src/main/java/ctbrec/recorder/server/HttpServer.java
@@ -20,6 +20,7 @@ import ctbrec.Config;
import ctbrec.recorder.LocalRecorder;
import ctbrec.recorder.Recorder;
import ctbrec.sites.Site;
+import ctbrec.sites.cam4.Cam4;
import ctbrec.sites.camsoda.Camsoda;
import ctbrec.sites.chaturbate.Chaturbate;
import ctbrec.sites.mfc.MyFreeCams;
@@ -64,6 +65,7 @@ public class HttpServer {
sites.add(new Chaturbate());
sites.add(new MyFreeCams());
sites.add(new Camsoda());
+ sites.add(new Cam4());
}
private void addShutdownHook() {
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4.java b/src/main/java/ctbrec/sites/cam4/Cam4.java
new file mode 100644
index 00000000..5d791883
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4.java
@@ -0,0 +1,155 @@
+package ctbrec.sites.cam4;
+
+import java.io.IOException;
+
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.io.HttpClient;
+import ctbrec.recorder.Recorder;
+import ctbrec.sites.AbstractSite;
+import ctbrec.ui.DesktopIntergation;
+import ctbrec.ui.SettingsTab;
+import ctbrec.ui.TabProvider;
+import javafx.geometry.Insets;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.PasswordField;
+import javafx.scene.control.TextField;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.Priority;
+
+public class Cam4 extends AbstractSite {
+
+ public static final String BASE_URI = "https://www.cam4.com";
+
+ public static final String AFFILIATE_LINK = BASE_URI + "/?referrerId=1514a80d87b5effb456cca02f6743aa1";
+
+ private HttpClient httpClient;
+ private Recorder recorder;
+
+ @Override
+ public String getName() {
+ return "Cam4";
+ }
+
+ @Override
+ public String getBaseUrl() {
+ return BASE_URI;
+ }
+
+ @Override
+ public String getAffiliateLink() {
+ return AFFILIATE_LINK;
+ }
+
+ @Override
+ public void setRecorder(Recorder recorder) {
+ this.recorder = recorder;
+ }
+
+ @Override
+ public TabProvider getTabProvider() {
+ return new Cam4TabProvider(this, recorder);
+ }
+
+ @Override
+ public Model createModel(String name) {
+ Cam4Model m = new Cam4Model();
+ m.setSite(this);
+ m.setName(name);
+ m.setUrl(getBaseUrl() + '/' + name + '/');
+ return m;
+ }
+
+ @Override
+ public Integer getTokenBalance() throws IOException {
+ if (!credentialsAvailable()) {
+ throw new IOException("Not logged in");
+ }
+ return ((Cam4HttpClient)getHttpClient()).getTokenBalance();
+ }
+
+ @Override
+ public String getBuyTokensLink() {
+ return getAffiliateLink();
+ }
+
+ @Override
+ public void login() throws IOException {
+ if (credentialsAvailable()) {
+ boolean success = getHttpClient().login();
+ LoggerFactory.getLogger(getClass()).debug("Login success: {}", success);
+ }
+ }
+
+ @Override
+ public HttpClient getHttpClient() {
+ if(httpClient == null) {
+ httpClient = new Cam4HttpClient();
+ }
+ return httpClient;
+ }
+
+ @Override
+ public void shutdown() {
+ getHttpClient().shutdown();
+ }
+
+ @Override
+ public void init() throws IOException {
+ }
+
+ @Override
+ public boolean supportsTips() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFollow() {
+ return true;
+ }
+
+ @Override
+ public boolean isSiteForModel(Model m) {
+ return m instanceof Cam4Model;
+ }
+
+ @Override
+ public boolean credentialsAvailable() {
+ String username = Config.getInstance().getSettings().cam4Username;
+ return username != null && !username.trim().isEmpty();
+ }
+
+ @Override
+ public Node getConfigurationGui() {
+ GridPane layout = SettingsTab.createGridLayout();
+ layout.add(new Label("Cam4 User"), 0, 0);
+ TextField username = new TextField(Config.getInstance().getSettings().cam4Username);
+ username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Username = username.getText());
+ GridPane.setFillWidth(username, true);
+ GridPane.setHgrow(username, Priority.ALWAYS);
+ GridPane.setColumnSpan(username, 2);
+ layout.add(username, 1, 0);
+
+ layout.add(new Label("Cam4 Password"), 0, 1);
+ PasswordField password = new PasswordField();
+ password.setText(Config.getInstance().getSettings().cam4Password);
+ password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Password = password.getText());
+ GridPane.setFillWidth(password, true);
+ GridPane.setHgrow(password, Priority.ALWAYS);
+ GridPane.setColumnSpan(password, 2);
+ layout.add(password, 1, 1);
+
+ Button createAccount = new Button("Create new Account");
+ createAccount.setOnAction((e) -> DesktopIntergation.open(Cam4.AFFILIATE_LINK));
+ layout.add(createAccount, 1, 2);
+ GridPane.setColumnSpan(createAccount, 2);
+ GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ return layout;
+ }
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4FollowedTab.java b/src/main/java/ctbrec/sites/cam4/Cam4FollowedTab.java
new file mode 100644
index 00000000..bd25fa90
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4FollowedTab.java
@@ -0,0 +1,75 @@
+package ctbrec.sites.cam4;
+
+import ctbrec.ui.FollowedTab;
+import ctbrec.ui.ThumbOverviewTab;
+import javafx.concurrent.WorkerStateEvent;
+import javafx.geometry.Insets;
+import javafx.scene.Scene;
+import javafx.scene.control.Label;
+import javafx.scene.control.RadioButton;
+import javafx.scene.control.ToggleGroup;
+import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.HBox;
+
+public class Cam4FollowedTab extends ThumbOverviewTab implements FollowedTab {
+ private Label status;
+
+ public Cam4FollowedTab(Cam4 cam4) {
+ super("Followed", new Cam4FollowedUpdateService(cam4), cam4);
+ status = new Label("Logging in...");
+ grid.getChildren().add(status);
+ }
+
+ @Override
+ protected void createGui() {
+ super.createGui();
+ addOnlineOfflineSelector();
+ }
+
+ private void addOnlineOfflineSelector() {
+ ToggleGroup group = new ToggleGroup();
+ RadioButton online = new RadioButton("online");
+ online.setToggleGroup(group);
+ RadioButton offline = new RadioButton("offline");
+ offline.setToggleGroup(group);
+ pagination.getChildren().add(online);
+ pagination.getChildren().add(offline);
+ HBox.setMargin(online, new Insets(5,5,5,40));
+ HBox.setMargin(offline, new Insets(5,5,5,5));
+ online.setSelected(true);
+ group.selectedToggleProperty().addListener((e) -> {
+ ((Cam4FollowedUpdateService)updateService).setShowOnline(online.isSelected());
+ queue.clear();
+ updateService.restart();
+ });
+ }
+
+ @Override
+ protected void onSuccess() {
+ grid.getChildren().remove(status);
+ super.onSuccess();
+ }
+
+ @Override
+ protected void onFail(WorkerStateEvent event) {
+ status.setText("Login failed");
+ super.onFail(event);
+ }
+
+ @Override
+ public void selected() {
+ status.setText("Logging in...");
+ super.selected();
+ }
+
+ public void setScene(Scene scene) {
+ scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
+ if(this.isSelected()) {
+ if(event.getCode() == KeyCode.DELETE) {
+ follow(selectedThumbCells, false);
+ }
+ }
+ });
+ }
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4FollowedUpdateService.java b/src/main/java/ctbrec/sites/cam4/Cam4FollowedUpdateService.java
new file mode 100644
index 00000000..c752fb4a
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4FollowedUpdateService.java
@@ -0,0 +1,94 @@
+package ctbrec.sites.cam4;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.stream.Collectors;
+
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.ui.HtmlParser;
+import ctbrec.ui.PaginatedScheduledService;
+import javafx.concurrent.Task;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class Cam4FollowedUpdateService extends PaginatedScheduledService {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(Cam4FollowedUpdateService.class);
+ private Cam4 site;
+ private boolean showOnline = true;
+
+ public Cam4FollowedUpdateService(Cam4 site) {
+ this.site = site;
+ ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() {
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setDaemon(true);
+ t.setName("ThumbOverviewTab UpdateService");
+ return t;
+ }
+ });
+ setExecutor(executor);
+ }
+
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public List call() throws IOException {
+ List models = new ArrayList<>();
+ String username = Config.getInstance().getSettings().cam4Username;
+ String url = site.getBaseUrl() + '/' + username + "/edit/friends_favorites";
+ Request req = new Request.Builder().url(url).build();
+ Response response = site.getHttpClient().execute(req, true);
+ if(response.isSuccessful()) {
+ String content = response.body().string();
+ Elements cells = HtmlParser.getTags(content, "div#favorites div.ff_thumb");
+ for (Element cell : cells) {
+ String cellHtml = cell.html();
+ Element link = HtmlParser.getTag(cellHtml, "div.ff_img a");
+ String path = link.attr("href");
+ String modelName = path.substring(1);
+ Cam4Model model = (Cam4Model) site.createModel(modelName);
+ model.setPreview("https://snapshots.xcdnpro.com/thumbnails/"+model.getName()+"?s=" + System.currentTimeMillis());
+ model.setOnlineState(parseOnlineState(cellHtml));
+ models.add(model);
+ }
+ return models.stream()
+ .filter(m -> {
+ try {
+ return m.isOnline() == showOnline;
+ } catch (IOException | ExecutionException | InterruptedException e) {
+ LOG.error("Couldn't determine online state", e);
+ return false;
+ }
+ }).collect(Collectors.toList());
+ } else {
+ IOException e = new IOException(response.code() + " " + response.message());
+ response.close();
+ throw e;
+ }
+ }
+
+ private String parseOnlineState(String cellHtml) {
+ Element state = HtmlParser.getTag(cellHtml, "div.ff_name div");
+ return state.attr("class").equals("online") ? "NORMAL" : "OFFLINE";
+ }
+ };
+ }
+
+ public void setShowOnline(boolean online) {
+ this.showOnline = online;
+ }
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java b/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java
new file mode 100644
index 00000000..3a40c555
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java
@@ -0,0 +1,109 @@
+package ctbrec.sites.cam4;
+
+import java.io.IOException;
+import java.net.HttpCookie;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.io.HttpClient;
+import javafx.application.Platform;
+import okhttp3.Cookie;
+import okhttp3.HttpUrl;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class Cam4HttpClient extends HttpClient {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(Cam4HttpClient.class);
+
+ @Override
+ public synchronized boolean login() throws IOException {
+ if(loggedIn) {
+ return true;
+ }
+
+ BlockingQueue queue = new LinkedBlockingQueue<>();
+
+ Runnable showDialog = () -> {
+ // login with javafx WebView
+ Cam4LoginDialog loginDialog = new Cam4LoginDialog();
+
+ // transfer cookies from WebView to OkHttp cookie jar
+ transferCookies(loginDialog);
+
+ try {
+ queue.put(true);
+ } catch (InterruptedException e) {
+ LOG.error("Error while signaling termination", e);
+ }
+ };
+
+ if(Platform.isFxApplicationThread()) {
+ showDialog.run();
+ } else {
+ Platform.runLater(showDialog);
+ try {
+ queue.take();
+ } catch (InterruptedException e) {
+ LOG.error("Error while waiting for login dialog to close", e);
+ throw new IOException(e);
+ }
+ }
+
+ loggedIn = checkLoginSuccess();
+ return loggedIn;
+ }
+
+ /**
+ * check, if the login worked by requesting unchecked mail
+ * @throws IOException
+ */
+ private boolean checkLoginSuccess() throws IOException {
+ String mailUrl = Cam4.BASE_URI + "/mail/unreadThreads";
+ Request req = new Request.Builder()
+ .url(mailUrl)
+ .addHeader("X-Requested-With", "XMLHttpRequest")
+ .build();
+ Response response = execute(req);
+ if(response.isSuccessful() && response.body().contentLength() > 0) {
+ JSONObject json = new JSONObject(response.body().string());
+ return json.has("status") && Objects.equals("success", json.getString("status"));
+ } else {
+ response.close();
+ return false;
+ }
+ }
+
+ private void transferCookies(Cam4LoginDialog loginDialog) {
+ HttpUrl redirectedUrl = HttpUrl.parse(loginDialog.getUrl());
+ List cookies = new ArrayList<>();
+ for (HttpCookie webViewCookie : loginDialog.getCookies()) {
+ Cookie cookie = Cookie.parse(redirectedUrl, webViewCookie.toString());
+ cookies.add(cookie);
+ }
+ cookieJar.saveFromResponse(redirectedUrl, cookies);
+
+ HttpUrl origUrl = HttpUrl.parse(Cam4LoginDialog.URL);
+ cookies = new ArrayList<>();
+ for (HttpCookie webViewCookie : loginDialog.getCookies()) {
+ Cookie cookie = Cookie.parse(origUrl, webViewCookie.toString());
+ cookies.add(cookie);
+ }
+ cookieJar.saveFromResponse(origUrl, cookies);
+ }
+
+ protected int getTokenBalance() throws IOException {
+ if(!loggedIn) {
+ login();
+ }
+
+ throw new RuntimeException("Not implemented, yet");
+ }
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java
new file mode 100644
index 00000000..9aae0544
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java
@@ -0,0 +1,103 @@
+package ctbrec.sites.cam4;
+
+import java.io.InputStream;
+import java.net.CookieHandler;
+import java.net.CookieManager;
+import java.net.HttpCookie;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.List;
+import java.util.Objects;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import javafx.concurrent.Worker.State;
+import javafx.scene.Scene;
+import javafx.scene.control.ProgressIndicator;
+import javafx.scene.image.Image;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.StackPane;
+import javafx.scene.web.WebEngine;
+import javafx.scene.web.WebView;
+import javafx.stage.Stage;
+
+public class Cam4LoginDialog {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(Cam4LoginDialog.class);
+ public static final String URL = Cam4.BASE_URI + "/login";
+ private List cookies = null;
+ private String url;
+ private Region veil;
+ private ProgressIndicator p;
+
+ public Cam4LoginDialog() {
+ Stage stage = new Stage();
+ stage.setTitle("Cam4 Login");
+ InputStream icon = getClass().getResourceAsStream("/icon.png");
+ stage.getIcons().add(new Image(icon));
+ CookieManager cookieManager = new CookieManager();
+ CookieHandler.setDefault(cookieManager);
+ WebView webView = createWebView(stage);
+
+ veil = new Region();
+ veil.setStyle("-fx-background-color: rgba(0, 0, 0, 0.4)");
+ p = new ProgressIndicator();
+ p.setMaxSize(140, 140);
+
+ StackPane stackPane = new StackPane();
+ stackPane.getChildren().addAll(webView, veil, p);
+
+ stage.setScene(new Scene(stackPane, 480, 854));
+ stage.showAndWait();
+ cookies = cookieManager.getCookieStore().getCookies();
+ }
+
+ private WebView createWebView(Stage stage) {
+ WebView browser = new WebView();
+ WebEngine webEngine = browser.getEngine();
+ webEngine.setJavaScriptEnabled(true);
+ webEngine.locationProperty().addListener((obs, oldV, newV) -> {
+ try {
+ URL _url = new URL(newV);
+ if (Objects.equals(_url.getPath(), "/")) {
+ stage.close();
+ }
+ } catch (MalformedURLException e) {
+ LOG.error("Couldn't parse new url {}", newV, e);
+ }
+ url = newV.toString();
+ });
+ webEngine.getLoadWorker().stateProperty().addListener((observable, oldState, newState) -> {
+ if (newState == State.SUCCEEDED) {
+ String username = Config.getInstance().getSettings().cam4Username;
+ if (username != null && !username.trim().isEmpty()) {
+ webEngine.executeScript("$('input[name=username]').attr('value','" + username + "')");
+ }
+ String password = Config.getInstance().getSettings().cam4Password;
+ if (password != null && !password.trim().isEmpty()) {
+ webEngine.executeScript("$('input[name=password]').attr('value','" + password + "')");
+ }
+ webEngine.executeScript("$('div[class~=navbar]').css('display','none')");
+ webEngine.executeScript("$('div#footer').css('display','none')");
+ webEngine.executeScript("$('div#content').css('padding','0')");
+ veil.setVisible(false);
+ p.setVisible(false);
+ } else if (newState == State.CANCELLED || newState == State.FAILED) {
+ veil.setVisible(false);
+ p.setVisible(false);
+ }
+ });
+ webEngine.load(URL);
+ return browser;
+ }
+
+ public List getCookies() {
+ return cookies;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4Model.java b/src/main/java/ctbrec/sites/cam4/Cam4Model.java
new file mode 100644
index 00000000..c8972af8
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4Model.java
@@ -0,0 +1,231 @@
+package ctbrec.sites.cam4;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.jsoup.nodes.Element;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.iheartradio.m3u8.Encoding;
+import com.iheartradio.m3u8.Format;
+import com.iheartradio.m3u8.ParseException;
+import com.iheartradio.m3u8.PlaylistException;
+import com.iheartradio.m3u8.PlaylistParser;
+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.recorder.download.StreamSource;
+import ctbrec.sites.Site;
+import ctbrec.ui.HtmlParser;
+import okhttp3.FormBody;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+public class Cam4Model extends AbstractModel {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(Cam4Model.class);
+ private Cam4 site;
+ private String playlistUrl;
+ private String onlineState = "offline";
+ private int[] resolution = null;
+
+ @Override
+ public boolean isOnline() throws IOException, ExecutionException, InterruptedException {
+ return isOnline(false);
+ }
+
+ @Override
+ public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
+ if(ignoreCache || onlineState == null) {
+ loadModelDetails();
+ }
+ return Objects.equals("NORMAL", onlineState);
+ }
+
+ private void loadModelDetails() throws IOException {
+ String url = "https://www.cam4.de.com/getBroadcasting?usernames=" + getName();
+ LOG.debug("Loading model details {}", url);
+ Request req = new Request.Builder().url(url).build();
+ Response response = site.getHttpClient().execute(req);
+ if(response.isSuccessful()) {
+ JSONArray json = new JSONArray(response.body().string());
+ if(json.length() == 0) {
+ throw new IOException("Couldn't fetch model details");
+ }
+ JSONObject details = json.getJSONObject(0);
+ onlineState = details.getString("showType");
+ playlistUrl = details.getString("hlsPreviewUrl");
+ if(details.has("resolution")) {
+ String res = details.getString("resolution");
+ String[] tokens = res.split(":");
+ resolution = new int[] {Integer.parseInt(tokens[0]), Integer.parseInt(tokens[1])};
+ }
+ } else {
+ IOException io = new IOException(response.code() + " " + response.message());
+ response.close();
+ throw io;
+ }
+ }
+
+ @Override
+ public String getOnlineState(boolean failFast) throws IOException, ExecutionException {
+ return onlineState;
+ }
+
+ private String getPlaylistUrl() throws IOException {
+ if(playlistUrl == null) {
+ loadModelDetails();
+ }
+ return playlistUrl;
+ }
+
+ @Override
+ public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException {
+ MasterPlaylist masterPlaylist = getMasterPlaylist();
+ List sources = new ArrayList<>();
+ for (PlaylistData playlist : masterPlaylist.getPlaylists()) {
+ if (playlist.hasStreamInfo()) {
+ StreamSource src = new StreamSource();
+ src.bandwidth = playlist.getStreamInfo().getBandwidth();
+ src.height = playlist.getStreamInfo().getResolution().height;
+ String masterUrl = getPlaylistUrl();
+ String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1);
+ String segmentUri = baseUrl + playlist.getUri();
+ src.mediaPlaylistUrl = segmentUri;
+ LOG.trace("Media playlist {}", src.mediaPlaylistUrl);
+ sources.add(src);
+ }
+ }
+ return sources;
+ }
+
+ private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException {
+ LOG.trace("Loading master playlist {}", getPlaylistUrl());
+ Request req = new Request.Builder().url(getPlaylistUrl()).build();
+ Response response = site.getHttpClient().execute(req);
+ try {
+ InputStream inputStream = response.body().byteStream();
+ PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8);
+ Playlist playlist = parser.parse();
+ MasterPlaylist master = playlist.getMasterPlaylist();
+ return master;
+ } finally {
+ response.close();
+ }
+ }
+
+ @Override
+ public void invalidateCacheEntries() {
+ resolution = null;
+ playlistUrl = null;
+ }
+
+ @Override
+ public void receiveTip(int tokens) throws IOException {
+ throw new RuntimeException("Not implemented for Cam4, yet");
+ }
+
+ @Override
+ public int[] getStreamResolution(boolean failFast) throws ExecutionException {
+ if(resolution == null) {
+ if(failFast) {
+ return new int[2];
+ } else {
+ try {
+ loadModelDetails();
+ } catch (IOException e) {
+ throw new ExecutionException(e);
+ }
+ }
+ }
+ return resolution;
+ }
+
+ @Override
+ public boolean follow() throws IOException {
+ String url = site.getBaseUrl() + "/profiles/addFriendFavorite?action=addFavorite&object=" + getName() + "&_=" + System.currentTimeMillis();
+ Request req = new Request.Builder()
+ .url(url)
+ .addHeader("X-Requested-With", "XMLHttpRequest")
+ .build();
+ Response response = site.getHttpClient().execute(req, true);
+ boolean success = response.isSuccessful();
+ response.close();
+ return success;
+ }
+
+ @Override
+ public boolean unfollow() throws IOException {
+ // get model user id
+ String url = site.getBaseUrl() + '/' + getName();
+ Request req = new Request.Builder().url(url).build();
+ Response response = site.getHttpClient().execute(req, true);
+ String broadCasterId = null;
+ if(response.isSuccessful()) {
+ String content = response.body().string();
+ try {
+ Element tag = HtmlParser.getTag(content, "input[name=\"broadcasterId\"]");
+ broadCasterId = tag.attr("value");
+ } catch(Exception e) {
+ LOG.debug(content);
+ throw new IOException(e);
+ }
+
+ // send unfollow request
+ String username = Config.getInstance().getSettings().cam4Username;
+ url = site.getBaseUrl() + '/' + username + "/edit/friends_favorites";
+ RequestBody body = new FormBody.Builder()
+ .add("deleteFavorites", broadCasterId)
+ .add("simpleresult", "true")
+ .build();
+ req = new Request.Builder()
+ .url(url)
+ .post(body)
+ .addHeader("X-Requested-With", "XMLHttpRequest")
+ .build();
+ response = site.getHttpClient().execute(req, true);
+ if(response.isSuccessful()) {
+ return Objects.equals(response.body().string(), "Ok");
+ } else {
+ response.close();
+ return false;
+ }
+ } else {
+ response.close();
+ return false;
+ }
+ }
+
+ @Override
+ public void setSite(Site site) {
+ if(site instanceof Cam4) {
+ this.site = (Cam4) site;
+ } else {
+ throw new IllegalArgumentException("Site has to be an instance of Cam4");
+ }
+ }
+
+ @Override
+ public Site getSite() {
+ return site;
+ }
+
+ public void setPlaylistUrl(String playlistUrl) {
+ this.playlistUrl = playlistUrl;
+ }
+
+ public void setOnlineState(String onlineState) {
+ this.onlineState = onlineState;
+ }
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4TabProvider.java b/src/main/java/ctbrec/sites/cam4/Cam4TabProvider.java
new file mode 100644
index 00000000..62136129
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4TabProvider.java
@@ -0,0 +1,45 @@
+package ctbrec.sites.cam4;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import ctbrec.recorder.Recorder;
+import ctbrec.ui.TabProvider;
+import ctbrec.ui.ThumbOverviewTab;
+import javafx.scene.Scene;
+import javafx.scene.control.Tab;
+
+public class Cam4TabProvider extends TabProvider {
+
+ private Cam4 cam4;
+ private Recorder recorder;
+
+ public Cam4TabProvider(Cam4 cam4, Recorder recorder) {
+ this.cam4 = cam4;
+ this.recorder = recorder;
+ }
+
+ @Override
+ public List getTabs(Scene scene) {
+ List tabs = new ArrayList<>();
+
+ tabs.add(createTab("Female", cam4.getBaseUrl() + "/directoryResults?online=true&gender=female&orderBy=MOST_VIEWERS"));
+ tabs.add(createTab("Male", cam4.getBaseUrl() + "/directoryResults?online=true&gender=male&orderBy=MOST_VIEWERS"));
+ tabs.add(createTab("Couples", cam4.getBaseUrl() + "/directoryResults?online=true&broadcastType=male_group&broadcastType=female_group&broadcastType=male_female_group&orderBy=MOST_VIEWERS"));
+ tabs.add(createTab("HD", cam4.getBaseUrl() + "/directoryResults?online=true&hd=true&orderBy=MOST_VIEWERS"));
+
+ Cam4FollowedTab followed = new Cam4FollowedTab(cam4);
+ followed.setRecorder(recorder);
+ tabs.add(followed);
+
+ return tabs;
+ }
+
+ private Tab createTab(String name, String url) {
+ Cam4UpdateService updateService = new Cam4UpdateService(url, false, cam4);
+ ThumbOverviewTab tab = new ThumbOverviewTab(name, updateService, cam4);
+ tab.setRecorder(recorder);
+ return tab;
+ }
+
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4UpdateService.java b/src/main/java/ctbrec/sites/cam4/Cam4UpdateService.java
new file mode 100644
index 00000000..bcd89205
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4UpdateService.java
@@ -0,0 +1,104 @@
+package ctbrec.sites.cam4;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+
+import org.eclipse.jetty.util.StringUtil;
+import org.json.JSONObject;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.ui.HtmlParser;
+import ctbrec.ui.PaginatedScheduledService;
+import javafx.concurrent.Task;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class Cam4UpdateService extends PaginatedScheduledService {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(Cam4UpdateService.class);
+ private String url;
+ private Cam4 site;
+ private boolean loginRequired;
+
+ public Cam4UpdateService(String url, boolean loginRequired, Cam4 site) {
+ this.site = site;
+ this.url = url;
+ this.loginRequired = loginRequired;
+
+ ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() {
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setDaemon(true);
+ t.setName("ThumbOverviewTab UpdateService");
+ return t;
+ }
+ });
+ setExecutor(executor);
+ }
+
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public List call() throws IOException {
+ if(loginRequired && StringUtil.isBlank(Config.getInstance().getSettings().cam4Username)) {
+ return Collections.emptyList();
+ } else {
+ String url = Cam4UpdateService.this.url + "&page=" + page;
+ LOG.debug("Fetching page {}", url);
+ Request request = new Request.Builder().url(url).build();
+ Response response = site.getHttpClient().execute(request, loginRequired);
+ if (response.isSuccessful()) {
+ JSONObject json = new JSONObject(response.body().string());
+ String html = json.getString("html");
+ Elements profilesBoxes = HtmlParser.getTags(html, "div[class~=profileDataBox]");
+ List models = new ArrayList<>(profilesBoxes.size());
+ for (Element profileBox : profilesBoxes) {
+ String boxHtml = profileBox.html();
+ Element profileLink = HtmlParser.getTag(boxHtml, "a.profile-preview");
+ String path = profileLink.attr("href");
+ String slug = path.substring(1);
+ Cam4Model model = (Cam4Model) site.createModel(slug);
+ String playlistUrl = profileLink.attr("data-hls-preview-url");
+ model.setPlaylistUrl(playlistUrl);
+ model.setPreview("https://snapshots.xcdnpro.com/thumbnails/"+model.getName()+"?s=" + System.currentTimeMillis());
+ model.setDescription(parseDesription(boxHtml));
+ models.add(model);
+ }
+ response.close();
+ return models;
+ } else {
+ int code = response.code();
+ response.close();
+ throw new IOException("HTTP status " + code);
+ }
+ }
+ }
+
+ private String parseDesription(String boxHtml) {
+ try {
+ return HtmlParser.getText(boxHtml, "div[class~=statusMsg2]");
+ } catch(Exception e) {
+ LOG.trace("Couldn't parse description for room");
+ }
+ return "";
+ }
+ };
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+}
diff --git a/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java b/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java
index 2aa1fb7c..984e8c69 100644
--- a/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java
+++ b/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java
@@ -26,7 +26,6 @@ import com.squareup.moshi.Moshi;
import ctbrec.Config;
import ctbrec.Model;
-import ctbrec.Settings;
import ctbrec.io.HttpClient;
import ctbrec.recorder.Recorder;
import ctbrec.sites.AbstractSite;
@@ -121,8 +120,7 @@ public class Chaturbate extends AbstractSite {
@Override
public void login() {
- Settings settings = Config.getInstance().getSettings();
- if (settings.username != null && !settings.username.isEmpty()) {
+ if (credentialsAvailable()) {
new Thread() {
@Override
public void run() {
diff --git a/src/main/java/ctbrec/ui/CamrecApplication.java b/src/main/java/ctbrec/ui/CamrecApplication.java
index eda28a84..352dadb9 100644
--- a/src/main/java/ctbrec/ui/CamrecApplication.java
+++ b/src/main/java/ctbrec/ui/CamrecApplication.java
@@ -28,6 +28,7 @@ import ctbrec.recorder.Recorder;
import ctbrec.recorder.RemoteRecorder;
import ctbrec.sites.Site;
import ctbrec.sites.camsoda.Camsoda;
+import ctbrec.sites.cam4.Cam4;
import ctbrec.sites.chaturbate.Chaturbate;
import ctbrec.sites.mfc.MyFreeCams;
import javafx.application.Application;
@@ -62,6 +63,7 @@ public class CamrecApplication extends Application {
sites.add(new Chaturbate());
sites.add(new MyFreeCams());
sites.add(new Camsoda());
+ sites.add(new Cam4());
loadConfig();
createHttpClient();
bus = new AsyncEventBus(Executors.newSingleThreadExecutor());
diff --git a/src/main/java/ctbrec/ui/CookieJarImpl.java b/src/main/java/ctbrec/ui/CookieJarImpl.java
index e0bee9ee..ce18ea4d 100644
--- a/src/main/java/ctbrec/ui/CookieJarImpl.java
+++ b/src/main/java/ctbrec/ui/CookieJarImpl.java
@@ -6,6 +6,7 @@ import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
+import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -50,7 +51,11 @@ public class CookieJarImpl implements CookieJar {
public List loadForRequest(HttpUrl url) {
String host = getHost(url);
List cookies = cookieStore.get(host);
- LOG.debug("Cookies for {}: {}", url.host(), cookies);
+ LOG.debug("Cookies for {}", url);
+ Optional.ofNullable(cookies).ifPresent(cookiez -> cookiez.forEach(c -> {
+ LOG.debug(" {} expires on:{}", c, c.expiresAt());
+ }));
+ //LOG.debug("Cookies for {}: {}", url.host(), cookies);
return cookies != null ? cookies : new ArrayList();
}
diff --git a/src/main/java/ctbrec/ui/ThumbCell.java b/src/main/java/ctbrec/ui/ThumbCell.java
index c4f35030..f579a948 100644
--- a/src/main/java/ctbrec/ui/ThumbCell.java
+++ b/src/main/java/ctbrec/ui/ThumbCell.java
@@ -208,6 +208,8 @@ public class ThumbCell extends StackPane {
LOG.trace("Removing invalid resolution value for {}", model.getName());
model.invalidateCacheEntries();
}
+
+ Thread.sleep(500);
} catch (IOException | InterruptedException e1) {
LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e1);
} catch(ExecutionException e) {
@@ -419,13 +421,11 @@ public class ThumbCell extends StackPane {
}
public void setModel(Model model) {
- //this.model = model;
this.model.setName(model.getName());
this.model.setDescription(model.getDescription());
this.model.setPreview(model.getPreview());
this.model.setTags(model.getTags());
this.model.setUrl(model.getUrl());
-
update();
}