diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3b95ef4..5a025d00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+1.7.0
+========================
+* Added CamSoda
+* Added detection of model name changes for MyFreeCams
+* Added setting to define a maximum resolution
+* Fixed sorting by date in recordings table
+
1.6.1
========================
* Fixed UI freeze, which occured for a high number of recorded models
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 f38886a0..5994a6db 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
4.0.0
ctbrec
ctbrec
- 1.6.1
+ 1.7.0
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/main/java/ctbrec/AbstractModel.java b/src/main/java/ctbrec/AbstractModel.java
index 267f9661..bf0395d0 100644
--- a/src/main/java/ctbrec/AbstractModel.java
+++ b/src/main/java/ctbrec/AbstractModel.java
@@ -2,14 +2,11 @@ package ctbrec;
import java.io.IOException;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
-import com.iheartradio.m3u8.ParseException;
-import com.iheartradio.m3u8.PlaylistException;
-
-import ctbrec.recorder.download.StreamSource;
+import com.squareup.moshi.JsonReader;
+import com.squareup.moshi.JsonWriter;
public abstract class AbstractModel implements Model {
@@ -20,6 +17,11 @@ public abstract class AbstractModel implements Model {
private List tags = new ArrayList<>();
private int streamUrlIndex = -1;
+ @Override
+ public boolean isOnline() throws IOException, ExecutionException, InterruptedException {
+ return isOnline(false);
+ }
+
@Override
public String getUrl() {
return url;
@@ -81,16 +83,13 @@ public abstract class AbstractModel implements Model {
}
@Override
- public String getSegmentPlaylistUrl() throws IOException, ExecutionException, ParseException, PlaylistException {
- List streamSources = getStreamSources();
- String url = null;
- if(getStreamUrlIndex() >= 0 && getStreamUrlIndex() < streamSources.size()) {
- url = streamSources.get(getStreamUrlIndex()).getMediaPlaylistUrl();
- } else {
- Collections.sort(streamSources);
- url = streamSources.get(streamSources.size()-1).getMediaPlaylistUrl();
- }
- return url;
+ public void readSiteSpecificData(JsonReader reader) throws IOException {
+ // noop default implementation, can be overriden by concrete models
+ }
+
+ @Override
+ public void writeSiteSpecificData(JsonWriter writer) throws IOException {
+ // noop default implementation, can be overriden by concrete models
}
@Override
diff --git a/src/main/java/ctbrec/Model.java b/src/main/java/ctbrec/Model.java
index 68d9646b..351dda3e 100644
--- a/src/main/java/ctbrec/Model.java
+++ b/src/main/java/ctbrec/Model.java
@@ -6,6 +6,8 @@ import java.util.concurrent.ExecutionException;
import com.iheartradio.m3u8.ParseException;
import com.iheartradio.m3u8.PlaylistException;
+import com.squareup.moshi.JsonReader;
+import com.squareup.moshi.JsonWriter;
import ctbrec.recorder.download.StreamSource;
import ctbrec.sites.Site;
@@ -27,7 +29,6 @@ public interface Model {
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException;
public String getOnlineState(boolean failFast) throws IOException, ExecutionException;
public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException;
- public String getSegmentPlaylistUrl() throws IOException, ExecutionException, ParseException, PlaylistException;
public void invalidateCacheEntries();
public void receiveTip(int tokens) throws IOException;
public int[] getStreamResolution(boolean failFast) throws ExecutionException;
@@ -35,4 +36,6 @@ public interface Model {
public boolean unfollow() throws IOException;
public void setSite(Site site);
public Site getSite();
+ public void writeSiteSpecificData(JsonWriter writer) throws IOException;
+ public void readSiteSpecificData(JsonReader reader) throws IOException;
}
\ No newline at end of file
diff --git a/src/main/java/ctbrec/Settings.java b/src/main/java/ctbrec/Settings.java
index 1da9aaf6..a8809ddf 100644
--- a/src/main/java/ctbrec/Settings.java
+++ b/src/main/java/ctbrec/Settings.java
@@ -24,6 +24,8 @@ public class Settings {
public String password = ""; // chaturbate password TODO maybe rename this onetime
public String mfcUsername = "";
public String mfcPassword = "";
+ public String camsodaUsername = "";
+ public String camsodaPassword = "";
public String cam4Username;
public String cam4Password;
public String lastDownloadDir = "";
@@ -32,6 +34,7 @@ public class Settings {
public boolean determineResolution = false;
public boolean requireAuthentication = false;
public boolean chooseStreamQuality = false;
+ public int maximumResolution = 0;
public byte[] key = null;
public ProxyType proxyType = ProxyType.DIRECT;
public String proxyHost;
diff --git a/src/main/java/ctbrec/ui/CookieJarImpl.java b/src/main/java/ctbrec/io/CookieJarImpl.java
similarity index 98%
rename from src/main/java/ctbrec/ui/CookieJarImpl.java
rename to src/main/java/ctbrec/io/CookieJarImpl.java
index ce18ea4d..712ff30c 100644
--- a/src/main/java/ctbrec/ui/CookieJarImpl.java
+++ b/src/main/java/ctbrec/io/CookieJarImpl.java
@@ -1,4 +1,4 @@
-package ctbrec.ui;
+package ctbrec.io;
import java.util.ArrayList;
import java.util.HashMap;
@@ -34,6 +34,7 @@ public class CookieJarImpl implements CookieJar {
if(newCookie.name().equalsIgnoreCase(name)) {
LOG.debug("Replacing cookie {} {} -> {} [{}]", oldCookie.name(), oldCookie.value(), newCookie.value(), oldCookie.domain());
iterator.remove();
+ break;
}
}
}
diff --git a/src/main/java/ctbrec/io/HttpClient.java b/src/main/java/ctbrec/io/HttpClient.java
index 3b8e18e8..02c8f818 100644
--- a/src/main/java/ctbrec/io/HttpClient.java
+++ b/src/main/java/ctbrec/io/HttpClient.java
@@ -7,7 +7,6 @@ import java.util.concurrent.TimeUnit;
import ctbrec.Config;
import ctbrec.Settings.ProxyType;
-import ctbrec.ui.CookieJarImpl;
import okhttp3.ConnectionPool;
import okhttp3.Credentials;
import okhttp3.OkHttpClient;
diff --git a/src/main/java/ctbrec/io/ModelJsonAdapter.java b/src/main/java/ctbrec/io/ModelJsonAdapter.java
index fdaefc39..804c77fa 100644
--- a/src/main/java/ctbrec/io/ModelJsonAdapter.java
+++ b/src/main/java/ctbrec/io/ModelJsonAdapter.java
@@ -32,45 +32,51 @@ public class ModelJsonAdapter extends JsonAdapter {
String url = null;
String type = null;
int streamUrlIndex = -1;
+
+ Model model = null;
while(reader.hasNext()) {
- Token token = reader.peek();
- if(token == Token.NAME) {
- String key = reader.nextName();
- if(key.equals("name")) {
- name = reader.nextString();
- } else if(key.equals("description")) {
- description = reader.nextString();
- } else if(key.equals("url")) {
- url = reader.nextString();
- } else if(key.equals("type")) {
- type = reader.nextString();
- } else if(key.equals("streamUrlIndex")) {
- streamUrlIndex = reader.nextInt();
+ try {
+ Token token = reader.peek();
+ if(token == Token.NAME) {
+ String key = reader.nextName();
+ if(key.equals("name")) {
+ name = reader.nextString();
+ model.setName(name);
+ } else if(key.equals("description")) {
+ description = reader.nextString();
+ model.setDescription(description);
+ } else if(key.equals("url")) {
+ url = reader.nextString();
+ model.setUrl(url);
+ } else if(key.equals("type")) {
+ type = reader.nextString();
+ Class> modelClass = Class.forName(Optional.ofNullable(type).orElse(ChaturbateModel.class.getName()));
+ model = (Model) modelClass.newInstance();
+ } else if(key.equals("streamUrlIndex")) {
+ streamUrlIndex = reader.nextInt();
+ model.setStreamUrlIndex(streamUrlIndex);
+ } else if(key.equals("siteSpecific")) {
+ reader.beginObject();
+ model.readSiteSpecificData(reader);
+ reader.endObject();
+ }
+ } else {
+ reader.skipValue();
}
- } else {
- reader.skipValue();
+ } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) {
+ throw new IOException("Couldn't instantiate model class [" + type + "]", e);
}
}
reader.endObject();
- try {
- Class> modelClass = Class.forName(Optional.ofNullable(type).orElse(ChaturbateModel.class.getName()));
- Model model = (Model) modelClass.newInstance();
- model.setName(name);
- model.setDescription(description);
- model.setUrl(url);
- model.setStreamUrlIndex(streamUrlIndex);
- if(sites != null) {
- for (Site site : sites) {
- if(site.isSiteForModel(model)) {
- model.setSite(site);
- }
+ if(sites != null) {
+ for (Site site : sites) {
+ if(site.isSiteForModel(model)) {
+ model.setSite(site);
}
}
- return model;
- } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) {
- throw new IOException("Couldn't instantiate model class [" + type + "]", e);
}
+ return model;
}
@Override
@@ -81,6 +87,10 @@ public class ModelJsonAdapter extends JsonAdapter {
writeValueIfSet(writer, "description", model.getDescription());
writeValueIfSet(writer, "url", model.getUrl());
writer.name("streamUrlIndex").value(model.getStreamUrlIndex());
+ writer.name("siteSpecific");
+ writer.beginObject();
+ model.writeSiteSpecificData(writer);
+ writer.endObject();
writer.endObject();
}
diff --git a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java
index 787ca938..84a3744d 100644
--- a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java
+++ b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java
@@ -6,10 +6,16 @@ import java.io.InputStream;
import java.net.URL;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
import java.util.List;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import com.iheartradio.m3u8.Encoding;
import com.iheartradio.m3u8.Format;
import com.iheartradio.m3u8.ParseException;
@@ -20,12 +26,16 @@ 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.io.HttpClient;
import okhttp3.Request;
import okhttp3.Response;
public abstract class AbstractHlsDownload implements Download {
+ private static final transient Logger LOG = LoggerFactory.getLogger(AbstractHlsDownload.class);
+
ExecutorService downloadThreadPool = Executors.newFixedThreadPool(5);
HttpClient client;
volatile boolean running = false;
@@ -69,6 +79,34 @@ public abstract class AbstractHlsDownload implements Download {
}
}
+
+ String getSegmentPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException {
+ List streamSources = model.getStreamSources();
+ String url = null;
+ if(model.getStreamUrlIndex() >= 0 && model.getStreamUrlIndex() < streamSources.size()) {
+ url = streamSources.get(model.getStreamUrlIndex()).getMediaPlaylistUrl();
+ } else {
+ Collections.sort(streamSources);
+ // filter out stream resolutions, which are too high
+ int maxRes = Config.getInstance().getSettings().maximumResolution;
+ if(maxRes > 0) {
+ for (Iterator iterator = streamSources.iterator(); iterator.hasNext();) {
+ StreamSource streamSource = iterator.next();
+ if(streamSource.height > 0 && maxRes < streamSource.height) {
+ LOG.trace("Res too high {} > {}", streamSource.height, maxRes);
+ iterator.remove();
+ }
+ }
+ }
+ if(streamSources.isEmpty()) {
+ throw new ExecutionException(new RuntimeException("No stream left in playlist"));
+ } else {
+ url = streamSources.get(streamSources.size()-1).getMediaPlaylistUrl();
+ }
+ }
+ return url;
+ }
+
@Override
public boolean isAlive() {
return alive;
diff --git a/src/main/java/ctbrec/recorder/download/HlsDownload.java b/src/main/java/ctbrec/recorder/download/HlsDownload.java
index 313b0c0e..6974056a 100644
--- a/src/main/java/ctbrec/recorder/download/HlsDownload.java
+++ b/src/main/java/ctbrec/recorder/download/HlsDownload.java
@@ -48,7 +48,7 @@ public class HlsDownload extends AbstractHlsDownload {
throw new IOException(model.getName() +"'s room is not public");
}
- String segments = model.getSegmentPlaylistUrl();
+ String segments = getSegmentPlaylistUrl(model);
if(segments != null) {
if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(downloadDir);
diff --git a/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java b/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java
index f122b895..51e7b422 100644
--- a/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java
+++ b/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java
@@ -101,7 +101,7 @@ public class MergedHlsDownload extends AbstractHlsDownload {
target = new File(targetFile.getAbsolutePath().replaceAll("\\.ts", "-00000.ts"));
}
- String segments = model.getSegmentPlaylistUrl();
+ String segments = getSegmentPlaylistUrl(model);
mergeThread = createMergeThread(target, null, true);
mergeThread.start();
if(segments != null) {
diff --git a/src/main/java/ctbrec/recorder/server/HttpServer.java b/src/main/java/ctbrec/recorder/server/HttpServer.java
index 69aceead..00537280 100644
--- a/src/main/java/ctbrec/recorder/server/HttpServer.java
+++ b/src/main/java/ctbrec/recorder/server/HttpServer.java
@@ -21,6 +21,7 @@ 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;
@@ -53,7 +54,9 @@ public class HttpServer {
}
recorder = new LocalRecorder(config);
for (Site site : sites) {
- site.init();
+ if(site.isEnabled()) {
+ site.init();
+ }
}
startHttpServer();
}
@@ -61,6 +64,7 @@ public class HttpServer {
private void createSites() {
sites.add(new Chaturbate());
sites.add(new MyFreeCams());
+ sites.add(new Camsoda());
sites.add(new Cam4());
}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java
index 9aae0544..c170af83 100644
--- a/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java
+++ b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java
@@ -1,5 +1,6 @@
package ctbrec.sites.cam4;
+import java.io.File;
import java.io.InputStream;
import java.net.CookieHandler;
import java.net.CookieManager;
@@ -13,6 +14,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ctbrec.Config;
+import ctbrec.OS;
import javafx.concurrent.Worker.State;
import javafx.scene.Scene;
import javafx.scene.control.ProgressIndicator;
@@ -71,24 +73,29 @@ public class Cam4LoginDialog {
});
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);
+ try {
+ 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')");
+ } catch(Exception e) {
+ LOG.warn("Couldn't auto fill username and password for Cam4", e);
+ }
} else if (newState == State.CANCELLED || newState == State.FAILED) {
veil.setVisible(false);
p.setVisible(false);
}
});
+ webEngine.setUserDataDirectory(new File(OS.getConfigDir(), "webengine"));
webEngine.load(URL);
return browser;
}
diff --git a/src/main/java/ctbrec/sites/camsoda/Camsoda.java b/src/main/java/ctbrec/sites/camsoda/Camsoda.java
new file mode 100644
index 00000000..3cf7f937
--- /dev/null
+++ b/src/main/java/ctbrec/sites/camsoda/Camsoda.java
@@ -0,0 +1,171 @@
+package ctbrec.sites.camsoda;
+
+import java.io.IOException;
+
+import org.json.JSONObject;
+
+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;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class Camsoda extends AbstractSite {
+
+ public static final String BASE_URI = "https://www.camsoda.com";
+ private Recorder recorder;
+ private HttpClient httpClient;
+
+ @Override
+ public String getName() {
+ return "CamSoda";
+ }
+
+ @Override
+ public String getBaseUrl() {
+ return BASE_URI;
+ }
+
+ @Override
+ public String getAffiliateLink() {
+ return BASE_URI;
+ }
+
+ @Override
+ public void setRecorder(Recorder recorder) {
+ this.recorder = recorder;
+ }
+
+ @Override
+ public TabProvider getTabProvider() {
+ return new CamsodaTabProvider(this, recorder);
+ }
+
+ @Override
+ public Model createModel(String name) {
+ CamsodaModel model = new CamsodaModel();
+ model.setName(name);
+ model.setUrl(getBaseUrl() + "/" + name);
+ model.setSite(this);
+ return model;
+ }
+
+ @Override
+ public Integer getTokenBalance() throws IOException {
+ if (!credentialsAvailable()) {
+ throw new IOException("Account settings not available");
+ }
+
+ String username = Config.getInstance().getSettings().camsodaUsername;
+ String url = BASE_URI + "/api/v1/user/" + username;
+ Request request = new Request.Builder().url(url).build();
+ Response response = getHttpClient().execute(request, true);
+ if(response.isSuccessful()) {
+ JSONObject json = new JSONObject(response.body().string());
+ if(json.has("user")) {
+ JSONObject user = json.getJSONObject("user");
+ if(user.has("tokens")) {
+ return user.getInt("tokens");
+ }
+ }
+ } else {
+ throw new IOException(response.code() + " " + response.message());
+ }
+ throw new RuntimeException("Tokens not found in response");
+ }
+
+ @Override
+ public String getBuyTokensLink() {
+ return getBaseUrl();
+ }
+
+ @Override
+ public void login() throws IOException {
+ if(credentialsAvailable()) {
+ getHttpClient().login();
+ }
+ }
+
+ @Override
+ public HttpClient getHttpClient() {
+ if(httpClient == null) {
+ httpClient = new CamsodaHttpClient();
+ }
+ return httpClient;
+ }
+
+ @Override
+ public void init() throws IOException {
+ }
+
+ @Override
+ public void shutdown() {
+ if(httpClient != null) {
+ httpClient.shutdown();
+ }
+ }
+
+ @Override
+ public boolean supportsTips() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFollow() {
+ return true;
+ }
+
+ @Override
+ public boolean isSiteForModel(Model m) {
+ return m instanceof CamsodaModel;
+ }
+
+ @Override
+ public boolean credentialsAvailable() {
+ String username = Config.getInstance().getSettings().camsodaUsername;
+ return username != null && !username.trim().isEmpty();
+ }
+
+ @Override
+ public Node getConfigurationGui() {
+ GridPane layout = SettingsTab.createGridLayout();
+ layout.add(new Label("CamSoda User"), 0, 0);
+ TextField username = new TextField(Config.getInstance().getSettings().camsodaUsername);
+ username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaUsername = username.getText());
+ GridPane.setFillWidth(username, true);
+ GridPane.setHgrow(username, Priority.ALWAYS);
+ GridPane.setColumnSpan(username, 2);
+ layout.add(username, 1, 0);
+
+ layout.add(new Label("CamSoda Password"), 0, 1);
+ PasswordField password = new PasswordField();
+ password.setText(Config.getInstance().getSettings().camsodaPassword);
+ password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaPassword = 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(getAffiliateLink()));
+ 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/camsoda/CamsodaFollowedTab.java b/src/main/java/ctbrec/sites/camsoda/CamsodaFollowedTab.java
new file mode 100644
index 00000000..bc95672b
--- /dev/null
+++ b/src/main/java/ctbrec/sites/camsoda/CamsodaFollowedTab.java
@@ -0,0 +1,80 @@
+package ctbrec.sites.camsoda;
+
+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 CamsodaFollowedTab extends ThumbOverviewTab implements FollowedTab {
+ private Label status;
+ boolean showOnline = true;
+
+ public CamsodaFollowedTab(String title, Camsoda camsoda) {
+ super(title, new CamsodaFollowedUpdateService(camsoda), camsoda);
+ 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) -> {
+ queue.clear();
+ ((CamsodaFollowedUpdateService)updateService).showOnline(online.isSelected());
+ updateService.restart();
+ });
+ }
+
+ @Override
+ protected void onSuccess() {
+ grid.getChildren().remove(status);
+ super.onSuccess();
+ }
+
+ @Override
+ protected void onFail(WorkerStateEvent event) {
+ String msg = "";
+ if (event.getSource().getException() != null) {
+ msg = ": " + event.getSource().getException().getMessage();
+ }
+ status.setText("Login failed" + msg);
+ 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/camsoda/CamsodaFollowedUpdateService.java b/src/main/java/ctbrec/sites/camsoda/CamsodaFollowedUpdateService.java
new file mode 100644
index 00000000..20c4bbe0
--- /dev/null
+++ b/src/main/java/ctbrec/sites/camsoda/CamsodaFollowedUpdateService.java
@@ -0,0 +1,73 @@
+package ctbrec.sites.camsoda;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import ctbrec.Model;
+import ctbrec.ui.PaginatedScheduledService;
+import javafx.concurrent.Task;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class CamsodaFollowedUpdateService extends PaginatedScheduledService {
+ private Camsoda camsoda;
+ private boolean showOnline = true;
+
+ public CamsodaFollowedUpdateService(Camsoda camsoda) {
+ this.camsoda = camsoda;
+ }
+
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public List call() throws IOException {
+ List models = new ArrayList<>();
+ String url = camsoda.getBaseUrl() + "/api/v1/user/current";
+ Request request = new Request.Builder().url(url).build();
+ Response response = camsoda.getHttpClient().execute(request, true);
+ if (response.isSuccessful()) {
+ JSONObject json = new JSONObject(response.body().string());
+ if(json.has("status") && json.getBoolean("status")) {
+ JSONObject user = json.getJSONObject("user");
+ JSONArray following = user.getJSONArray("following");
+ for (int i = 0; i < following.length(); i++) {
+ JSONObject m = following.getJSONObject(i);
+ CamsodaModel model = (CamsodaModel) camsoda.createModel(m.getString("followname"));
+ boolean online = m.getInt("online") == 1;
+ model.setOnlineState(online ? "online" : "offline");
+ model.setPreview("https://md.camsoda.com/thumbs/" + model.getName() + ".jpg");
+ models.add(model);
+ }
+ return models.stream()
+ .filter((m) -> {
+ try {
+ return m.isOnline() == showOnline;
+ } catch (IOException | ExecutionException | InterruptedException e) {
+ return false;
+ }
+ }).collect(Collectors.toList());
+ } else {
+ response.close();
+ return Collections.emptyList();
+ }
+ } else {
+ int code = response.code();
+ response.close();
+ throw new IOException("HTTP status " + code);
+ }
+ }
+ };
+ }
+
+ void showOnline(boolean online) {
+ this.showOnline = online;
+ }
+}
diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java b/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java
new file mode 100644
index 00000000..9ab57b9e
--- /dev/null
+++ b/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java
@@ -0,0 +1,151 @@
+package ctbrec.sites.camsoda;
+
+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.jsoup.nodes.Element;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.io.HttpClient;
+import ctbrec.sites.cam4.Cam4LoginDialog;
+import ctbrec.ui.HtmlParser;
+import javafx.application.Platform;
+import okhttp3.Cookie;
+import okhttp3.FormBody;
+import okhttp3.HttpUrl;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class CamsodaHttpClient extends HttpClient {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaHttpClient.class);
+ private String csrfToken = null;
+
+ @Override
+ public boolean login() throws IOException {
+ if(loggedIn) {
+ return true;
+ }
+
+ String url = Camsoda.BASE_URI + "/api/v1/auth/login";
+ FormBody body = new FormBody.Builder()
+ .add("username", Config.getInstance().getSettings().camsodaUsername)
+ .add("password", Config.getInstance().getSettings().camsodaPassword)
+ .build();
+ Request request = new Request.Builder()
+ .url(url)
+ .post(body)
+ .build();
+ Response response = execute(request);
+ if(response.isSuccessful()) {
+ JSONObject resp = new JSONObject(response.body().string());
+ if(resp.has("error")) {
+ String error = resp.getString("error");
+ if (Objects.equals(error, "Please confirm that you are not a robot.")) {
+ //return loginWithDialog();
+ throw new IOException("CamSoda requested to solve a captcha. Please try again in a while (maybe 15 min).");
+ } else {
+ throw new IOException(resp.getString("error"));
+ }
+ } else {
+ return true;
+ }
+ } else {
+ throw new IOException(response.code() + " " + response.message());
+ }
+ }
+
+ @SuppressWarnings("unused")
+ private boolean loginWithDialog() throws IOException {
+ BlockingQueue queue = new LinkedBlockingQueue<>();
+
+ Runnable showDialog = () -> {
+ // login with javafx WebView
+ CamsodaLoginDialog loginDialog = new CamsodaLoginDialog();
+
+ // 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
+ * @throws IOException
+ */
+ private boolean checkLoginSuccess() throws IOException {
+ String url = Camsoda.BASE_URI + "/api/v1/user/current";
+ Request request = new Request.Builder().url(url).build();
+ try(Response response = execute(request)) {
+ if(response.isSuccessful()) {
+ JSONObject resp = new JSONObject(response.body().string());
+ return resp.optBoolean("status");
+ } else {
+ return false;
+ }
+ }
+ }
+
+ private void transferCookies(CamsodaLoginDialog 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 String getCsrfToken() throws IOException {
+ if(csrfToken == null) {
+ String url = Camsoda.BASE_URI;
+ Request request = new Request.Builder().url(url).build();
+ Response resp = execute(request, true);
+ if(resp.isSuccessful()) {
+ Element meta = HtmlParser.getTag(resp.body().string(), "meta[name=\"_token\"]");
+ csrfToken = meta.attr("content");
+ } else {
+ IOException e = new IOException(resp.code() + " " + resp.message());
+ resp.close();
+ throw e;
+ }
+ }
+ return csrfToken;
+ }
+}
diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaLoginDialog.java b/src/main/java/ctbrec/sites/camsoda/CamsodaLoginDialog.java
new file mode 100644
index 00000000..6668babf
--- /dev/null
+++ b/src/main/java/ctbrec/sites/camsoda/CamsodaLoginDialog.java
@@ -0,0 +1,109 @@
+package ctbrec.sites.camsoda;
+
+import java.io.File;
+import java.io.InputStream;
+import java.net.CookieHandler;
+import java.net.CookieManager;
+import java.net.HttpCookie;
+import java.util.Base64;
+import java.util.List;
+
+import ctbrec.OS;
+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;
+
+// FIXME this dialog does not help, because google's recaptcha does not work
+// with WebView even though it does work in Cam4LoginDialog
+public class CamsodaLoginDialog {
+
+ public static final String URL = Camsoda.BASE_URI;
+ private List cookies = null;
+ private String url;
+ private Region veil;
+ private ProgressIndicator p;
+
+ public CamsodaLoginDialog() {
+ Stage stage = new Stage();
+ stage.setTitle("CamSoda 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(1, 1, 1)");
+ p = new ProgressIndicator();
+ p.setMaxSize(140, 140);
+
+ p.setVisible(true);
+ veil.visibleProperty().bind(p.visibleProperty());
+
+ StackPane stackPane = new StackPane();
+ stackPane.getChildren().addAll(webView, veil, p);
+
+ stage.setScene(new Scene(stackPane, 400, 358));
+ 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();
+ System.out.println(newV.toString());
+ });
+ webEngine.getLoadWorker().stateProperty().addListener((observable, oldState, newState) -> {
+ if (newState == State.SUCCEEDED) {
+ webEngine.executeScript("document.querySelector('a[ng-click=\"signin();\"]').click()");
+ p.setVisible(false);
+
+ // TODO make this work
+ // String username = Config.getInstance().getSettings().camsodaUsername;
+ // if (username != null && !username.trim().isEmpty()) {
+ // webEngine.executeScript("document.querySelector('input[name=\"loginUsername\"]').value = '" + username + "'");
+ // }
+ // String password = Config.getInstance().getSettings().camsodaPassword;
+ // if (password != null && !password.trim().isEmpty()) {
+ // webEngine.executeScript("document.querySelector('input[name=\"loginPassword\"]').value = '" + password + "'");
+ // }
+ } else if (newState == State.CANCELLED || newState == State.FAILED) {
+ p.setVisible(false);
+ }
+ });
+
+ webEngine.setUserStyleSheetLocation("data:text/css;base64," + Base64.getEncoder().encodeToString(CUSTOM_STYLE.getBytes()));
+ webEngine.setUserDataDirectory(new File(OS.getConfigDir(), "webengine"));
+ webEngine.load(URL);
+ return browser;
+ }
+
+ public List getCookies() {
+ return cookies;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ private static final String CUSTOM_STYLE = ""
+ + ".ngdialog.ngdialog-theme-custom { padding: 0 !important }"
+ + ".ngdialog-overlay { background: black !important; }";
+}
diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java b/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java
new file mode 100644
index 00000000..1fe5beeb
--- /dev/null
+++ b/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java
@@ -0,0 +1,270 @@
+package ctbrec.sites.camsoda;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+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 com.iheartradio.m3u8.data.StreamInfo;
+
+import ctbrec.AbstractModel;
+import ctbrec.recorder.download.StreamSource;
+import ctbrec.sites.Site;
+import okhttp3.FormBody;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+public class CamsodaModel extends AbstractModel {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaModel.class);
+ private String streamUrl;
+ private Site site;
+ private List streamSources = null;
+ private String status = "n/a";
+ private float sortOrder = 0;
+
+ private static Cache streamResolutionCache = CacheBuilder.newBuilder()
+ .initialCapacity(10_000)
+ .maximumSize(10_000)
+ .expireAfterWrite(30, TimeUnit.MINUTES)
+ .build();
+
+ public String getStreamUrl() throws IOException {
+ if(streamUrl == null) {
+ // load model
+ loadModel();
+ }
+ return streamUrl;
+ }
+
+ private void loadModel() throws IOException {
+ String modelUrl = site.getBaseUrl() + "/api/v1/user/" + getName();
+ Request req = new Request.Builder().url(modelUrl).build();
+ Response response = site.getHttpClient().execute(req);
+ try {
+ JSONObject result = new JSONObject(response.body().string());
+ if(result.getBoolean("status")) {
+ JSONObject chat = result.getJSONObject("user").getJSONObject("chat");
+ status = chat.getString("status");
+ if(chat.has("edge_servers")) {
+ String edgeServer = chat.getJSONArray("edge_servers").getString(0);
+ String streamName = chat.getString("stream_name");
+ streamUrl = "https://" + edgeServer + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8";
+ }
+
+ } else {
+ throw new IOException("Result was not ok");
+ }
+ } finally {
+ response.close();
+ }
+ }
+
+ @Override
+ public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
+ if(ignoreCache) {
+ loadModel();
+ }
+ return Objects.equals(status, "online");
+ }
+
+ @Override
+ public String getOnlineState(boolean failFast) throws IOException, ExecutionException {
+ if(failFast) {
+ return status;
+ } else {
+ if(status.equals("n/a")) {
+ loadModel();
+ }
+ return status;
+ }
+ }
+
+ public void setOnlineState(String state) {
+ this.status = state;
+ }
+
+ @Override
+ public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException {
+ String streamUrl = getStreamUrl();
+ if(streamUrl == null) {
+ return Collections.emptyList();
+ }
+ Request req = new Request.Builder().url(streamUrl).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();
+ PlaylistData playlistData = master.getPlaylists().get(0);
+ StreamSource streamsource = new StreamSource();
+ streamsource.mediaPlaylistUrl = streamUrl.replace("playlist.m3u8", playlistData.getUri());
+ if(playlistData.hasStreamInfo()) {
+ StreamInfo info = playlistData.getStreamInfo();
+ streamsource.bandwidth = info.getBandwidth();
+ streamsource.width = info.hasResolution() ? info.getResolution().width : 0;
+ streamsource.height = info.hasResolution() ? info.getResolution().height : 0;
+ } else {
+ streamsource.bandwidth = 0;
+ streamsource.width = 0;
+ streamsource.height = 0;
+ }
+ streamSources = Collections.singletonList(streamsource);
+ } finally {
+ response.close();
+ }
+ return streamSources;
+ }
+
+ @Override
+ public void invalidateCacheEntries() {
+ streamSources = null;
+ streamResolutionCache.invalidate(getName());
+ }
+
+ @Override
+ public int[] getStreamResolution(boolean failFast) throws ExecutionException {
+ int[] resolution = streamResolutionCache.getIfPresent(getName());
+ if(resolution != null) {
+ return resolution;
+ } else {
+ if(failFast) {
+ return new int[] {0,0};
+ } else {
+ try {
+ List streamSources = getStreamSources();
+ if(streamSources.isEmpty()) {
+ return new int[] {0,0};
+ } else {
+ StreamSource src = streamSources.get(0);
+ resolution = new int[] {src.width, src.height};
+ streamResolutionCache.put(getName(), resolution);
+ return resolution;
+ }
+ } catch (IOException | ParseException | PlaylistException e) {
+ throw new ExecutionException(e);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void receiveTip(int tokens) throws IOException {
+ String csrfToken = ((CamsodaHttpClient)site.getHttpClient()).getCsrfToken();
+ String url = site.getBaseUrl() + "/api/v1/tip/" + getName();
+ if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) {
+ LOG.debug("Sending tip {}", url);
+ RequestBody body = new FormBody.Builder()
+ .add("amount", Integer.toString(tokens))
+ .add("comment", "")
+ .build();
+ Request request = new Request.Builder()
+ .url(url)
+ .post(body)
+ .addHeader("Referer", Camsoda.BASE_URI + '/' + getName())
+ .addHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0")
+ .addHeader("Accept", "application/json, text/plain, */*")
+ .addHeader("Accept-Language", "en")
+ .addHeader("X-CSRF-Token", csrfToken)
+ .build();
+ try(Response response = site.getHttpClient().execute(request, true)) {
+ if(!response.isSuccessful()) {
+ throw new IOException("HTTP status " + response.code() + " " + response.message());
+ }
+ }
+ }
+ }
+
+ @Override
+ public boolean follow() throws IOException {
+ String url = Camsoda.BASE_URI + "/api/v1/follow/" + getName();
+ LOG.debug("Sending follow request {}", url);
+ String csrfToken = ((CamsodaHttpClient)site.getHttpClient()).getCsrfToken();
+ Request request = new Request.Builder()
+ .url(url)
+ .post(RequestBody.create(null, ""))
+ .addHeader("Referer", Camsoda.BASE_URI + '/' + getName())
+ .addHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0")
+ .addHeader("Accept", "application/json, text/plain, */*")
+ .addHeader("Accept-Language", "en")
+ .addHeader("X-CSRF-Token", csrfToken)
+ .build();
+ Response resp = site.getHttpClient().execute(request, true);
+ if (resp.isSuccessful()) {
+ resp.close();
+ return true;
+ } else {
+ resp.close();
+ throw new IOException("HTTP status " + resp.code() + " " + resp.message());
+ }
+ }
+
+ @Override
+ public boolean unfollow() throws IOException {
+ String url = Camsoda.BASE_URI + "/api/v1/unfollow/" + getName();
+ LOG.debug("Sending follow request {}", url);
+ String csrfToken = ((CamsodaHttpClient)site.getHttpClient()).getCsrfToken();
+ Request request = new Request.Builder()
+ .url(url)
+ .post(RequestBody.create(null, ""))
+ .addHeader("Referer", Camsoda.BASE_URI + '/' + getName())
+ .addHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0")
+ .addHeader("Accept", "application/json, text/plain, */*")
+ .addHeader("Accept-Language", "en")
+ .addHeader("X-CSRF-Token", csrfToken)
+ .build();
+ Response resp = site.getHttpClient().execute(request, true);
+ if (resp.isSuccessful()) {
+ resp.close();
+ return true;
+ } else {
+ resp.close();
+ throw new IOException("HTTP status " + resp.code() + " " + resp.message());
+ }
+ }
+
+ @Override
+ public void setSite(Site site) {
+ if(site instanceof Camsoda) {
+ this.site = site;
+ } else {
+ throw new IllegalArgumentException("Site has to be an instance of Camsoda");
+ }
+ }
+
+ @Override
+ public Site getSite() {
+ return site;
+ }
+
+ public void setStreamUrl(String streamUrl) {
+ this.streamUrl = streamUrl;
+ }
+
+ public float getSortOrder() {
+ return sortOrder;
+ }
+
+ public void setSortOrder(float sortOrder) {
+ this.sortOrder = sortOrder;
+ }
+}
diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java b/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java
new file mode 100644
index 00000000..44070fcc
--- /dev/null
+++ b/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java
@@ -0,0 +1,286 @@
+package ctbrec.sites.camsoda;
+
+import java.io.IOException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.time.temporal.TemporalAccessor;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Model;
+import ctbrec.recorder.Recorder;
+import ctbrec.ui.AutosizeAlert;
+import ctbrec.ui.DesktopIntergation;
+import ctbrec.ui.TabSelectionListener;
+import javafx.application.Platform;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.concurrent.Task;
+import javafx.geometry.Insets;
+import javafx.scene.Cursor;
+import javafx.scene.Node;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.ProgressIndicator;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TitledPane;
+import javafx.scene.control.Tooltip;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.GridPane;
+import javafx.scene.text.Font;
+import javafx.scene.text.FontWeight;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class CamsodaShowsTab extends Tab implements TabSelectionListener {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaShowsTab.class);
+
+ private Camsoda camsoda;
+ private Recorder recorder;
+ private GridPane showList;
+ private ProgressIndicator progressIndicator;
+
+ public CamsodaShowsTab(Camsoda camsoda, Recorder recorder) {
+ this.camsoda = camsoda;
+ this.recorder = recorder;
+ createGui();
+ }
+
+ private void createGui() {
+ showList = new GridPane();
+ showList.setPadding(new Insets(5));
+ showList.setHgap(5);
+ showList.setVgap(5);
+ progressIndicator = new ProgressIndicator();
+ progressIndicator.setPrefSize(100, 100);
+ setContent(progressIndicator);
+ setClosable(false);
+ setText("Shows");
+ }
+
+ @Override
+ public void selected() {
+ Task> task = new Task>() {
+ @Override
+ protected List call() throws Exception {
+ String url = camsoda.getBaseUrl() + "/api/v1/user/model_shows";
+ Request req = new Request.Builder().url(url).build();
+ Response response = camsoda.getHttpClient().execute(req);
+ if (response.isSuccessful()) {
+ JSONObject json = new JSONObject(response.body().string());
+ if (json.optInt("success") == 1) {
+ List boxes = new ArrayList<>();
+ JSONArray results = json.getJSONArray("results");
+ for (int i = 0; i < results.length(); i++) {
+ JSONObject result = results.getJSONObject(i);
+ String modelUrl = camsoda.getBaseUrl() + result.getString("url");
+ String name = modelUrl.substring(modelUrl.lastIndexOf('/') + 1);
+ Model model = camsoda.createModel(name);
+ ZonedDateTime startTime = parseUtcTime(result.getString("start"));
+ ZonedDateTime endTime = parseUtcTime(result.getString("end"));
+ boxes.add(new ShowBox(model, startTime, endTime));
+ }
+ return boxes;
+ } else {
+ LOG.error("Couldn't load upcoming camsoda shows. Unexpected response: {}", json.toString());
+ showErrorDialog("Oh no!", "Couldn't load upcoming CamSoda shows", "Got an unexpected response from server");
+ }
+ } else {
+ response.close();
+ showErrorDialog("Oh no!", "Couldn't load upcoming CamSoda shows", "Got an unexpected response from server");
+ LOG.error("Couldn't load upcoming camsoda shows: {} {}", response.code(), response.message());
+ }
+ return Collections.emptyList();
+ }
+
+ private ZonedDateTime parseUtcTime(String string) {
+ DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+ TemporalAccessor ta = formatter.parse(string.replace(" UTC", ""));
+ Instant instant = Instant.from(ta);
+ return ZonedDateTime.ofInstant(instant, ZoneId.systemDefault());
+ }
+
+ @Override
+ protected void done() {
+ super.done();
+ Platform.runLater(() -> {
+ try {
+ List boxes = get();
+ showList.getChildren().clear();
+ int index = 0;
+ for (ShowBox showBox : boxes) {
+ showList.add(showBox, index % 2, index++ / 2);
+ GridPane.setMargin(showBox, new Insets(20, 20, 0, 20));
+ }
+ } catch (Exception e) {
+ LOG.error("Couldn't load upcoming camsoda shows", e);
+ }
+ setContent(new ScrollPane(showList));
+ });
+ }
+ };
+ new Thread(task).start();
+ }
+
+ @Override
+ public void deselected() {
+ }
+
+ private void showErrorDialog(String title, String head, String msg) {
+ Platform.runLater(() -> {
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle(title);
+ alert.setHeaderText(head);
+ alert.setContentText(msg);
+ alert.showAndWait();
+ });
+ }
+
+ private class ShowBox extends TitledPane {
+
+ BorderPane root = new BorderPane();
+ int thumbSize = 200;
+
+ public ShowBox(Model model, ZonedDateTime startTime, ZonedDateTime endTime) {
+ setText(model.getName());
+ setPrefHeight(268);
+ setContent(root);
+
+ ImageView thumb = new ImageView();
+ thumb.setPreserveRatio(true);
+ thumb.setFitHeight(thumbSize);
+ loadImage(model, thumb);
+ root.setLeft(new ProgressIndicator());
+ BorderPane.setMargin(thumb, new Insets(10, 30, 10, 10));
+
+ DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM);
+ GridPane grid = new GridPane();
+
+ grid.add(createLabel("Start", true), 0, 0);
+ grid.add(createLabel(formatter.format(startTime), false), 1, 0);
+ grid.add(createLabel("End", true), 0, 1);
+ grid.add(createLabel(formatter.format(endTime), false), 1, 1);
+ Button record = new Button("Record Model");
+ record.setTooltip(new Tooltip(record.getText()));
+ record.setOnAction((evt) -> record(model));
+ grid.add(record, 1, 2);
+ GridPane.setMargin(record, new Insets(10));
+ Button follow = new Button("Follow");
+ follow.setTooltip(new Tooltip(follow.getText()));
+ follow.setOnAction((evt) -> follow(model));
+ grid.add(follow, 1, 3);
+ GridPane.setMargin(follow, new Insets(10));
+ Button openInBrowser = new Button("Open in Browser");
+ openInBrowser.setTooltip(new Tooltip(openInBrowser.getText()));
+ openInBrowser.setOnAction((evt) -> DesktopIntergation.open(model.getUrl()));
+ grid.add(openInBrowser, 1, 4);
+ GridPane.setMargin(openInBrowser, new Insets(10));
+ root.setCenter(grid);
+ loadImage(model, thumb);
+
+ record.prefWidthProperty().bind(openInBrowser.widthProperty());
+ follow.prefWidthProperty().bind(openInBrowser.widthProperty());
+ }
+
+ private void follow(Model model) {
+ setCursor(Cursor.WAIT);
+ new Thread(() -> {
+ try {
+ model.follow();
+ } catch (Exception e) {
+ LOG.error("Couldn't follow model {}", model, e);
+ showErrorDialog("Oh no!", "Couldn't follow model", e.getMessage());
+ } finally {
+ Platform.runLater(() -> {
+ setCursor(Cursor.DEFAULT);
+ });
+ }
+ }).start();
+ }
+
+ private void record(Model model) {
+ setCursor(Cursor.WAIT);
+ new Thread(() -> {
+ try {
+ recorder.startRecording(model);
+ } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) {
+ showErrorDialog("Oh no!", "Couldn't add model to the recorder", "Recorder error: " + e.getMessage());
+ } finally {
+ Platform.runLater(() -> {
+ setCursor(Cursor.DEFAULT);
+ });
+ }
+ }).start();
+ }
+
+ private void loadImage(Model model, ImageView thumb) {
+ new Thread(() -> {
+ try {
+ String url = camsoda.getBaseUrl() + "/api/v1/user/" + model.getName();
+ Request detailRequest = new Request.Builder().url(url).build();
+ Response resp = camsoda.getHttpClient().execute(detailRequest);
+ if (resp.isSuccessful()) {
+ JSONObject json = new JSONObject(resp.body().string());
+ if (json.optBoolean("status") && json.has("user")) {
+ JSONObject user = json.getJSONObject("user");
+ if (user.has("settings")) {
+ JSONObject settings = user.getJSONObject("settings");
+ String imageUrl;
+ if(Objects.equals(System.getenv("CTBREC_DEV"), "1")) {
+ imageUrl = getClass().getResource("/image_not_found.png").toString();
+ } else {
+ if (settings.has("offline_picture")) {
+ imageUrl = settings.getString("offline_picture");
+ } else {
+ imageUrl = "https:" + user.getString("thumb");
+ }
+ }
+ Platform.runLater(() -> {
+ Image img = new Image(imageUrl, 1000, thumbSize, true, true, true);
+ img.progressProperty().addListener(new ChangeListener() {
+ @Override
+ public void changed(ObservableValue extends Number> observable, Number oldValue, Number newValue) {
+ if (newValue.doubleValue() == 1.0) {
+ thumb.setImage(img);
+ root.setLeft(thumb);
+ }
+ }
+ });
+
+ });
+ }
+ }
+ }
+ resp.close();
+ } catch (Exception e) {
+ LOG.error("Couldn't load model details", e);
+ }
+ }).start();
+ }
+
+ private Node createLabel(String string, boolean bold) {
+ Label label = new Label(string);
+ label.setPadding(new Insets(10));
+ Font def = Font.getDefault();
+ label.setFont(Font.font(def.getFamily(), bold ? FontWeight.BOLD : FontWeight.NORMAL, 16));
+ return label;
+ }
+ }
+}
diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaTabProvider.java b/src/main/java/ctbrec/sites/camsoda/CamsodaTabProvider.java
new file mode 100644
index 00000000..9aa552a7
--- /dev/null
+++ b/src/main/java/ctbrec/sites/camsoda/CamsodaTabProvider.java
@@ -0,0 +1,43 @@
+package ctbrec.sites.camsoda;
+
+import static ctbrec.sites.camsoda.Camsoda.*;
+
+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 CamsodaTabProvider extends TabProvider {
+
+ private Camsoda camsoda;
+ private Recorder recorder;
+
+ public CamsodaTabProvider(Camsoda camsoda, Recorder recorder) {
+ this.camsoda = camsoda;
+ this.recorder = recorder;
+ }
+
+ @Override
+ public List getTabs(Scene scene) {
+ List tabs = new ArrayList<>();
+ tabs.add(createTab("Online", BASE_URI + "/api/v1/browse/online"));
+ CamsodaFollowedTab followedTab = new CamsodaFollowedTab("Followed", camsoda);
+ followedTab.setRecorder(recorder);
+ followedTab.setScene(scene);
+ tabs.add(followedTab);
+ tabs.add(new CamsodaShowsTab(camsoda, recorder));
+ return tabs;
+ }
+
+ private Tab createTab(String title, String url) {
+ CamsodaUpdateService updateService = new CamsodaUpdateService(url, false, camsoda);
+ ThumbOverviewTab tab = new ThumbOverviewTab(title, updateService, camsoda);
+ tab.setRecorder(recorder);
+ return tab;
+ }
+
+}
diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaUpdateService.java b/src/main/java/ctbrec/sites/camsoda/CamsodaUpdateService.java
new file mode 100644
index 00000000..a43f9332
--- /dev/null
+++ b/src/main/java/ctbrec/sites/camsoda/CamsodaUpdateService.java
@@ -0,0 +1,124 @@
+package ctbrec.sites.camsoda;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.eclipse.jetty.util.StringUtil;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Model;
+import ctbrec.ui.PaginatedScheduledService;
+import javafx.concurrent.Task;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class CamsodaUpdateService extends PaginatedScheduledService {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaUpdateService.class);
+
+ private String url;
+ private boolean loginRequired;
+ private Camsoda camsoda;
+ int modelsPerPage = 50;
+
+ public CamsodaUpdateService(String url, boolean loginRequired, Camsoda camsoda) {
+ this.url = url;
+ this.loginRequired = loginRequired;
+ this.camsoda = camsoda;
+ }
+
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public List call() throws IOException {
+ List models = new ArrayList<>();
+ if(loginRequired && StringUtil.isBlank(ctbrec.Config.getInstance().getSettings().username)) {
+ return Collections.emptyList();
+ } else {
+ String url = CamsodaUpdateService.this.url;
+ LOG.debug("Fetching page {}", url);
+ Request request = new Request.Builder().url(url).build();
+ Response response = camsoda.getHttpClient().execute(request, loginRequired);
+ if (response.isSuccessful()) {
+ JSONObject json = new JSONObject(response.body().string());
+ if(json.has("status") && json.getBoolean("status")) {
+ JSONArray results = json.getJSONArray("results");
+ for (int i = 0; i < results.length(); i++) {
+ JSONObject result = results.getJSONObject(i);
+ if(result.has("tpl")) {
+ JSONArray tpl = result.getJSONArray("tpl");
+ String name = tpl.getString(0);
+ // int connections = tpl.getInt(2);
+ String streamName = tpl.getString(5);
+ String tsize = tpl.getString(6);
+ String serverPrefix = tpl.getString(7);
+ CamsodaModel model = (CamsodaModel) camsoda.createModel(name);
+ model.setDescription(tpl.getString(4));
+ model.setSortOrder(tpl.getFloat(3));
+ long unixtime = System.currentTimeMillis() / 1000;
+ String preview = "https://thumbs-orig.camsoda.com/thumbs/"
+ + streamName + '/' + serverPrefix + '/' + tsize + '/' + unixtime + '/' + name + ".jpg?cb=" + unixtime;
+ model.setPreview(preview);
+ if(result.has("edge_servers")) {
+ JSONArray edgeServers = result.getJSONArray("edge_servers");
+ model.setStreamUrl("https://" + edgeServers.getString(0) + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8");
+ }
+ models.add(model);
+ } else {
+ //LOG.debug("{}", result.toString(2));
+ String name = result.getString("username");
+ CamsodaModel model = (CamsodaModel) camsoda.createModel(name);
+
+
+ if(result.has("server_prefix")) {
+ String serverPrefix = result.getString("server_prefix");
+ String streamName = result.getString("stream_name");
+ model.setSortOrder(result.getFloat("sort_value"));
+ models.add(model);
+ if(result.has("status")) {
+ model.setOnlineState(result.getString("status"));
+ }
+
+ if(result.has("edge_servers")) {
+ JSONArray edgeServers = result.getJSONArray("edge_servers");
+ model.setStreamUrl("https://" + edgeServers.getString(0) + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8");
+ }
+
+ if(result.has("tsize")) {
+ long unixtime = System.currentTimeMillis() / 1000;
+ String tsize = result.getString("tsize");
+ String preview = "https://thumbs-orig.camsoda.com/thumbs/"
+ + streamName + '/' + serverPrefix + '/' + tsize + '/' + unixtime + '/' + name + ".jpg?cb=" + unixtime;
+ model.setPreview(preview);
+ }
+
+ }
+ }
+ }
+ return models.stream()
+ .sorted((m1,m2) -> (int)(m2.getSortOrder() - m1.getSortOrder()))
+ .skip( (page-1) * modelsPerPage)
+ .limit(modelsPerPage)
+ .collect(Collectors.toList());
+ } else {
+ response.close();
+ return Collections.emptyList();
+ }
+ } else {
+ int code = response.code();
+ response.close();
+ throw new IOException("HTTP status " + code);
+ }
+ }
+ }
+ };
+ }
+
+}
diff --git a/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java b/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java
index aa9bfe8c..9b573fb2 100644
--- a/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java
+++ b/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java
@@ -38,11 +38,6 @@ public class ChaturbateModel extends AbstractModel {
this.site = site;
}
- @Override
- public boolean isOnline() throws IOException, ExecutionException, InterruptedException {
- return isOnline(false);
- }
-
@Override
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
StreamInfo info;
diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCams.java b/src/main/java/ctbrec/sites/mfc/MyFreeCams.java
index c8544740..c06c1218 100644
--- a/src/main/java/ctbrec/sites/mfc/MyFreeCams.java
+++ b/src/main/java/ctbrec/sites/mfc/MyFreeCams.java
@@ -55,7 +55,7 @@ public class MyFreeCams extends AbstractSite {
@Override
public String getAffiliateLink() {
- return "";
+ return BASE_URI + "/?baf=8127165";
}
@Override
@@ -93,7 +93,7 @@ public class MyFreeCams extends AbstractSite {
@Override
public String getBuyTokensLink() {
- return "https://www.myfreecams.com/php/purchase.php?request=tokens";
+ return BASE_URI + "/php/purchase.php?request=tokens";
}
@Override
@@ -149,7 +149,7 @@ public class MyFreeCams extends AbstractSite {
layout.add(password, 1, 1);
Button createAccount = new Button("Create new Account");
- createAccount.setOnAction((e) -> DesktopIntergation.open(BASE_URI + "/php/signup.php?request=register"));
+ createAccount.setOnAction((e) -> DesktopIntergation.open(getAffiliateLink()));
layout.add(createAccount, 1, 2);
GridPane.setColumnSpan(createAccount, 2);
GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java b/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java
index 188f2378..17191f65 100644
--- a/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java
+++ b/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java
@@ -57,6 +57,7 @@ public class MyFreeCamsClient {
private String ctxenc;
private String chatToken;
private int sessionId;
+ private long heartBeat;
private EvictingQueue receivedTextHistory = EvictingQueue.create(10000);
@@ -135,6 +136,7 @@ public class MyFreeCamsClient {
// TODO find out, what the values in the json message mean, at the moment we hust send 0s, which seems to work, too
// webSocket.send("1 0 0 81 0 %7B%22err%22%3A0%2C%22start%22%3A1540159843072%2C%22stop%22%3A1540159844121%2C%22a%22%3A6392%2C%22time%22%3A1540159844%2C%22key%22%3A%228da80f985c9db390809713dac71df297%22%2C%22cid%22%3A%22c504d684%22%2C%22pid%22%3A1%2C%22site%22%3A%22www%22%7D\n");
webSocket.send("1 0 0 81 0 %7B%22err%22%3A0%2C%22start%22%3A0%2C%22stop%22%3A0%2C%22a%22%3A0%2C%22time%22%3A0%2C%22key%22%3A%22%22%2C%22cid%22%3A%22%22%2C%22pid%22%3A1%2C%22site%22%3A%22www%22%7D\n");
+ heartBeat = System.currentTimeMillis();
startKeepAlive(webSocket);
} catch (IOException e) {
e.printStackTrace();
@@ -165,6 +167,7 @@ public class MyFreeCamsClient {
@Override
public void onMessage(WebSocket webSocket, String text) {
super.onMessage(webSocket, text);
+ heartBeat = System.currentTimeMillis();
receivedTextHistory.add(text);
msgBuffer.append(text);
Message message;
@@ -469,6 +472,14 @@ public class MyFreeCamsClient {
LOG.trace("--> NULL to keep the connection alive");
try {
ws.send("0 0 0 0 0 -\n");
+
+ long millisSinceLastMessage = System.currentTimeMillis() - heartBeat;
+ if(millisSinceLastMessage > TimeUnit.MINUTES.toMillis(2)) {
+ LOG.info("No message since 2 mins. Restarting websocket");
+ ws.close(1000, "");
+ MyFreeCamsClient.this.ws = null;
+ }
+
Thread.sleep(TimeUnit.SECONDS.toMillis(15));
} catch (Exception e) {
e.printStackTrace();
@@ -484,7 +495,7 @@ public class MyFreeCamsClient {
lock.lock();
try {
for (SessionState state : sessionStates.values()) {
- if(Objects.equals(state.getNm(), model.getName())) {
+ if(Objects.equals(state.getNm(), model.getName()) || Objects.equals(model.getUid(), state.getUid())) {
model.update(state, getStreamUrl(state));
return;
}
diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java b/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java
index 36a24da6..db794ae2 100644
--- a/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java
+++ b/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java
@@ -23,6 +23,8 @@ import com.iheartradio.m3u8.PlaylistParser;
import com.iheartradio.m3u8.data.MasterPlaylist;
import com.iheartradio.m3u8.data.Playlist;
import com.iheartradio.m3u8.data.PlaylistData;
+import com.squareup.moshi.JsonReader;
+import com.squareup.moshi.JsonWriter;
import ctbrec.AbstractModel;
import ctbrec.recorder.download.StreamSource;
@@ -37,7 +39,7 @@ public class MyFreeCamsModel extends AbstractModel {
private static final transient Logger LOG = LoggerFactory.getLogger(MyFreeCamsModel.class);
- private int uid;
+ private int uid = -1; // undefined
private String hlsUrl;
private double camScore;
private int viewerCount;
@@ -207,7 +209,17 @@ public class MyFreeCamsModel extends AbstractModel {
this.state = state;
}
+ @Override
+ public void setName(String name) {
+ if(getName() != null && name != null && !getName().equals(name)) {
+ LOG.debug("Model name changed {} -> {}", getName(), name);
+ }
+ super.setName(name);
+ }
+
public void update(SessionState state, String streamUrl) {
+ uid = Integer.parseInt(state.getUid().toString());
+ setName(state.getNm());
setCamScore(state.getM().getCamscore());
setState(State.of(state.getVs()));
setStreamUrl(streamUrl);
@@ -308,4 +320,15 @@ public class MyFreeCamsModel extends AbstractModel {
public Site getSite() {
return site;
}
+
+ @Override
+ public void readSiteSpecificData(JsonReader reader) throws IOException {
+ reader.nextName();
+ uid = reader.nextInt();
+ }
+
+ @Override
+ public void writeSiteSpecificData(JsonWriter writer) throws IOException {
+ writer.name("uid").value(uid);
+ }
}
diff --git a/src/main/java/ctbrec/ui/CamrecApplication.java b/src/main/java/ctbrec/ui/CamrecApplication.java
index acb92508..90be7e79 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.cam4.Cam4;
+import ctbrec.sites.camsoda.Camsoda;
import ctbrec.sites.chaturbate.Chaturbate;
import ctbrec.sites.mfc.MyFreeCams;
import javafx.application.Application;
@@ -61,6 +62,7 @@ public class CamrecApplication extends Application {
public void start(Stage primaryStage) throws Exception {
sites.add(new Chaturbate());
sites.add(new MyFreeCams());
+ sites.add(new Camsoda());
sites.add(new Cam4());
loadConfig();
createHttpClient();
@@ -72,9 +74,6 @@ public class CamrecApplication extends Application {
try {
site.setRecorder(recorder);
site.init();
- if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) {
- site.login();
- }
} catch(Exception e) {
LOG.error("Error while initializing site {}", site.getName(), e);
}
diff --git a/src/main/java/ctbrec/ui/JavaFxModel.java b/src/main/java/ctbrec/ui/JavaFxModel.java
index 1ede0c64..a1c9d399 100644
--- a/src/main/java/ctbrec/ui/JavaFxModel.java
+++ b/src/main/java/ctbrec/ui/JavaFxModel.java
@@ -2,11 +2,12 @@ package ctbrec.ui;
import java.io.IOException;
import java.util.List;
-import java.util.Objects;
import java.util.concurrent.ExecutionException;
import com.iheartradio.m3u8.ParseException;
import com.iheartradio.m3u8.PlaylistException;
+import com.squareup.moshi.JsonReader;
+import com.squareup.moshi.JsonWriter;
import ctbrec.AbstractModel;
import ctbrec.Model;
@@ -20,14 +21,13 @@ import javafx.beans.property.SimpleBooleanProperty;
*/
public class JavaFxModel extends AbstractModel {
private transient BooleanProperty onlineProperty = new SimpleBooleanProperty();
-
private Model delegate;
public JavaFxModel(Model delegate) {
this.delegate = delegate;
try {
- onlineProperty.set(Objects.equals("public", delegate.getOnlineState(true)));
- } catch (IOException | ExecutionException e) {}
+ onlineProperty.set(delegate.isOnline());
+ } catch (IOException | ExecutionException | InterruptedException e) {}
}
@Override
@@ -147,4 +147,14 @@ public class JavaFxModel extends AbstractModel {
public Site getSite() {
return delegate.getSite();
}
+
+ @Override
+ public void readSiteSpecificData(JsonReader reader) throws IOException {
+ delegate.readSiteSpecificData(reader);
+ }
+
+ @Override
+ public void writeSiteSpecificData(JsonWriter writer) throws IOException {
+ delegate.writeSiteSpecificData(writer);
+ }
}
diff --git a/src/main/java/ctbrec/ui/RecordingsTab.java b/src/main/java/ctbrec/ui/RecordingsTab.java
index fc3acd64..e64184f0 100644
--- a/src/main/java/ctbrec/ui/RecordingsTab.java
+++ b/src/main/java/ctbrec/ui/RecordingsTab.java
@@ -34,7 +34,7 @@ import ctbrec.recorder.Recorder;
import ctbrec.recorder.download.MergedHlsDownload;
import ctbrec.sites.Site;
import javafx.application.Platform;
-import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.ScheduledService;
@@ -47,6 +47,7 @@ import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tab;
+import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
@@ -57,6 +58,7 @@ import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
import javafx.stage.FileChooser;
+import javafx.util.Callback;
import javafx.util.Duration;
public class RecordingsTab extends Tab implements TabSelectionListener {
@@ -99,12 +101,28 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
TableColumn name = new TableColumn<>("Model");
name.setPrefWidth(200);
name.setCellValueFactory(new PropertyValueFactory("modelName"));
- TableColumn date = new TableColumn<>("Date");
+ TableColumn date = new TableColumn<>("Date");
date.setCellValueFactory((cdf) -> {
Instant instant = cdf.getValue().getStartDate();
- ZonedDateTime time = instant.atZone(ZoneId.systemDefault());
- DateTimeFormatter dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM);
- return new SimpleStringProperty(dtf.format(time));
+ return new SimpleObjectProperty(instant);
+ });
+ date.setCellFactory(new Callback, TableCell>() {
+ @Override
+ public TableCell call(TableColumn param) {
+ TableCell cell = new TableCell() {
+ @Override
+ protected void updateItem(Instant instant, boolean empty) {
+ if(empty || instant == null) {
+ setText(null);
+ } else {
+ ZonedDateTime time = instant.atZone(ZoneId.systemDefault());
+ DateTimeFormatter dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM);
+ setText(dtf.format(time));
+ }
+ }
+ };
+ return cell;
+ }
});
date.setPrefWidth(200);
TableColumn status = new TableColumn<>("Status");
diff --git a/src/main/java/ctbrec/ui/SettingsTab.java b/src/main/java/ctbrec/ui/SettingsTab.java
index ae5ce0c2..f0066613 100644
--- a/src/main/java/ctbrec/ui/SettingsTab.java
+++ b/src/main/java/ctbrec/ui/SettingsTab.java
@@ -20,6 +20,7 @@ import javafx.beans.value.ObservableValue;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.scene.Node;
+import javafx.scene.control.Accordion;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
@@ -64,9 +65,11 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private RadioButton recordRemote;
private ToggleGroup recordLocation;
private ProxySettingsPane proxySettingsPane;
+ private ComboBox maxResolution;
private ComboBox splitAfter;
private List sites;
private Label restartLabel;
+ private Accordion credentialsAccordion = new Accordion();
public SettingsTab(List sites) {
this.sites = sites;
@@ -113,14 +116,16 @@ public class SettingsTab extends Tab implements TabSelectionListener {
//right side
rightSide.getChildren().add(createSiteSelectionPanel());
- for (Site site : sites) {
+ rightSide.getChildren().add(credentialsAccordion);
+ for (int i = 0; i < sites.size(); i++) {
+ Site site = sites.get(i);
Node siteConfig = site.getConfigurationGui();
if(siteConfig != null) {
TitledPane pane = new TitledPane(site.getName(), siteConfig);
- pane.setCollapsible(false);
- rightSide.getChildren().add(pane);
+ credentialsAccordion.getPanes().add(pane);
}
}
+ credentialsAccordion.setExpandedPane(credentialsAccordion.getPanes().get(0));
}
private Node createSiteSelectionPanel() {
@@ -227,8 +232,8 @@ public class SettingsTab extends Tab implements TabSelectionListener {
keyDialog.show();
}
});
- GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, CHECKBOX_MARGIN, 0, 0));
- GridPane.setMargin(secureCommunication, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
+ GridPane.setMargin(l, new Insets(4, CHECKBOX_MARGIN, 0, 0));
+ GridPane.setMargin(secureCommunication, new Insets(4, 0, 0, 0));
layout.add(secureCommunication, 1, 3);
TitledPane recordLocation = new TitledPane("Record Location", layout);
@@ -245,6 +250,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
GridPane.setFillWidth(recordingsDirectory, true);
GridPane.setHgrow(recordingsDirectory, Priority.ALWAYS);
GridPane.setColumnSpan(recordingsDirectory, 2);
+ GridPane.setMargin(recordingsDirectory, new Insets(0, 0, 0, CHECKBOX_MARGIN));
layout.add(recordingsDirectory, 1, 0);
recordingsDirectoryButton = createRecordingsBrowseButton();
layout.add(recordingsDirectoryButton, 3, 0);
@@ -255,19 +261,10 @@ public class SettingsTab extends Tab implements TabSelectionListener {
GridPane.setFillWidth(mediaPlayer, true);
GridPane.setHgrow(mediaPlayer, Priority.ALWAYS);
GridPane.setColumnSpan(mediaPlayer, 2);
+ GridPane.setMargin(mediaPlayer, new Insets(0, 0, 0, CHECKBOX_MARGIN));
layout.add(mediaPlayer, 1, 1);
layout.add(createMpvBrowseButton(), 3, 1);
- Label l = new Label("Allow multiple players");
- layout.add(l, 0, 2);
- multiplePlayers.setSelected(!Config.getInstance().getSettings().singlePlayer);
- multiplePlayers.setOnAction((e) -> Config.getInstance().getSettings().singlePlayer = !multiplePlayers.isSelected());
- GridPane.setMargin(recordingsDirectory, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- GridPane.setMargin(mediaPlayer, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- GridPane.setMargin(l, new Insets(3, 0, 0, 0));
- GridPane.setMargin(multiplePlayers, new Insets(3, 0, 0, CHECKBOX_MARGIN));
- layout.add(multiplePlayers, 1, 2);
-
TitledPane locations = new TitledPane("Locations", layout);
locations.setCollapsible(false);
return locations;
@@ -275,8 +272,9 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private Node createGeneralPanel() {
GridPane layout = createGridLayout();
+ int row = 0;
Label l = new Label("Display stream resolution in overview");
- layout.add(l, 0, 0);
+ layout.add(l, 0, row);
loadResolution = new CheckBox();
loadResolution.setSelected(Config.getInstance().getSettings().determineResolution);
loadResolution.setOnAction((e) -> {
@@ -287,18 +285,41 @@ public class SettingsTab extends Tab implements TabSelectionListener {
});
//GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
GridPane.setMargin(loadResolution, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- layout.add(loadResolution, 1, 0);
+ layout.add(loadResolution, 1, row++);
+
+ l = new Label("Allow multiple players");
+ layout.add(l, 0, row);
+ multiplePlayers.setSelected(!Config.getInstance().getSettings().singlePlayer);
+ multiplePlayers.setOnAction((e) -> Config.getInstance().getSettings().singlePlayer = !multiplePlayers.isSelected());
+ GridPane.setMargin(l, new Insets(3, 0, 0, 0));
+ GridPane.setMargin(multiplePlayers, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ layout.add(multiplePlayers, 1, row++);
l = new Label("Manually select stream quality");
- layout.add(l, 0, 1);
+ layout.add(l, 0, row);
chooseStreamQuality.setSelected(Config.getInstance().getSettings().chooseStreamQuality);
chooseStreamQuality.setOnAction((e) -> Config.getInstance().getSettings().chooseStreamQuality = chooseStreamQuality.isSelected());
GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
GridPane.setMargin(chooseStreamQuality, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
- layout.add(chooseStreamQuality, 1, 1);
+ layout.add(chooseStreamQuality, 1, row++);
+
+ l = new Label("Maximum resolution (0 = unlimited)");
+ layout.add(l, 0, row);
+ List resolutionOptions = new ArrayList<>();
+ resolutionOptions.add(1080);
+ resolutionOptions.add(720);
+ resolutionOptions.add(600);
+ resolutionOptions.add(480);
+ resolutionOptions.add(0);
+ maxResolution = new ComboBox<>(new ObservableListWrapper<>(resolutionOptions));
+ setMaxResolutionValue();
+ maxResolution.setOnAction((e) -> Config.getInstance().getSettings().maximumResolution = maxResolution.getSelectionModel().getSelectedItem());
+ layout.add(maxResolution, 1, row++);
+ GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
+ GridPane.setMargin(maxResolution, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
l = new Label("Split recordings after (minutes)");
- layout.add(l, 0, 2);
+ layout.add(l, 0, row);
List options = new ArrayList<>();
options.add(new SplitAfterOption("disabled", 0));
options.add(new SplitAfterOption("10 min", 10 * 60));
@@ -307,11 +328,12 @@ public class SettingsTab extends Tab implements TabSelectionListener {
options.add(new SplitAfterOption("30 min", 30 * 60));
options.add(new SplitAfterOption("60 min", 60 * 60));
splitAfter = new ComboBox<>(new ObservableListWrapper<>(options));
- layout.add(splitAfter, 1, 2);
+ layout.add(splitAfter, 1, row++);
setSplitAfterValue();
splitAfter.setOnAction((e) -> Config.getInstance().getSettings().splitRecordings = splitAfter.getSelectionModel().getSelectedItem().getValue());
- GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
- GridPane.setMargin(splitAfter, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ GridPane.setMargin(l, new Insets(0, 0, 0, 0));
+ GridPane.setMargin(splitAfter, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ maxResolution.prefWidthProperty().bind(splitAfter.widthProperty());
TitledPane general = new TitledPane("General", layout);
general.setCollapsible(false);
@@ -327,6 +349,15 @@ public class SettingsTab extends Tab implements TabSelectionListener {
}
}
+ private void setMaxResolutionValue() {
+ int value = Config.getInstance().getSettings().maximumResolution;
+ for (Integer option : maxResolution.getItems()) {
+ if(option == value) {
+ maxResolution.getSelectionModel().select(option);
+ }
+ }
+ }
+
void showRestartRequired() {
restartLabel.setVisible(true);
}
@@ -346,6 +377,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
recordingsDirectory.setDisable(!local);
recordingsDirectoryButton.setDisable(!local);
splitAfter.setDisable(!local);
+ maxResolution.setDisable(!local);
}
private ChangeListener super Boolean> createRecordingsDirectoryFocusListener() {
diff --git a/src/main/java/ctbrec/ui/ThumbCell.java b/src/main/java/ctbrec/ui/ThumbCell.java
index e7306d3b..f579a948 100644
--- a/src/main/java/ctbrec/ui/ThumbCell.java
+++ b/src/main/java/ctbrec/ui/ThumbCell.java
@@ -1,5 +1,6 @@
package ctbrec.ui;
+import java.io.EOFException;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
@@ -207,10 +208,16 @@ public class ThumbCell extends StackPane {
LOG.trace("Removing invalid resolution value for {}", model.getName());
model.invalidateCacheEntries();
}
-
+
Thread.sleep(500);
- } catch (ExecutionException | IOException | InterruptedException e1) {
+ } catch (IOException | InterruptedException e1) {
LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e1);
+ } catch(ExecutionException e) {
+ if(e.getCause() instanceof EOFException) {
+ LOG.warn("Couldn't update resolution tag for model {}. Playlist empty", model.getName());
+ } else {
+ LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e);
+ }
} finally {
ThumbOverviewTab.resolutionProcessing.remove(model);
}
@@ -485,13 +492,13 @@ public class ThumbCell extends StackPane {
nameBackground.setWidth(w);
nameBackground.setHeight(20);
topicBackground.setWidth(w);
- topicBackground.setHeight(h-nameBackground.getHeight());
- topic.prefHeight(h-25);
- topic.maxHeight(h-25);
+ topicBackground.setHeight(getHeight()-nameBackground.getHeight());
+ topic.prefHeight(getHeight()-25);
+ topic.maxHeight(getHeight()-25);
int margin = 4;
topic.maxWidth(w-margin*2);
topic.setWrappingWidth(w-margin*2);
selectionOverlay.setWidth(w);
- selectionOverlay.setHeight(h);
+ selectionOverlay.setHeight(getHeight());
}
}
diff --git a/src/main/java/ctbrec/ui/TokenLabel.java b/src/main/java/ctbrec/ui/TokenLabel.java
index a3123a34..30471ab8 100644
--- a/src/main/java/ctbrec/ui/TokenLabel.java
+++ b/src/main/java/ctbrec/ui/TokenLabel.java
@@ -13,6 +13,7 @@ import ctbrec.sites.Site;
import javafx.application.Platform;
import javafx.concurrent.Task;
import javafx.scene.control.Label;
+import javafx.scene.control.Tooltip;
public class TokenLabel extends Label {
@@ -26,10 +27,10 @@ public class TokenLabel extends Label {
CamrecApplication.bus.register(new Object() {
@Subscribe
public void tokensUpdates(Map e) {
- if(Objects.equals("tokens", e.get("event"))) {
+ if (Objects.equals("tokens", e.get("event"))) {
tokens = (int) e.get("amount");
updateText();
- } else if(Objects.equals("tokens.sent", e.get("event"))) {
+ } else if (Objects.equals("tokens.sent", e.get("event"))) {
int _tokens = (int) e.get("amount");
tokens -= _tokens;
updateText();
@@ -70,7 +71,10 @@ public class TokenLabel extends Label {
update(tokens);
} catch (InterruptedException | ExecutionException e) {
LOG.error("Couldn't retrieve account balance", e);
- Platform.runLater(() -> setText("Tokens: error"));
+ Platform.runLater(() -> {
+ setText("Tokens: error");
+ setTooltip(new Tooltip(e.getMessage()));
+ });
}
}
};
diff --git a/src/main/resources/image_not_found.png b/src/main/resources/image_not_found.png
new file mode 100644
index 00000000..7ab180c6
Binary files /dev/null and b/src/main/resources/image_not_found.png differ
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
index 6c731cec..4642d547 100644
--- a/src/main/resources/logback.xml
+++ b/src/main/resources/logback.xml
@@ -41,7 +41,7 @@
-
+