commit 1ab902892d94321062bd9e2463574d8a89e47730
Author: 0xboobface <0xboobface@gmail.com>
Date: Sun Jul 1 17:38:53 2018 +0200
initial import
diff --git a/.classpath b/.classpath
new file mode 100644
index 00000000..de8cd3cb
--- /dev/null
+++ b/.classpath
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..bd2c5fee
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/bin/
+/target/
+*~
+*.bak
+/ctbrec.log
+/ctbrec-tunnel.sh
+/jre/
diff --git a/.project b/.project
new file mode 100644
index 00000000..93146da6
--- /dev/null
+++ b/.project
@@ -0,0 +1,23 @@
+
+
+ ctbrec
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.m2e.core.maven2Builder
+
+
+
+
+
+ org.eclipse.m2e.core.maven2Nature
+ org.eclipse.jdt.core.javanature
+
+
diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs
new file mode 100644
index 00000000..29abf999
--- /dev/null
+++ b/.settings/org.eclipse.core.resources.prefs
@@ -0,0 +1,6 @@
+eclipse.preferences.version=1
+encoding//src/main/java=UTF-8
+encoding//src/main/resources=UTF-8
+encoding//src/test/java=UTF-8
+encoding//src/test/resources=UTF-8
+encoding/=UTF-8
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
new file mode 100644
index 00000000..7c5d29a4
--- /dev/null
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -0,0 +1,12 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=10
+org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
+org.eclipse.jdt.core.compiler.compliance=10
+org.eclipse.jdt.core.compiler.debug.lineNumber=generate
+org.eclipse.jdt.core.compiler.debug.localVariable=generate
+org.eclipse.jdt.core.compiler.debug.sourceFile=generate
+org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
+org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+org.eclipse.jdt.core.compiler.source=10
diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs
new file mode 100644
index 00000000..f897a7f1
--- /dev/null
+++ b/.settings/org.eclipse.m2e.core.prefs
@@ -0,0 +1,4 @@
+activeProfiles=
+eclipse.preferences.version=1
+resolveWorkspaceProjects=true
+version=1
diff --git a/ctbrec.sh b/ctbrec.sh
new file mode 100755
index 00000000..8b9116d2
--- /dev/null
+++ b/ctbrec.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+#JAVA=/opt/jdk-10.0.1/bin/java
+JAVA=java
+
+$JAVA -version
+$JAVA -Djdk.gtk.version=3 -cp ctbrec-1.0.0-final.jar ctbrec.ui.Launcher
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 00000000..c9407c58
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,158 @@
+
+
+ 4.0.0
+ ctbrec
+ ctbrec
+ 1.0.0
+
+
+ UTF-8
+ 1.8
+ 1.8
+ ${project.artifactId}-${project.version}-final
+
+
+
+
+
+ maven-assembly-plugin
+ 3.1.0
+
+
+ assembly
+ package
+
+ single
+
+
+ ${name.final}
+ false
+
+ jar-with-dependencies
+
+
+
+
+ zip
+ verify
+
+ single
+
+
+
+ src/assembly/win64.xml
+ src/assembly/win64-jre.xml
+ src/assembly/linux.xml
+
+
+
+
+
+
+ com.akathist.maven.plugins.launch4j
+ launch4j-maven-plugin
+ 1.7.22
+
+
+ l4j-clui
+ package
+
+ launch4j
+
+
+ gui
+ target/ctbrec.exe
+ ${name.final}.jar
+ true
+ src/main/resources/icon.ico
+ ctbrec
+
+ ctbrec.ui.Launcher
+ false
+ anything
+
+
+ jre
+ true
+ 1.8.0
+
+
+ ${project.version}.0
+ ${project.version}
+ Recorder for Charturbate streams
+ 2018 blaueelise
+ ${project.version}.0
+ ${project.version}
+ CTB Recorder
+ ctbrec
+ ctbrec.exe
+
+
+ src/main/resources/splash.bmp
+ true
+ 60
+ true
+
+
+
+
+
+
+
+
+
+
+ org.jsoup
+ jsoup
+ 1.10.3
+
+
+ com.squareup.okhttp3
+ okhttp
+ 3.10.0
+
+
+ com.squareup.moshi
+ moshi
+ 1.5.0
+
+
+ org.slf4j
+ slf4j-api
+ 1.7.25
+
+
+ ch.qos.logback
+ logback-classic
+ 1.2.3
+ runtime
+
+
+ org.mozilla
+ rhino
+ 1.7.7.1
+
+
+ org.eclipse.jetty
+ jetty-server
+ 9.3.8.v20160314
+
+
+ org.eclipse.jetty
+ jetty-servlet
+ 9.3.8.v20160314
+
+
+ com.iheartradio.m3u8
+ open-m3u8
+ 0.2.4
+
+
+ org.jcodec
+ jcodec
+ 0.2.3
+
+
+
diff --git a/server.bat b/server.bat
new file mode 100755
index 00000000..815bd84a
--- /dev/null
+++ b/server.bat
@@ -0,0 +1 @@
+java -cp ${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer
\ No newline at end of file
diff --git a/server.sh b/server.sh
new file mode 100755
index 00000000..5f1126cd
--- /dev/null
+++ b/server.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+JAVA=java
+$JAVA -cp ${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer
diff --git a/src/assembly/linux.xml b/src/assembly/linux.xml
new file mode 100644
index 00000000..26c75a93
--- /dev/null
+++ b/src/assembly/linux.xml
@@ -0,0 +1,24 @@
+
+
+ linux
+
+ zip
+
+ false
+
+
+ ${project.basedir}/ctbrec.sh
+ ctbrec
+ true
+
+
+ ${project.basedir}/server.sh
+ ctbrec
+ true
+
+
+ ${project.build.directory}/${name.final}.jar
+ ctbrec
+
+
+
diff --git a/src/assembly/win64-jre.xml b/src/assembly/win64-jre.xml
new file mode 100644
index 00000000..4f8b80ac
--- /dev/null
+++ b/src/assembly/win64-jre.xml
@@ -0,0 +1,33 @@
+
+
+ win64-jre
+
+ zip
+
+ false
+
+
+ ${project.build.directory}/ctbrec.exe
+ ctbrec
+
+
+ ${project.basedir}/server.bat
+ ctbrec
+ true
+
+
+ ${project.build.directory}/${name.final}.jar
+ ctbrec
+
+
+
+
+ jre
+
+ **/*
+
+ ctbrec/jre
+ false
+
+
+
diff --git a/src/assembly/win64.xml b/src/assembly/win64.xml
new file mode 100644
index 00000000..62bb8a6f
--- /dev/null
+++ b/src/assembly/win64.xml
@@ -0,0 +1,23 @@
+
+
+ win64
+
+ zip
+
+ false
+
+
+ ${project.build.directory}/ctbrec.exe
+ ctbrec
+
+
+ ${project.basedir}/server.bat
+ ctbrec
+ true
+
+
+ ${project.build.directory}/${name.final}.jar
+ ctbrec
+
+
+
diff --git a/src/main/java/ctbrec/Config.java b/src/main/java/ctbrec/Config.java
new file mode 100644
index 00000000..5c9db23d
--- /dev/null
+++ b/src/main/java/ctbrec/Config.java
@@ -0,0 +1,82 @@
+package ctbrec;
+
+import static java.nio.file.StandardOpenOption.CREATE;
+import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
+import static java.nio.file.StandardOpenOption.WRITE;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.squareup.moshi.JsonAdapter;
+import com.squareup.moshi.Moshi;
+
+import ctbrec.recorder.OS;
+import ctbrec.ui.AutosizeAlert;
+import javafx.scene.control.Alert;
+import okio.Buffer;
+import okio.BufferedSource;
+
+public class Config {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(Config.class);
+
+ private static Config instance = new Config();
+ private Settings settings;
+ private String filename;
+
+ private Config() {
+ if(System.getProperty("ctbrec.config") != null) {
+ filename = System.getProperty("ctbrec.config");
+ } else {
+ filename = "settings.json";
+ }
+ load();
+ }
+
+ private void load() {
+ Moshi moshi = new Moshi.Builder().build();
+ JsonAdapter adapter = moshi.adapter(Settings.class);
+ File configDir = OS.getConfigDir();
+ File configFile = new File(configDir, filename);
+ LOG.debug("Loading config from {}", configFile.getAbsolutePath());
+ if(configFile.exists()) {
+ try(FileInputStream fin = new FileInputStream(configFile); Buffer buffer = new Buffer()) {
+ BufferedSource source = buffer.readFrom(fin);
+ settings = adapter.fromJson(source);
+ } catch(Exception e) {
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Whoopsie");
+ alert.setContentText("Couldn't load settings.");
+ alert.showAndWait();
+ System.exit(1);
+ }
+ } else {
+ LOG.error("Config file does not exist. Falling back to default values.");
+ settings = OS.getDefaultSettings();
+ }
+ }
+
+ public static Config getInstance() {
+ return instance;
+ }
+
+ public Settings getSettings() {
+ return settings;
+ }
+
+ public void save() throws IOException {
+ Moshi moshi = new Moshi.Builder().build();
+ JsonAdapter adapter = moshi.adapter(Settings.class);
+ String json = adapter.toJson(settings);
+ File configDir = OS.getConfigDir();
+ File configFile = new File(configDir, filename);
+ LOG.debug("Saving config to {}", configFile.getAbsolutePath());
+ Files.createDirectories(configDir.toPath());
+ Files.write(configFile.toPath(), json.getBytes("utf-8"), CREATE, WRITE, TRUNCATE_EXISTING);
+ }
+}
diff --git a/src/main/java/ctbrec/HttpClient.java b/src/main/java/ctbrec/HttpClient.java
new file mode 100644
index 00000000..91f379a6
--- /dev/null
+++ b/src/main/java/ctbrec/HttpClient.java
@@ -0,0 +1,125 @@
+package ctbrec;
+
+import java.io.IOException;
+import java.util.NoSuchElementException;
+import java.util.concurrent.TimeUnit;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.ui.CookieJarImpl;
+import ctbrec.ui.HtmlParser;
+import ctbrec.ui.Launcher;
+import okhttp3.Cookie;
+import okhttp3.FormBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+public class HttpClient {
+ private static final transient Logger LOG = LoggerFactory.getLogger(HttpClient.class);
+ private static HttpClient instance = new HttpClient();
+
+ private OkHttpClient client;
+ private CookieJarImpl cookieJar = new CookieJarImpl();
+ private boolean loggedIn = false;
+ private int loginTries = 0;
+ private String token;
+
+ private HttpClient() {
+ client = new OkHttpClient.Builder()
+ .cookieJar(cookieJar)
+ .connectTimeout(Config.getInstance().getSettings().httpTimeout, TimeUnit.SECONDS)
+ .readTimeout(Config.getInstance().getSettings().httpTimeout, TimeUnit.SECONDS)
+ .addInterceptor(new LoggingInterceptor())
+ .build();
+ }
+
+ public static HttpClient getInstance() {
+ return instance;
+ }
+
+ public Response execute(Request request) throws IOException {
+ Response resp = execute(request, false);
+ return resp;
+ }
+
+ private void extractCsrfToken(Request request) {
+ try {
+ Cookie csrfToken = cookieJar.getCookie(request.url(), "csrftoken");
+ token = csrfToken.value();
+ } catch(NoSuchElementException e) {
+ LOG.trace("CSRF token not found in cookies");
+ }
+ }
+
+ public Response execute(Request req, boolean requiresLogin) throws IOException {
+ if(requiresLogin && !loggedIn) {
+ boolean loginSuccessful = login();
+ if(!loginSuccessful) {
+ throw new IOException("403 Unauthorized");
+ }
+ }
+ Response resp = client.newCall(req).execute();
+ extractCsrfToken(req);
+ return resp;
+ }
+
+ public boolean login() throws IOException {
+ try {
+ Request login = new Request.Builder()
+ .url(Launcher.BASE_URI + "/auth/login/")
+ .build();
+ Response response = client.newCall(login).execute();
+ String content = response.body().string();
+ token = HtmlParser.getTag(content, "input[name=csrfmiddlewaretoken]").attr("value");
+ LOG.debug("csrf token is {}", token);
+
+ RequestBody body = new FormBody.Builder()
+ .add("username", Config.getInstance().getSettings().username)
+ .add("password", Config.getInstance().getSettings().password)
+ .add("next", "")
+ .add("csrfmiddlewaretoken", token)
+ .build();
+ login = new Request.Builder()
+ .url(Launcher.BASE_URI + "/auth/login/")
+ .header("Referer", Launcher.BASE_URI + "/auth/login/")
+ .post(body)
+ .build();
+
+ response = client.newCall(login).execute();
+ if(response.isSuccessful()) {
+ content = response.body().string();
+ if(content.contains("Login, Chaturbate login")) {
+ loggedIn = false;
+ } else {
+ loggedIn = true;
+ extractCsrfToken(login);
+ }
+ } else {
+ if(loginTries++ < 3) {
+ login();
+ } else {
+ throw new IOException("Login failed: " + response.code() + " " + response.message());
+ }
+ }
+ response.close();
+ } finally {
+ loginTries = 0;
+ }
+ return loggedIn;
+ }
+
+ public String getToken() throws IOException {
+ if(token == null) {
+ login();
+ }
+ return token;
+ }
+
+ public void shutdown() {
+ client.connectionPool().evictAll();
+ client.dispatcher().executorService().shutdown();
+ }
+}
diff --git a/src/main/java/ctbrec/InstantJsonAdapter.java b/src/main/java/ctbrec/InstantJsonAdapter.java
new file mode 100644
index 00000000..ff291844
--- /dev/null
+++ b/src/main/java/ctbrec/InstantJsonAdapter.java
@@ -0,0 +1,21 @@
+package ctbrec;
+
+import java.io.IOException;
+import java.time.Instant;
+
+import com.squareup.moshi.JsonAdapter;
+import com.squareup.moshi.JsonReader;
+import com.squareup.moshi.JsonWriter;
+
+public class InstantJsonAdapter extends JsonAdapter {
+ @Override
+ public Instant fromJson(JsonReader reader) throws IOException {
+ long timeInEpochMillis = reader.nextLong();
+ return Instant.ofEpochMilli(timeInEpochMillis);
+ }
+
+ @Override
+ public void toJson(JsonWriter writer, Instant time) throws IOException {
+ writer.value(time.toEpochMilli());
+ }
+}
diff --git a/src/main/java/ctbrec/LoggingInterceptor.java b/src/main/java/ctbrec/LoggingInterceptor.java
new file mode 100644
index 00000000..83c0b8db
--- /dev/null
+++ b/src/main/java/ctbrec/LoggingInterceptor.java
@@ -0,0 +1,29 @@
+package ctbrec;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import okhttp3.Interceptor;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class LoggingInterceptor implements Interceptor {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(LoggingInterceptor.class);
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ long t1 = System.nanoTime();
+ Request request = chain.request();
+ LOG.debug("OkHttp Sending request {} on {}\n{}", request.url(), chain.connection(), request.headers());
+ if(request.method().equalsIgnoreCase("POST")) {
+ LOG.debug("Body: {}", request.body().toString());
+ }
+ Response response = chain.proceed(request);
+ long t2 = System.nanoTime();
+ LOG.debug("OkHttp Received response for {} in {}\n{}", response.request().url(), (t2 - t1) / 1e6d, response.headers());
+ return response;
+ }
+}
diff --git a/src/main/java/ctbrec/Model.java b/src/main/java/ctbrec/Model.java
new file mode 100644
index 00000000..7e4db43b
--- /dev/null
+++ b/src/main/java/ctbrec/Model.java
@@ -0,0 +1,103 @@
+package ctbrec;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class Model {
+ private String url;
+ private String name;
+ private String preview;
+ private String description;
+ private List tags = new ArrayList<>();
+ private boolean online = false;
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getPreview() {
+ return preview;
+ }
+
+ public void setPreview(String preview) {
+ this.preview = preview;
+ }
+
+ public List getTags() {
+ return tags;
+ }
+
+ public void setTags(List tags) {
+ this.tags = tags;
+ }
+
+ public boolean isOnline() {
+ return online;
+ }
+
+ public void setOnline(boolean online) {
+ this.online = online;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((getName() == null) ? 0 : getName().hashCode());
+ result = prime * result + ((getUrl() == null) ? 0 : getUrl().hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (!(obj instanceof Model))
+ return false;
+ Model other = (Model) obj;
+ if (getName() == null) {
+ if (other.getName() != null)
+ return false;
+ } else if (!getName().equals(other.getName()))
+ return false;
+ if (getUrl() == null) {
+ if (other.getUrl() != null)
+ return false;
+ } else if (!getUrl().equals(other.getUrl()))
+ return false;
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ public static void main(String[] args) {
+ Model model = new Model();
+ model.name = "A";
+ model.url = "url";
+ }
+}
diff --git a/src/main/java/ctbrec/ModelParser.java b/src/main/java/ctbrec/ModelParser.java
new file mode 100644
index 00000000..6709e0f4
--- /dev/null
+++ b/src/main/java/ctbrec/ModelParser.java
@@ -0,0 +1,42 @@
+package ctbrec;
+
+import static ctbrec.ui.Launcher.BASE_URI;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.ui.HtmlParser;
+
+public class ModelParser {
+ private static final transient Logger LOG = LoggerFactory.getLogger(ModelParser.class);
+
+ public static List parseModels(String html) {
+ List models = new ArrayList<>();
+ Elements cells = HtmlParser.getTags(html, "ul.list > li");
+ for (Element cell : cells) {
+ String cellHtml = cell.html();
+ try {
+ Model model = new Model();
+ model.setName(HtmlParser.getText(cellHtml, "div.title > a").trim());
+ model.setPreview(HtmlParser.getTag(cellHtml, "a img").attr("src"));
+ model.setUrl(BASE_URI + HtmlParser.getTag(cellHtml, "a").attr("href"));
+ model.setDescription(HtmlParser.getText(cellHtml, "div.details ul.subject"));
+ Elements tags = HtmlParser.getTags(cellHtml, "div.details ul.subject li a");
+ if(tags != null) {
+ for (Element tag : tags) {
+ model.getTags().add(tag.text());
+ }
+ }
+ models.add(model);
+ } catch (Exception e) {
+ LOG.error("Parsing of model details failed: {}", cellHtml, e);
+ }
+ }
+ return models;
+ }
+}
diff --git a/src/main/java/ctbrec/Recording.java b/src/main/java/ctbrec/Recording.java
new file mode 100644
index 00000000..0881afec
--- /dev/null
+++ b/src/main/java/ctbrec/Recording.java
@@ -0,0 +1,119 @@
+package ctbrec;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.util.Date;
+
+public class Recording {
+ private String modelName;
+ private Instant startDate;
+ private String path;
+ private boolean hasPlaylist;
+ private STATUS status;
+ private int generatingPlaylistProgress = -1;
+ private long sizeInByte;
+
+ public static enum STATUS {
+ RECORDING,
+ GENERATING_PLAYLIST,
+ FINISHED,
+ DOWNLOADING,
+ MERGING
+ }
+
+ public Recording() {}
+
+ public Recording(String path) throws ParseException {
+ this.path = path;
+ this.modelName = path.substring(0, path.indexOf("/"));
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm");
+ Date date = sdf.parse(path.substring(path.indexOf('/')+1));
+ startDate = Instant.ofEpochMilli(date.getTime());
+ }
+
+ public String getModelName() {
+ return modelName;
+ }
+
+ public void setModelName(String modelName) {
+ this.modelName = modelName;
+ }
+
+ public Instant getStartDate() {
+ return startDate;
+ }
+
+ public void setStartDate(Instant startDate) {
+ this.startDate = startDate;
+ }
+
+ public STATUS getStatus() {
+ return status;
+ }
+
+ public void setStatus(STATUS status) {
+ this.status = status;
+ }
+
+ public int getProgress() {
+ return this.generatingPlaylistProgress;
+ }
+
+ public void setProgress(int progress) {
+ this.generatingPlaylistProgress = progress;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ public boolean hasPlaylist() {
+ return hasPlaylist;
+ }
+
+ public void setHasPlaylist(boolean hasPlaylist) {
+ this.hasPlaylist = hasPlaylist;
+ }
+
+ public long getSizeInByte() {
+ return sizeInByte;
+ }
+
+ public void setSizeInByte(long sizeInByte) {
+ this.sizeInByte = sizeInByte;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((modelName == null) ? 0 : modelName.hashCode());
+ result = prime * result + ((startDate == null) ? 0 : startDate.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ Recording other = (Recording) obj;
+ if (getModelName() == null) {
+ if (other.getModelName() != null)
+ return false;
+ } else if (!getModelName().equals(other.getModelName()))
+ return false;
+ if (getStartDate() == null) {
+ if (other.getStartDate() != null)
+ return false;
+ } else if (!getStartDate().equals(other.getStartDate()))
+ return false;
+ return true;
+ }
+}
diff --git a/src/main/java/ctbrec/Settings.java b/src/main/java/ctbrec/Settings.java
new file mode 100644
index 00000000..f826adb5
--- /dev/null
+++ b/src/main/java/ctbrec/Settings.java
@@ -0,0 +1,18 @@
+package ctbrec;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+public class Settings {
+ public boolean localRecording = true;
+ public int httpPort = 8080;
+ public int httpTimeout = 30;
+ public String httpServer = "localhost";
+ public String recordingsDir = System.getProperty("user.home") + File.separator + "ctbrec";
+ public String mediaPlayer = "/usr/bin/mpv";
+ public String username = "";
+ public String password = "";
+ public String lastDownloadDir = "";
+ public List models = new ArrayList();
+}
diff --git a/src/main/java/ctbrec/recorder/Chaturbate.java b/src/main/java/ctbrec/recorder/Chaturbate.java
new file mode 100644
index 00000000..6c9ca18b
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/Chaturbate.java
@@ -0,0 +1,37 @@
+package ctbrec.recorder;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.squareup.moshi.JsonAdapter;
+import com.squareup.moshi.Moshi;
+
+import ctbrec.HttpClient;
+import ctbrec.Model;
+import okhttp3.FormBody;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+
+public class Chaturbate {
+ private static final transient Logger LOG = LoggerFactory.getLogger(Chaturbate.class);
+
+ public static StreamInfo getStreamInfo(Model model, HttpClient client) throws IOException {
+ RequestBody body = new FormBody.Builder()
+ .add("room_slug", model.getName())
+ .add("bandwidth", "high")
+ .build();
+ Request req = new Request.Builder()
+ .url("https://chaturbate.com/get_edge_hls_url_ajax/")
+ .post(body)
+ .addHeader("X-Requested-With", "XMLHttpRequest")
+ .build();
+ String content = client.execute(req).body().string();
+ LOG.debug("Raw stream info: {}", content);
+ Moshi moshi = new Moshi.Builder().build();
+ JsonAdapter adapter = moshi.adapter(StreamInfo.class);
+ StreamInfo streamInfo = adapter.fromJson(content);
+ return streamInfo;
+ }
+}
diff --git a/src/main/java/ctbrec/recorder/LocalRecorder.java b/src/main/java/ctbrec/recorder/LocalRecorder.java
new file mode 100644
index 00000000..49fabdcb
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/LocalRecorder.java
@@ -0,0 +1,466 @@
+package ctbrec.recorder;
+import static ctbrec.Recording.STATUS.FINISHED;
+import static ctbrec.Recording.STATUS.GENERATING_PLAYLIST;
+import static ctbrec.Recording.STATUS.RECORDING;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.iheartradio.m3u8.ParseException;
+import com.iheartradio.m3u8.PlaylistException;
+
+import ctbrec.Config;
+import ctbrec.HttpClient;
+import ctbrec.Model;
+import ctbrec.Recording;
+import ctbrec.recorder.download.Download;
+import ctbrec.recorder.download.HlsDownload;
+import ctbrec.recorder.server.PlaylistGenerator;
+import ctbrec.recorder.server.PlaylistGenerator.InvalidPlaylistException;
+
+public class LocalRecorder implements Recorder {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(LocalRecorder.class);
+
+ private List models = Collections.synchronizedList(new ArrayList<>());
+ private Lock lock = new ReentrantLock();
+ private Map recordingProcesses = Collections.synchronizedMap(new HashMap<>());
+ private Map playlistGenerators = new HashMap<>();
+ private Config config;
+ private ProcessMonitor processMonitor;
+ private OnlineMonitor onlineMonitor;
+ private PlaylistGeneratorTrigger playlistGenTrigger;
+ private HttpClient client = HttpClient.getInstance();
+ private volatile boolean recording = true;
+
+ public LocalRecorder(Config config) {
+ this.config = config;
+ config.getSettings().models.stream().forEach((m) -> {
+ m.setOnline(false);
+ models.add(m);
+ });
+
+ recording = true;
+ processMonitor = new ProcessMonitor();
+ processMonitor.start();
+ onlineMonitor = new OnlineMonitor();
+ onlineMonitor.start();
+ playlistGenTrigger = new PlaylistGeneratorTrigger();
+ playlistGenTrigger.start();
+
+ LOG.debug("Recorder initialized");
+ LOG.debug("Models to record: {}", models);
+ }
+
+ @Override
+ public void startRecording(Model model) throws IOException {
+ lock.lock();
+ if(!models.contains(model)) {
+ LOG.info("Model {} added", model);
+ models.add(model);
+ config.getSettings().models.add(model);
+ onlineMonitor.interrupt();
+ }
+ lock.unlock();
+ }
+
+ @Override
+ public void stopRecording(Model model) throws IOException, InterruptedException {
+ lock.lock();
+ try {
+ if (models.contains(model)) {
+ models.remove(model);
+ config.getSettings().models.remove(model);
+ if(recordingProcesses.containsKey(model)) {
+ stopRecordingProcess(model);
+ }
+ LOG.info("Model {} removed", model);
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void startRecordingProcess(Model model) throws IOException {
+ lock.lock();
+ LOG.debug("Waiting for lock to restart recording for {}", model.getName());
+ try {
+ LOG.debug("Restart recording for model {}", model.getName());
+ if(recordingProcesses.containsKey(model)) {
+ LOG.error("A recording for model {} is already running", model);
+ return;
+ }
+
+ if(!models.contains(model)) {
+ LOG.info("Model {} has been removed. Restarting of recording cancelled.", model);
+ return;
+ }
+
+ Download download = new HlsDownload(client);
+ recordingProcesses.put(model, download);
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ download.start(model, config);
+ } catch (IOException e) {
+ LOG.error("Download failed. Download alive: {}", download.isAlive(), e);
+ }
+ }
+ }.start();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void stopRecordingProcess(Model model) throws IOException, InterruptedException {
+ lock.lock();
+ try {
+ Download download = recordingProcesses.get(model);
+ download.stop();
+ recordingProcesses.remove(model);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public boolean isRecording(Model model) {
+ lock.lock();
+ try {
+ return models.contains(model);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public List getModelsRecording() {
+ return Collections.unmodifiableList(models);
+ }
+
+ @Override
+ public void shutdown() {
+ LOG.info("Shutting down");
+ recording = false;
+ LOG.debug("Stopping monitor threads");
+ onlineMonitor.running = false;
+ processMonitor.running = false;
+ playlistGenTrigger.running = false;
+ LOG.debug("Stopping all recording processes");
+ stopRecordingProcesses();
+ }
+
+ private void stopRecordingProcesses() {
+ lock.lock();
+ try {
+ for (Model model : models) {
+ Download recordingProcess = recordingProcesses.get(model);
+ if(recordingProcess != null) {
+ try {
+ recordingProcess.stop();
+ LOG.debug("Stopped recording for {}", model);
+ } catch (Exception e) {
+ LOG.error("Couldn't stop recording for model {}", model, e);
+ }
+ }
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private boolean checkIfOnline(Model model) throws IOException {
+ StreamInfo streamInfo = Chaturbate.getStreamInfo(model, client);
+ return Objects.equals(streamInfo.room_status, "public");
+ }
+
+ private void tryRestartRecording(Model model) {
+ if(!recording) {
+ // recorder is not in recording state
+ return;
+ }
+
+ try {
+ lock.lock();
+ boolean modelInRecordingList = models.contains(model);
+ boolean online = checkIfOnline(model);
+ if(modelInRecordingList && online) {
+ LOG.info("Restarting recording for model {}", model);
+ recordingProcesses.remove(model);
+ startRecordingProcess(model);
+ }
+ } catch (Exception e) {
+ LOG.error("Couldn't restart recording for model {}", model);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private class ProcessMonitor extends Thread {
+ private volatile boolean running = false;
+
+ public ProcessMonitor() {
+ setName("ProcessMonitor");
+ setDaemon(true);
+ }
+
+ @Override
+ public void run() {
+ running = true;
+ while(running) {
+ lock.lock();
+ try {
+ List restart = new ArrayList();
+ for (Iterator> iterator = recordingProcesses.entrySet().iterator(); iterator.hasNext();) {
+ Entry entry = iterator.next();
+ Model m = entry.getKey();
+ Download d = entry.getValue();
+ if(!d.isAlive()) {
+ LOG.debug("Recording terminated for model {}", m.getName());
+ iterator.remove();
+ restart.add(m);
+ generatePlaylist(d.getDirectory());
+ }
+ }
+ for (Model m : restart) {
+ tryRestartRecording(m);
+ }
+ }
+ finally {
+ lock.unlock();
+ }
+
+ try {
+ if(running) Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ LOG.error("Couldn't sleep", e);
+ }
+ }
+ LOG.debug(getName() + " terminated");
+ }
+
+ }
+
+ private void generatePlaylist(File recDir) {
+ Thread t = new Thread() {
+ @Override
+ public void run() {
+ PlaylistGenerator playlistGenerator = new PlaylistGenerator();
+ playlistGenerators.put(recDir, playlistGenerator);
+ try {
+ playlistGenerator.generate(recDir);
+ playlistGenerator.validate(recDir);
+ } catch (IOException | ParseException | PlaylistException e) {
+ LOG.error("Couldn't generate playlist file", e);
+ } catch (InvalidPlaylistException e) {
+ LOG.error("Playlist is invalid", e);
+ File playlist = new File(recDir, "playlist.m3u8");
+ playlist.delete();
+ } finally {
+ playlistGenerators.remove(recDir);
+ }
+ }
+ };
+ t.setDaemon(true);
+ t.setName("Playlist Generator " + recDir.toString());
+ t.start();
+ }
+
+ private class OnlineMonitor extends Thread {
+ private volatile boolean running = false;
+
+ public OnlineMonitor() {
+ setName("OnlineMonitor");
+ setDaemon(true);
+ }
+
+ @Override
+ public void run() {
+ running = true;
+ while(running) {
+ lock.lock();
+ try {
+ for (Model model : models) {
+ if(!recordingProcesses.containsKey(model)) {
+ try {
+ LOG.trace("Checking online state for {}", model);
+ boolean isOnline = checkIfOnline(model);
+ boolean wasOnline = model.isOnline();
+ model.setOnline(isOnline);
+ if(wasOnline != isOnline && isOnline) {
+ LOG.info("Model {}'s room back to public. Starting recording", model);
+ startRecordingProcess(model);
+ }
+ } catch (IOException e) {
+ LOG.error("Couldn't check if model {} is online", model.getName(), e);
+ }
+ }
+ }
+ } finally {
+ lock.unlock();
+ }
+
+ try {
+ if(running) Thread.sleep(10000);
+ } catch (InterruptedException e) {
+ LOG.trace("Sleep interrupted");
+ }
+ }
+ LOG.debug(getName() + " terminated");
+ }
+ }
+
+ private class PlaylistGeneratorTrigger extends Thread {
+ private volatile boolean running = false;
+
+ public PlaylistGeneratorTrigger() {
+ setName("PlaylistGeneratorTrigger");
+ setDaemon(true);
+ }
+
+ @Override
+ public void run() {
+ running = true;
+ while(running) {
+ try {
+ List recs = getRecordings();
+ for (Recording rec : recs) {
+ if(rec.getStatus() == RECORDING) {
+ boolean recordingProcessFound = false;
+ File recordingsDir = new File(config.getSettings().recordingsDir);
+ File recDir = new File(recordingsDir, rec.getPath());
+ for(Entry download : recordingProcesses.entrySet()) {
+ if(download.getValue().getDirectory().equals(recDir)) {
+ recordingProcessFound = true;
+ }
+ }
+ if(!recordingProcessFound) {
+ // finished recording without playlist -> generate it
+ generatePlaylist(recDir);
+ }
+ }
+ }
+
+ if(running) Thread.sleep(10000);
+ } catch (InterruptedException e) {
+ LOG.error("Couldn't sleep", e);
+ } catch (Exception e) {
+ LOG.error("Unexpected error in playlist trigger thread", e);
+ }
+ }
+ LOG.debug(getName() + " terminated");
+ }
+ }
+
+ @Override
+ public List getRecordings() {
+ List recordings = new ArrayList<>();
+ File recordingsDir = new File(config.getSettings().recordingsDir);
+ File[] subdirs = recordingsDir.listFiles();
+ if(subdirs == null ) {
+ return Collections.emptyList();
+ }
+
+ for (File subdir : subdirs) {
+ if(!subdir.isDirectory()) {
+ continue;
+ }
+
+ File[] recordingsDirs = subdir.listFiles();
+ for (File rec : recordingsDirs) {
+ String pattern = "yyyy-MM-dd_HH-mm";
+ SimpleDateFormat sdf = new SimpleDateFormat(pattern);
+ if(rec.isDirectory()) {
+ try {
+ if(rec.getName().length() != pattern.length()) {
+ continue;
+ }
+
+ Date startDate = sdf.parse(rec.getName());
+ Recording recording = new Recording();
+ recording.setModelName(subdir.getName());
+ recording.setStartDate(Instant.ofEpochMilli(startDate.getTime()));
+ recording.setPath(recording.getModelName() + "/" + rec.getName());
+ recording.setSizeInByte(getSize(rec));
+ File playlist = new File(rec, "playlist.m3u8");
+ recording.setHasPlaylist(playlist.exists());
+ if(recording.hasPlaylist()) {
+ recording.setStatus(FINISHED);
+ } else {
+ PlaylistGenerator playlistGenerator = playlistGenerators.get(rec);
+ if(playlistGenerator != null) {
+ recording.setStatus(GENERATING_PLAYLIST);
+ recording.setProgress(playlistGenerator.getProgress());
+ } else {
+ recording.setStatus(RECORDING);
+ }
+ }
+ recordings.add(recording);
+ } catch(Exception e) {
+ LOG.debug("Ignoring {}", rec.getAbsolutePath());
+ }
+ }
+ }
+ }
+ return recordings;
+ }
+
+ private long getSize(File rec) {
+ long size = 0;
+ File[] files = rec.listFiles();
+ for (File file : files) {
+ size += file.length();
+ }
+ return size;
+ }
+
+ @Override
+ public void delete(Recording recording) throws IOException {
+ File recordingsDir = new File(config.getSettings().recordingsDir);
+ File directory = new File(recordingsDir, recording.getPath());
+ if(!directory.exists()) {
+ throw new IOException("Recording does not exist");
+ }
+ File[] files = directory.listFiles();
+ boolean deletedAllFiles = true;
+ for (File file : files) {
+ try {
+ Files.delete(file.toPath());
+ } catch (Exception e) {
+ deletedAllFiles = false;
+ LOG.debug("Couldn't delete {}", file, e);
+ }
+ }
+
+ if(deletedAllFiles) {
+ boolean deleted = directory.delete();
+ if(deleted) {
+ if(directory.getParentFile().list().length == 0) {
+ directory.getParentFile().delete();
+ }
+ } else {
+ throw new IOException("Couldn't delete " + directory);
+ }
+ } else {
+ throw new IOException("Couldn't delete all files in " + directory);
+ }
+ }
+}
diff --git a/src/main/java/ctbrec/recorder/OS.java b/src/main/java/ctbrec/recorder/OS.java
new file mode 100644
index 00000000..7a381711
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/OS.java
@@ -0,0 +1,77 @@
+package ctbrec.recorder;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map.Entry;
+
+import ctbrec.Settings;
+
+public class OS {
+
+ public static enum TYPE {
+ LINUX,
+ MAC,
+ WINDOWS,
+ OTHER
+ }
+
+ public static TYPE getOsType() {
+ if(System.getProperty("os.name").contains("Linux")) {
+ return TYPE.LINUX;
+ } else if(System.getProperty("os.name").contains("Windows")) {
+ return TYPE.WINDOWS;
+ } else if(System.getProperty("os.name").contains("Mac")) {
+ return TYPE.MAC;
+ } else {
+ return TYPE.OTHER;
+ }
+ }
+
+ public static File getConfigDir() {
+ File configDir;
+ switch (getOsType()) {
+ case LINUX:
+ String userHome = System.getProperty("user.home");
+ configDir = new File(new File(userHome, ".config"), "ctbrec");
+ break;
+ case MAC:
+ userHome = System.getProperty("user.home");
+ configDir = new File(userHome, "Library/Preferences/ctbrec");
+ break;
+ case WINDOWS:
+ String appData = System.getenv("APPDATA");
+ configDir = new File(appData, "ctbrec");
+ break;
+ default:
+ throw new RuntimeException("Unsupported operating system " + System.getProperty("os.name"));
+ }
+ return configDir;
+ }
+
+ public static Settings getDefaultSettings() {
+ Settings settings = new Settings();
+ if(getOsType() == TYPE.WINDOWS) {
+ String userHome = System.getProperty("user.home");
+ Path path = Paths.get(userHome, "Videos", "ctbrec");
+ settings.recordingsDir = path.toString();
+ String programFiles = System.getenv("ProgramFiles");
+ programFiles = programFiles != null ? programFiles : "C:\\Program Files";
+ settings.mediaPlayer = Paths.get(programFiles, "VideoLAN", "VLC", "vlc.exe").toString();
+ } else if(getOsType() == TYPE.MAC) {
+ String userHome = System.getProperty("user.home");
+ settings.recordingsDir = Paths.get(userHome, "Movies", "ctbrec").toString();
+ settings.mediaPlayer = "/Applications/VLC.app/Contents/MacOS/VLC";
+ }
+ return settings;
+ }
+
+ public static String[] getEnvironment() {
+ String[] env = new String[System.getenv().size()];
+ int index = 0;
+ for (Entry entry : System.getenv().entrySet()) {
+ env[index++] = entry.getKey() + "=" + entry.getValue();
+ }
+ return env;
+ }
+}
diff --git a/src/main/java/ctbrec/recorder/Recorder.java b/src/main/java/ctbrec/recorder/Recorder.java
new file mode 100644
index 00000000..472254e6
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/Recorder.java
@@ -0,0 +1,27 @@
+package ctbrec.recorder;
+
+import java.io.IOException;
+import java.util.List;
+
+import ctbrec.Model;
+import ctbrec.Recording;
+
+public interface Recorder {
+ public void startRecording(Model model) throws IOException;
+
+ public void stopRecording(Model model) throws IOException, InterruptedException;
+
+ /**
+ * Returns, if a model is in the list of models to record. This does not reflect, if there currently is a recording running. The model might be offline
+ * aswell.
+ */
+ public boolean isRecording(Model model);
+
+ public List getModelsRecording();
+
+ public List getRecordings() throws IOException;
+
+ public void delete(Recording recording) throws IOException;
+
+ public void shutdown();
+}
diff --git a/src/main/java/ctbrec/recorder/RemoteRecorder.java b/src/main/java/ctbrec/recorder/RemoteRecorder.java
new file mode 100644
index 00000000..527b0d58
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/RemoteRecorder.java
@@ -0,0 +1,214 @@
+package ctbrec.recorder;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.squareup.moshi.JsonAdapter;
+import com.squareup.moshi.Moshi;
+
+import ctbrec.Config;
+import ctbrec.HttpClient;
+import ctbrec.InstantJsonAdapter;
+import ctbrec.Model;
+import ctbrec.Recording;
+import okhttp3.MediaType;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+public class RemoteRecorder implements Recorder {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(RemoteRecorder.class);
+
+ public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
+ private Moshi moshi = new Moshi.Builder()
+ .add(Instant.class, new InstantJsonAdapter())
+ .build();
+ private JsonAdapter modelListResponseAdapter = moshi.adapter(ModelListResponse.class);
+ private JsonAdapter recordingListResponseAdapter = moshi.adapter(RecordingListResponse.class);
+ private JsonAdapter modelAdapter = moshi.adapter(Model.class);
+
+ private List models = Collections.emptyList();
+
+ private Config config;
+ private HttpClient client;
+ private Instant lastSync = Instant.EPOCH;
+ private SyncThread syncThread;
+
+ public RemoteRecorder(Config config, HttpClient client) {
+ this.config = config;
+ this.client = client;
+
+ syncThread = new SyncThread();
+ syncThread.start();
+ }
+
+ @Override
+ public void startRecording(Model model) throws IOException {
+ sendRequest("start", model);
+ }
+
+ @Override
+ public void stopRecording(Model model) throws IOException, InterruptedException {
+ sendRequest("stop", model);
+ }
+
+ private void sendRequest(String action, Model model) throws IOException {
+ String requestTemplate = "{\"action\": \"<>\", \"model\": <>}";
+ requestTemplate = requestTemplate.replaceAll("<>", action);
+ requestTemplate = requestTemplate.replaceAll("<>", modelAdapter.toJson(model));
+ LOG.debug("Sending request to recording server: {}", requestTemplate);
+ RequestBody body = RequestBody.create(JSON, requestTemplate);
+ Request request = new Request.Builder()
+ .url("http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/rec")
+ .post(body)
+ .build();
+ Response response = client.execute(request);
+ String json = response.body().string();
+ if(response.isSuccessful()) {
+ ModelListResponse resp = modelListResponseAdapter.fromJson(json);
+ if(resp.status.equals("success")) {
+ models = resp.models;
+ lastSync = Instant.now();
+ } else {
+ throw new IOException("Server returned error " + resp.status + " " + resp.msg);
+ }
+ } else {
+ throw new IOException("Server returned error. HTTP status: " + response.code());
+ }
+ }
+
+ @Override
+ public boolean isRecording(Model model) {
+ return models != null && models.contains(model);
+ }
+
+ @Override
+ public List getModelsRecording() {
+ if(lastSync.isBefore(Instant.now().minusSeconds(60))) {
+ throw new RuntimeException("Last sync was over a minute ago");
+ }
+ return models;
+ }
+
+ @Override
+ public void shutdown() {
+ syncThread.stopThread();
+ }
+
+ private class SyncThread extends Thread {
+ private volatile boolean running = false;
+
+ public SyncThread() {
+ setName("RemoteRecorder SyncThread");
+ setDaemon(true);
+ }
+
+ @Override
+ public void run() {
+ running = true;
+ while(running) {
+ RequestBody body = RequestBody.create(JSON, "{\"action\": \"list\"}");
+ Request request = new Request.Builder()
+ .url("http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/rec")
+ .post(body)
+ .build();
+ try {
+ Response response = client.execute(request);
+ String json = response.body().string();
+ if(response.isSuccessful()) {
+ ModelListResponse resp = modelListResponseAdapter.fromJson(json);
+ if(resp.status.equals("success")) {
+ models = resp.models;
+ lastSync = Instant.now();
+ } else {
+ LOG.error("Server returned error: {} - {}", resp.status, resp.msg);
+ }
+ } else {
+ LOG.error("Couldn't synchronize with server. HTTP status: {} - {}", response.code(), json);
+ }
+ } catch (IOException e) {
+ LOG.error("Couldn't synchronize with server", e);
+ }
+
+ sleep();
+ }
+ }
+
+ private void sleep() {
+ try {
+ Thread.sleep(10000);
+ } catch (InterruptedException e) {
+ // interrupted, probably by stopThread
+ }
+ }
+
+ public void stopThread() {
+ running = false;
+ interrupt();
+ }
+ }
+
+ private static class ModelListResponse {
+ public String status;
+ public String msg;
+ public List models;
+ }
+
+ private static class RecordingListResponse {
+ public String status;
+ public String msg;
+ public List recordings;
+ }
+
+ @Override
+ public List getRecordings() throws IOException {
+ RequestBody body = RequestBody.create(JSON, "{\"action\": \"recordings\"}");
+ Request request = new Request.Builder()
+ .url("http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/rec")
+ .post(body)
+ .build();
+
+ Response response = client.execute(request);
+ String json = response.body().string();
+ if(response.isSuccessful()) {
+ LOG.debug(json);
+ RecordingListResponse resp = recordingListResponseAdapter.fromJson(json);
+ if(resp.status.equals("success")) {
+ List recordings = resp.recordings;
+ return recordings;
+ } else {
+ LOG.error("Server returned error: {} - {}", resp.status, resp.msg);
+ }
+ } else {
+ LOG.error("Couldn't synchronize with server. HTTP status: {} - {}", response.code(), json);
+ }
+
+ return Collections.emptyList();
+ }
+
+ @Override
+ public void delete(Recording recording) throws IOException {
+ RequestBody body = RequestBody.create(JSON, "{\"action\": \"delete\", \"recording\": \""+recording.getPath()+"\"}");
+ Request request = new Request.Builder()
+ .url("http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/rec")
+ .post(body)
+ .build();
+
+ Response response = client.execute(request);
+ String json = response.body().string();
+ if(response.isSuccessful()) {
+ RecordingListResponse resp = recordingListResponseAdapter.fromJson(json);
+ if(!resp.status.equals("success")) {
+ throw new IOException("Couldn't delete recording: " + resp.status + " " + resp.msg);
+ }
+ } else {
+ throw new IOException("Couldn't delete recording: " + response.code() + " " + json);
+ }
+ }
+}
diff --git a/src/main/java/ctbrec/recorder/StreamInfo.java b/src/main/java/ctbrec/recorder/StreamInfo.java
new file mode 100644
index 00000000..740074fa
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/StreamInfo.java
@@ -0,0 +1,8 @@
+package ctbrec.recorder;
+
+public class StreamInfo {
+ public String url;
+ public String room_status;
+ public String hidden_message;
+ public boolean success;
+}
diff --git a/src/main/java/ctbrec/recorder/StreamRedirectThread.java b/src/main/java/ctbrec/recorder/StreamRedirectThread.java
new file mode 100644
index 00000000..94ee3966
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/StreamRedirectThread.java
@@ -0,0 +1,34 @@
+package ctbrec.recorder;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class StreamRedirectThread implements Runnable {
+ private static final transient Logger LOG = LoggerFactory.getLogger(StreamRedirectThread.class);
+
+ private InputStream in;
+ private OutputStream out;
+
+ public StreamRedirectThread(InputStream in, OutputStream out) {
+ super();
+ this.in = in;
+ this.out = out;
+ }
+
+ @Override
+ public void run() {
+ try {
+ int length = -1;
+ byte[] buffer = new byte[1024*1024];
+ while(in != null && (length = in.read(buffer)) >= 0) {
+ out.write(buffer, 0, length);
+ }
+ LOG.debug("Stream redirect thread ended");
+ } catch(Exception e) {
+ LOG.error("Couldn't redirect stream: {}", e.getLocalizedMessage());
+ }
+ }
+}
diff --git a/src/main/java/ctbrec/recorder/download/Download.java b/src/main/java/ctbrec/recorder/download/Download.java
new file mode 100644
index 00000000..4148a362
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/download/Download.java
@@ -0,0 +1,14 @@
+package ctbrec.recorder.download;
+
+import java.io.File;
+import java.io.IOException;
+
+import ctbrec.Config;
+import ctbrec.Model;
+
+public interface Download {
+ public void start(Model model, Config config) throws IOException;
+ public void stop();
+ public boolean isAlive();
+ public File getDirectory();
+}
diff --git a/src/main/java/ctbrec/recorder/download/HlsDownload.java b/src/main/java/ctbrec/recorder/download/HlsDownload.java
new file mode 100644
index 00000000..dc714140
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/download/HlsDownload.java
@@ -0,0 +1,240 @@
+package ctbrec.recorder.download;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Callable;
+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;
+import com.iheartradio.m3u8.PlaylistException;
+import com.iheartradio.m3u8.PlaylistParser;
+import com.iheartradio.m3u8.data.MasterPlaylist;
+import com.iheartradio.m3u8.data.MediaPlaylist;
+import com.iheartradio.m3u8.data.Playlist;
+import com.iheartradio.m3u8.data.PlaylistData;
+import com.iheartradio.m3u8.data.TrackData;
+
+import ctbrec.Config;
+import ctbrec.HttpClient;
+import ctbrec.Model;
+import ctbrec.recorder.Chaturbate;
+import ctbrec.recorder.StreamInfo;
+
+public class HlsDownload implements Download {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(HlsDownload.class);
+ private HttpClient client;
+ private ExecutorService threadPool = Executors.newFixedThreadPool(5);
+ private volatile boolean running = false;
+ private volatile boolean alive = true;
+ private Path downloadDir;
+
+ public HlsDownload(HttpClient client) {
+ this.client = client;
+ }
+
+ @Override
+ public void start(Model model, Config config) throws IOException {
+ try {
+ running = true;
+ StreamInfo streamInfo = Chaturbate.getStreamInfo(model, client);
+ if(!Objects.equals(streamInfo.room_status, "public")) {
+ throw new IOException(model.getName() +"'s room is not public");
+ }
+
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm");
+ String startTime = sdf.format(new Date());
+ Path modelDir = FileSystems.getDefault().getPath(config.getSettings().recordingsDir, model.getName());
+ downloadDir = FileSystems.getDefault().getPath(modelDir.toString(), startTime);
+ if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) {
+ Files.createDirectories(downloadDir);
+ }
+
+ String segments = parseMaster(streamInfo.url);
+ if(segments != null) {
+ int lastSegment = 0;
+ int nextSegment = 0;
+ while(running) {
+ LiveStreamingPlaylist lsp = parseSegments(segments);
+ if(nextSegment > 0 && lsp.seq > nextSegment) {
+ LOG.warn("Missed segments {} < {} in download for {}", nextSegment, lsp.seq, model);
+ String first = lsp.segments.get(0);
+ int seq = lsp.seq;
+ for (int i = nextSegment; i < lsp.seq; i++) {
+ URL segmentUrl = new URL(first.replaceAll(Integer.toString(seq), Integer.toString(i)));
+ LOG.debug("Reloading segment {} for model {}", i, model.getName());
+ threadPool.submit(new SegmentDownload(segmentUrl, downloadDir));
+ }
+ // TODO switch to a lower bitrate/resolution ?!?
+ }
+ int skip = nextSegment - lsp.seq;
+ for (String segment : lsp.segments) {
+ if(skip > 0) {
+ skip--;
+ } else {
+ URL segmentUrl = new URL(segment);
+ threadPool.submit(new SegmentDownload(segmentUrl, downloadDir));
+ //new SegmentDownload(segment, downloadDir).call();
+ }
+ }
+
+ long wait = 0;
+ if(lastSegment == lsp.seq) {
+ // playlist didn't change -> wait for at least half the target duration
+ wait = (long) lsp.targetDuration * 1000 / 2;
+ LOG.trace("Playlist didn't change... waiting for {}ms", wait);
+ } else {
+ // playlist did change -> wait for at least last segment duration
+ wait = 1;//(long) lsp.lastSegDuration * 1000;
+ LOG.trace("Playlist changed... waiting for {}ms", wait);
+ }
+
+ try {
+ Thread.sleep(wait);
+ } catch (InterruptedException e) {
+ if(running) {
+ LOG.error("Couldn't sleep between segment downloads. This might mess up the download!");
+ }
+ }
+
+ lastSegment = lsp.seq;
+ nextSegment = lastSegment + lsp.segments.size();
+ }
+ } else {
+ throw new IOException("Couldn't determine segments uri");
+ }
+ } catch(ParseException e) {
+ throw new IOException("Couldn't parse stream information", e);
+ } catch(PlaylistException e) {
+ throw new IOException("Couldn't parse HLS playlist", e);
+ } catch(Exception e) {
+ throw new IOException("Couldn't download segment", e);
+ } finally {
+ alive = false;
+ LOG.debug("Download for {} terminated", model);
+ }
+ }
+
+ @Override
+ public void stop() {
+ running = false;
+ alive = false;
+ }
+
+ private LiveStreamingPlaylist parseSegments(String segments) throws IOException, ParseException, PlaylistException {
+ URL segmentsUrl = new URL(segments);
+ InputStream inputStream = segmentsUrl.openStream();
+ PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8);
+ Playlist playlist = parser.parse();
+ if(playlist.hasMediaPlaylist()) {
+ MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist();
+ LiveStreamingPlaylist lsp = new LiveStreamingPlaylist();
+ lsp.seq = mediaPlaylist.getMediaSequenceNumber();
+ lsp.targetDuration = mediaPlaylist.getTargetDuration();
+ List tracks = mediaPlaylist.getTracks();
+ for (TrackData trackData : tracks) {
+ String uri = trackData.getUri();
+ if(!uri.startsWith("http")) {
+ String _url = segmentsUrl.toString();
+ _url = _url.substring(0, _url.lastIndexOf('/') + 1);
+ String segmentUri = _url + uri;
+ lsp.totalDuration += trackData.getTrackInfo().duration;
+ lsp.lastSegDuration = trackData.getTrackInfo().duration;
+ lsp.segments.add(segmentUri);
+ }
+ }
+ return lsp;
+ }
+ return null;
+ }
+
+ private String parseMaster(String url) throws IOException, ParseException, PlaylistException {
+ URL masterUrl = new URL(url);
+ InputStream inputStream = masterUrl.openStream();
+ PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8);
+ Playlist playlist = parser.parse();
+ if(playlist.hasMasterPlaylist()) {
+ MasterPlaylist master = playlist.getMasterPlaylist();
+ PlaylistData bestQuality = master.getPlaylists().get(master.getPlaylists().size()-1);
+ String uri = bestQuality.getUri();
+ if(!uri.startsWith("http")) {
+ String _masterUrl = masterUrl.toString();
+ _masterUrl = _masterUrl.substring(0, _masterUrl.lastIndexOf('/') + 1);
+ String segmentUri = _masterUrl + uri;
+ return segmentUri;
+ }
+ }
+ return null;
+ }
+
+ public static class LiveStreamingPlaylist {
+ public int seq = 0;
+ public float totalDuration = 0;
+ public float lastSegDuration = 0;
+ public float targetDuration = 0;
+ public List segments = new ArrayList<>();
+ }
+
+ private static class SegmentDownload implements Callable {
+ private URL url;
+ private Path file;
+
+ public SegmentDownload(URL url, Path dir) {
+ this.url = url;
+ File path = new File(url.getPath());
+ file = FileSystems.getDefault().getPath(dir.toString(), path.getName());
+ }
+
+ @Override
+ public Boolean call() throws Exception {
+ LOG.trace("Downloading segment to " + file);
+ for (int i = 0; i < 3; i++) {
+ try( FileOutputStream fos = new FileOutputStream(file.toFile());
+ InputStream in = url.openStream())
+ {
+ byte[] b = new byte[1024 * 100];
+ int length = -1;
+ while( (length = in.read(b)) >= 0 ) {
+ fos.write(b, 0, length);
+ }
+ return true;
+ } catch(FileNotFoundException e) {
+ LOG.debug("Segment does not exist {}", url.getFile());
+ break;
+ } catch(Exception e) {
+ LOG.error("Error while downloading segment. Retrying " + i, e);
+ }
+ }
+ return false;
+ }
+ }
+
+ @Override
+ public boolean isAlive() {
+ return alive;
+ }
+
+ @Override
+ public File getDirectory() {
+ return downloadDir.toFile();
+ }
+}
diff --git a/src/main/java/ctbrec/recorder/server/HlsServlet.java b/src/main/java/ctbrec/recorder/server/HlsServlet.java
new file mode 100644
index 00000000..88631098
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/server/HlsServlet.java
@@ -0,0 +1,88 @@
+package ctbrec.recorder.server;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.iheartradio.m3u8.ParseException;
+import com.iheartradio.m3u8.PlaylistException;
+
+import ctbrec.Config;
+
+public class HlsServlet extends HttpServlet {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(HlsServlet.class);
+
+ private Config config;
+
+ public HlsServlet(Config config) {
+ this.config = config;
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+ String request = req.getRequestURI().substring(5);
+ File recordingsDir = new File(config.getSettings().recordingsDir);
+ File requestedFile = new File(recordingsDir, request);
+
+ if (requestedFile.getCanonicalPath().startsWith(config.getSettings().recordingsDir)) {
+ if (requestedFile.getName().equals("playlist.m3u8")) {
+ try {
+ servePlaylist(req, resp, requestedFile);
+ } catch (ParseException | PlaylistException e) {
+ LOG.error("Error while generating playlist file", e);
+ throw new IOException("Couldn't generate playlist file " + requestedFile, e);
+ }
+ } else {
+ if (requestedFile.exists()) {
+ serveSegment(req, resp, requestedFile);
+ } else {
+ error404(req, resp);
+ }
+ }
+ } else {
+ resp.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ resp.getWriter().println("Stop it!");
+ }
+ }
+
+ private void error404(HttpServletRequest req, HttpServletResponse resp) {
+ resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ private void serveSegment(HttpServletRequest req, HttpServletResponse resp, File requestedFile) throws FileNotFoundException, IOException {
+ serveFile(resp, requestedFile, "application/octet-stream");
+ }
+
+ private void servePlaylist(HttpServletRequest req, HttpServletResponse resp, File requestedFile) throws FileNotFoundException, IOException, ParseException, PlaylistException {
+ serveFile(resp, requestedFile, "application/x-mpegURL");
+ }
+
+ private void serveFile(HttpServletResponse resp, File file, String contentType) throws FileNotFoundException, IOException {
+ LOG.trace("Serving segment {}", file.getAbsolutePath());
+ resp.setStatus(200);
+ resp.setContentLength((int) file.length());
+ resp.setContentType(contentType);
+ try(FileInputStream fin = new FileInputStream(file)) {
+ byte[] buffer = new byte[1024 * 100];
+ int length = -1;
+ while( (length = fin.read(buffer)) >= 0) {
+ resp.getOutputStream().write(buffer, 0, length);
+ }
+ }
+ }
+
+ @Override
+ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+ doGet(req, resp);
+ }
+}
diff --git a/src/main/java/ctbrec/recorder/server/HttpServer.java b/src/main/java/ctbrec/recorder/server/HttpServer.java
new file mode 100644
index 00000000..9faaf749
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/server/HttpServer.java
@@ -0,0 +1,92 @@
+package ctbrec.recorder.server;
+
+import java.io.IOException;
+
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.HandlerList;
+import org.eclipse.jetty.servlet.ServletHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.recorder.LocalRecorder;
+import ctbrec.recorder.Recorder;
+
+public class HttpServer {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(HttpServer.class);
+ private Recorder recorder;
+ private Config config;
+ private Server server = new Server();
+
+ public HttpServer() throws Exception {
+ addShutdownHook(); // for graceful termination
+
+ if(System.getProperty("ctbrec.config") == null) {
+ System.setProperty("ctbrec.config", "server.json");
+ }
+ config = Config.getInstance();
+ recorder = new LocalRecorder(config);
+ startHttpServer();
+ }
+
+ private void addShutdownHook() {
+ Runtime.getRuntime().addShutdownHook(new Thread() {
+ @Override
+ public void run() {
+ LOG.info("Shutting down");
+ if(recorder != null) {
+ recorder.shutdown();
+ }
+ try {
+ server.stop();
+ } catch (Exception e) {
+ LOG.error("Couldn't stop HTTP server", e);
+ }
+ try {
+ Config.getInstance().save();
+ } catch (IOException e) {
+ LOG.error("Couldn't save configuration", e);
+ }
+ LOG.info("Good bye!");
+ }
+ });
+ }
+
+ private void startHttpServer() throws Exception {
+ server = new Server();
+
+ HttpConfiguration config = new HttpConfiguration();
+ config.setSendServerVersion(false);
+ ServerConnector http = new ServerConnector(server, new HttpConnectionFactory(config));
+ http.setPort(this.config.getSettings().httpPort);
+ http.setIdleTimeout(this.config.getSettings().httpTimeout);
+ server.addConnector(http);
+
+ ServletHandler handler = new ServletHandler();
+ server.setHandler(handler);
+ HandlerList handlers = new HandlerList();
+ handlers.setHandlers(new Handler[] { handler });
+ server.setHandler(handlers);
+
+ RecorderServlet recorderServlet = new RecorderServlet(recorder);
+ ServletHolder holder = new ServletHolder(recorderServlet);
+ handler.addServletWithMapping(holder, "/rec");
+
+ HlsServlet hlsServlet = new HlsServlet(this.config);
+ holder = new ServletHolder(hlsServlet);
+ handler.addServletWithMapping(holder, "/hls/*");
+
+ server.start();
+ server.join();
+ }
+
+ public static void main(String[] args) throws Exception {
+ new HttpServer();
+ }
+}
diff --git a/src/main/java/ctbrec/recorder/server/PlaylistGenerator.java b/src/main/java/ctbrec/recorder/server/PlaylistGenerator.java
new file mode 100644
index 00000000..80080308
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/server/PlaylistGenerator.java
@@ -0,0 +1,206 @@
+package ctbrec.recorder.server;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.nio.channels.ReadableByteChannel;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+
+import org.jcodec.common.Demuxer;
+import org.jcodec.common.DemuxerTrack;
+import org.jcodec.common.TrackType;
+import org.jcodec.common.Tuple;
+import org.jcodec.common.Tuple._2;
+import org.jcodec.common.io.FileChannelWrapper;
+import org.jcodec.common.io.NIOUtils;
+import org.jcodec.common.model.Packet;
+import org.jcodec.containers.mps.MPSDemuxer;
+import org.jcodec.containers.mps.MTSDemuxer;
+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.PlaylistWriter;
+import com.iheartradio.m3u8.data.MediaPlaylist;
+import com.iheartradio.m3u8.data.Playlist;
+import com.iheartradio.m3u8.data.PlaylistType;
+import com.iheartradio.m3u8.data.TrackData;
+import com.iheartradio.m3u8.data.TrackInfo;
+
+
+public class PlaylistGenerator {
+ private static final transient Logger LOG = LoggerFactory.getLogger(PlaylistGenerator.class);
+
+ private int lastPercentage;
+ private List listeners = new ArrayList<>();
+
+ public void generate(File directory) throws IOException, ParseException, PlaylistException {
+ LOG.debug("Starting playlist generation for {}", directory);
+ // get a list of all ts files and sort them by sequence
+ File[] files = directory.listFiles((f) -> f.getName().endsWith(".ts"));
+ Arrays.sort(files, (f1, f2) -> {
+ String n1 = f1.getName();
+ n1 = n1.substring(0, n1.length()-3);
+ int seq1 = Integer.parseInt(n1.substring(n1.lastIndexOf('_')+1));
+
+ String n2 = f2.getName();
+ n2 = n2.substring(0, n2.length()-3);
+ int seq2 = Integer.parseInt(n2.substring(n2.lastIndexOf('_')+1));
+
+ if(seq1 < seq2) return -1;
+ if(seq1 > seq2) return 1;
+ return 0;
+ });
+
+ // create a track containing all files
+ List track = new ArrayList<>();
+ int total = files.length;
+ int done = 0;
+ for (File file : files) {
+ try {
+ track.add(new TrackData.Builder()
+ .withUri(file.getName())
+ .withTrackInfo(new TrackInfo((float) getFileDuration(file), file.getName()))
+ .build());
+ } catch(Exception e) {
+ LOG.warn("Couldn't determine duration for {}. Skipping this file.", file.getName());
+ file.renameTo(new File(directory, file.getName()+".corrupt"));
+ }
+ done++;
+ double percentage = (double)done / (double) total;
+ updateProgressListeners(percentage);
+ }
+
+ // create a media playlist
+ float targetDuration = getAvgDuration(track);
+ MediaPlaylist playlist = new MediaPlaylist.Builder()
+ .withPlaylistType(PlaylistType.VOD)
+ .withMediaSequenceNumber(0)
+ .withTargetDuration((int) targetDuration)
+ .withTracks(track).build();
+
+ // create a master playlist containing the media playlist
+ Playlist master = new Playlist.Builder()
+ .withCompatibilityVersion(4)
+ .withExtended(true)
+ .withMediaPlaylist(playlist)
+ .build();
+
+ // write the playlist to a file
+ File output = new File(directory, "playlist.m3u8");
+ try(FileOutputStream fos = new FileOutputStream(output)) {
+ PlaylistWriter writer = new PlaylistWriter.Builder()
+ .withFormat(Format.EXT_M3U)
+ .withEncoding(Encoding.UTF_8)
+ .withOutputStream(fos)
+ .build();
+ writer.write(master);
+ LOG.debug("Finished playlist generation for {}", directory);
+ }
+ }
+
+ private void updateProgressListeners(double percentage) {
+ int p = (int) (percentage*100);
+ if(p > lastPercentage) {
+ for (ProgressListener progressListener : listeners) {
+ progressListener.update(p);
+ }
+ lastPercentage = p;
+ }
+ }
+
+ private float getAvgDuration(List track) {
+ float targetDuration = 0;
+ for (TrackData trackData : track) {
+ targetDuration += trackData.getTrackInfo().duration;
+ }
+ targetDuration /= track.size();
+ return targetDuration;
+ }
+
+ private double getFileDuration(File file) throws IOException {
+ try(FileChannelWrapper ch = NIOUtils.readableChannel(file)) {
+ _2 m2tsDemuxer = createM2TSDemuxer(ch, TrackType.VIDEO);
+ Demuxer demuxer = m2tsDemuxer.v1;
+ DemuxerTrack videoDemux = demuxer.getTracks().get(0);
+ Packet videoFrame = null;
+ double totalDuration = 0;
+ while( (videoFrame = videoDemux.nextFrame()) != null) {
+ totalDuration += videoFrame.getDurationD();
+ }
+ return totalDuration;
+ }
+ }
+
+ public static _2 createM2TSDemuxer(FileChannelWrapper ch, TrackType targetTrack) throws IOException {
+ MTSDemuxer mts = new MTSDemuxer(ch);
+ Set programs = mts.getPrograms();
+ if (programs.size() == 0) {
+ LOG.error("The MPEG TS stream contains no programs");
+ return null;
+ }
+ Tuple._2 found = null;
+ for (Integer pid : programs) {
+ ReadableByteChannel program = mts.getProgram(pid);
+ if (found != null) {
+ program.close();
+ continue;
+ }
+ MPSDemuxer demuxer = new MPSDemuxer(program);
+ if (targetTrack == TrackType.AUDIO && demuxer.getAudioTracks().size() > 0
+ || targetTrack == TrackType.VIDEO && demuxer.getVideoTracks().size() > 0) {
+ found = org.jcodec.common.Tuple._2(pid, (Demuxer) demuxer);
+ } else {
+ program.close();
+ }
+ }
+ return found;
+ }
+
+ public void addProgressListener(ProgressListener l) {
+ listeners.add(l);
+ }
+
+ public int getProgress() {
+ return lastPercentage;
+ }
+
+ public void validate(File recDir) throws IOException, ParseException, PlaylistException {
+ File playlist = new File(recDir, "playlist.m3u8");
+ if(playlist.exists()) {
+ PlaylistParser playlistParser = new PlaylistParser(new FileInputStream(playlist), Format.EXT_M3U, Encoding.UTF_8);
+ Playlist m3u = playlistParser.parse();
+ MediaPlaylist mediaPlaylist = m3u.getMediaPlaylist();
+ int playlistSize = mediaPlaylist.getTracks().size();
+ File[] segments = recDir.listFiles(new FilenameFilter() {
+ @Override
+ public boolean accept(File dir, String name) {
+ return name.startsWith("media_") && name.endsWith(".ts");
+ }
+ });
+ if(segments.length != playlistSize) {
+ throw new InvalidPlaylistException("Playlist size and amount of segments differ");
+ } else {
+ LOG.debug("Generated playlist looks good");
+ }
+ } else {
+ throw new FileNotFoundException(playlist.getAbsolutePath() + " does not exist");
+ }
+ }
+
+ public static class InvalidPlaylistException extends RuntimeException {
+ public InvalidPlaylistException(String msg) {
+ super(msg);
+ }
+ }
+}
diff --git a/src/main/java/ctbrec/recorder/server/ProgressListener.java b/src/main/java/ctbrec/recorder/server/ProgressListener.java
new file mode 100644
index 00000000..e5a93c9e
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/server/ProgressListener.java
@@ -0,0 +1,6 @@
+package ctbrec.recorder.server;
+
+@FunctionalInterface
+public interface ProgressListener {
+ public void update(int percentage);
+}
diff --git a/src/main/java/ctbrec/recorder/server/RecorderServlet.java b/src/main/java/ctbrec/recorder/server/RecorderServlet.java
new file mode 100644
index 00000000..c0d8d549
--- /dev/null
+++ b/src/main/java/ctbrec/recorder/server/RecorderServlet.java
@@ -0,0 +1,134 @@
+package ctbrec.recorder.server;
+
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Iterator;
+import java.util.List;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.squareup.moshi.JsonAdapter;
+import com.squareup.moshi.Moshi;
+
+import ctbrec.InstantJsonAdapter;
+import ctbrec.Model;
+import ctbrec.Recording;
+import ctbrec.recorder.Recorder;
+
+public class RecorderServlet extends HttpServlet {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(RecorderServlet.class);
+
+ private Recorder recorder;
+
+ public RecorderServlet(Recorder recorder) {
+ this.recorder = recorder;
+ }
+
+ @Override
+ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+ resp.setStatus(SC_OK);
+ resp.setContentType("application/json");
+
+ try {
+ String json = body(req);
+ LOG.debug("Request: {}", json);
+ Moshi moshi = new Moshi.Builder()
+ .add(Instant.class, new InstantJsonAdapter())
+ .build();
+ JsonAdapter requestAdapter = moshi.adapter(Request.class);
+ Request request = requestAdapter.fromJson(json);
+ if(request.action != null) {
+ switch (request.action) {
+ case "start":
+ LOG.debug("Starting recording for model {} - {}", request.model.getName(), request.model.getUrl());
+ recorder.startRecording(request.model);
+ String response = "{\"status\": \"success\", \"msg\": \"Recording started\"}";
+ resp.getWriter().write(response);
+ break;
+ case "stop":
+ response = "{\"status\": \"success\", \"msg\": \"Recording stopped\"}";
+ recorder.stopRecording(request.model);
+ resp.getWriter().write(response);
+ break;
+ case "list":
+ resp.getWriter().write("{\"status\": \"success\", \"msg\": \"List of models\", \"models\": [");
+ JsonAdapter modelAdapter = moshi.adapter(Model.class);
+ List models = recorder.getModelsRecording();
+ for (Iterator iterator = models.iterator(); iterator.hasNext();) {
+ Model model = iterator.next();
+ resp.getWriter().write(modelAdapter.toJson(model));
+ if(iterator.hasNext()) {
+ resp.getWriter().write(',');
+ }
+ }
+ resp.getWriter().write("]}");
+ break;
+ case "recordings":
+ resp.getWriter().write("{\"status\": \"success\", \"msg\": \"List of recordings\", \"recordings\": [");
+ JsonAdapter recAdapter = moshi.adapter(Recording.class);
+ List recordings = recorder.getRecordings();
+ for (Iterator iterator = recordings.iterator(); iterator.hasNext();) {
+ Recording recording = iterator.next();
+ resp.getWriter().write(recAdapter.toJson(recording));
+ if (iterator.hasNext()) {
+ resp.getWriter().write(',');
+ }
+ }
+ resp.getWriter().write("]}");
+ break;
+ case "delete":
+ String path = request.recording;
+ Recording rec = new Recording(path);
+ recorder.delete(rec);
+ recAdapter = moshi.adapter(Recording.class);
+ resp.getWriter().write("{\"status\": \"success\", \"msg\": \"List of recordings\", \"recordings\": [");
+ resp.getWriter().write(recAdapter.toJson(rec));
+ resp.getWriter().write("]}");
+ break;
+ default:
+ resp.setStatus(SC_BAD_REQUEST);
+ response = "{\"status\": \"error\", \"msg\": \"Unknown action\"}";
+ resp.getWriter().write(response);
+ break;
+ }
+ } else {
+ resp.setStatus(SC_BAD_REQUEST);
+ String response = "{\"status\": \"error\", \"msg\": \"action is missing\"}";
+ resp.getWriter().write(response);
+ }
+ } catch(Throwable t) {
+ resp.setStatus(SC_INTERNAL_SERVER_ERROR);
+ String response = "{\"status\": \"error\", \"msg\": \"An unexpected error occured\"}";
+ resp.getWriter().write(response);
+ LOG.error("Unexpected error", t);
+ }
+ }
+
+ private String body(HttpServletRequest req) throws IOException {
+ StringBuilder body = new StringBuilder();
+ BufferedReader br = req.getReader();
+ String line= null;
+ while( (line = br.readLine()) != null ) {
+ body.append(line).append("\n");
+ }
+ return body.toString().trim();
+ }
+
+ private static class Request {
+ public String action;
+ public Model model;
+ public String recording;
+ }
+}
diff --git a/src/main/java/ctbrec/ui/AutosizeAlert.java b/src/main/java/ctbrec/ui/AutosizeAlert.java
new file mode 100644
index 00000000..1eabba96
--- /dev/null
+++ b/src/main/java/ctbrec/ui/AutosizeAlert.java
@@ -0,0 +1,23 @@
+package ctbrec.ui;
+
+import javafx.scene.control.Alert;
+import javafx.scene.control.ButtonType;
+import javafx.scene.layout.Region;
+
+public class AutosizeAlert extends Alert {
+
+ public AutosizeAlert(AlertType type) {
+ super(type);
+ init();
+ }
+
+ public AutosizeAlert(AlertType type, String text, ButtonType... buttons) {
+ super(type, text, buttons);
+ init();
+ }
+
+ private void init() {
+ setResizable(true);
+ getDialogPane().setMinHeight(Region.USE_PREF_SIZE);
+ }
+}
diff --git a/src/main/java/ctbrec/ui/CookieJarImpl.java b/src/main/java/ctbrec/ui/CookieJarImpl.java
new file mode 100644
index 00000000..e0bee9ee
--- /dev/null
+++ b/src/main/java/ctbrec/ui/CookieJarImpl.java
@@ -0,0 +1,76 @@
+package ctbrec.ui;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import okhttp3.Cookie;
+import okhttp3.CookieJar;
+import okhttp3.HttpUrl;
+
+public class CookieJarImpl implements CookieJar {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(CookieJarImpl.class);
+
+ private final HashMap> cookieStore = new HashMap<>();
+
+ @Override
+ public void saveFromResponse(HttpUrl url, List cookies) {
+ String host = getHost(url);
+ List cookiesForUrl = cookieStore.get(host);
+ if (cookiesForUrl != null) {
+ cookiesForUrl = new ArrayList(cookiesForUrl); //unmodifiable
+ for (Iterator iterator = cookiesForUrl.iterator(); iterator.hasNext();) {
+ Cookie oldCookie = iterator.next();
+ String name = oldCookie.name();
+ for (Cookie newCookie : cookies) {
+ if(newCookie.name().equalsIgnoreCase(name)) {
+ LOG.debug("Replacing cookie {} {} -> {} [{}]", oldCookie.name(), oldCookie.value(), newCookie.value(), oldCookie.domain());
+ iterator.remove();
+ }
+ }
+ }
+ cookiesForUrl.addAll(cookies);
+ cookieStore.put(host, cookiesForUrl);
+ LOG.debug("Adding cookie: {} for {}", cookiesForUrl, host);
+ }
+ else {
+ cookieStore.put(host, cookies);
+ LOG.debug("Storing cookie: {} for {}", cookies, host);
+ }
+ }
+
+ @Override
+ public List loadForRequest(HttpUrl url) {
+ String host = getHost(url);
+ List cookies = cookieStore.get(host);
+ LOG.debug("Cookies for {}: {}", url.host(), cookies);
+ return cookies != null ? cookies : new ArrayList();
+ }
+
+ public Cookie getCookie(HttpUrl url, String name) {
+ List cookies = loadForRequest(url);
+ for (Cookie cookie : cookies) {
+ if(Objects.equals(cookie.name(), name)) {
+ return cookie;
+ }
+ }
+ throw new NoSuchElementException("No cookie named " + name + " for " + url.host() + " available");
+ }
+
+ private String getHost(HttpUrl url) {
+ String host = url.host();
+ if (host.startsWith("www.")) {
+ host = host.substring(4);
+ }
+ return host;
+ }
+
+
+}
diff --git a/src/main/java/ctbrec/ui/DonateTabFx.java b/src/main/java/ctbrec/ui/DonateTabFx.java
new file mode 100644
index 00000000..6594bfd9
--- /dev/null
+++ b/src/main/java/ctbrec/ui/DonateTabFx.java
@@ -0,0 +1,79 @@
+package ctbrec.ui;
+
+
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Label;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TextField;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.Background;
+import javafx.scene.layout.BackgroundFill;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+import javafx.scene.paint.Color;
+import javafx.scene.text.Font;
+
+public class DonateTabFx extends Tab {
+
+ public DonateTabFx() {
+ setClosable(false);
+ setText("Donate");
+ BorderPane container = new BorderPane();
+ container.setPadding(new Insets(10));
+ container.setBackground(new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, new Insets(0))));
+ setContent(container);
+
+ VBox headerVbox = new VBox(10);
+ headerVbox.setAlignment(Pos.CENTER);
+ Label beer = new Label("Buy me some beer?!");
+ beer.setFont(new Font(36));
+ Label desc = new Label("If you like this software and want to buy me some beer or pizza, here are some possibilities!");
+ desc.setFont(new Font(24));
+ headerVbox.getChildren().addAll(beer, desc);
+ HBox header = new HBox();
+ header.setAlignment(Pos.CENTER);
+ header.getChildren().add(headerVbox);
+ header.setPadding(new Insets(20, 0, 30, 0));
+ container.setTop(header);
+
+ int prefWidth = 360;
+ TextField bitcoinAddress = new TextField("15sLWZon8diPqAX4UdPQU1DcaPuvZs2GgA");
+ bitcoinAddress.setEditable(false);
+ bitcoinAddress.setPrefWidth(prefWidth);
+ ImageView bitcoinQrCode = new ImageView(getClass().getResource("/html/bitcoin-address.png").toString());
+ Label bitcoinLabel = new Label("Bitcoin");
+ bitcoinLabel.setGraphic(new ImageView(getClass().getResource("/html/bitcoin.png").toString()));
+ VBox bitcoinBox = new VBox(5);
+ bitcoinBox.setAlignment(Pos.TOP_CENTER);
+ bitcoinBox.getChildren().addAll(bitcoinLabel, bitcoinAddress, bitcoinQrCode);
+
+ TextField ethereumAddress = new TextField("0x996041638eEAE7E31f39Ef6e82068d69bA7C090e");
+ ethereumAddress.setEditable(false);
+ ethereumAddress.setPrefWidth(prefWidth);
+ ImageView ethereumQrCode = new ImageView(getClass().getResource("/html/ethereum-address.png").toString());
+ Label ethereumLabel = new Label("Ethereum");
+ ethereumLabel.setGraphic(new ImageView(getClass().getResource("/html/ethereum.png").toString()));
+ VBox ethereumBox = new VBox(5);
+ ethereumBox.setAlignment(Pos.TOP_CENTER);
+ ethereumBox.getChildren().addAll(ethereumLabel, ethereumAddress, ethereumQrCode);
+
+ TextField moneroAddress = new TextField("448ZQZpzvT4iRNAVBr7CMQBfEbN3H8uAF2BWabtqVRckgTY3GQJkUgydjotEPaGvpzJboUpe39J8rPBkWZaUbrQa31FoSMj");
+ moneroAddress.setEditable(false);
+ moneroAddress.setPrefWidth(prefWidth);
+ ImageView moneroQrCode = new ImageView(getClass().getResource("/html/monero-address.png").toString());
+ Label moneroLabel = new Label("Monero");
+ moneroLabel.setGraphic(new ImageView(getClass().getResource("/html/monero.png").toString()));
+ VBox moneroBox = new VBox(5);
+ moneroBox.setAlignment(Pos.TOP_CENTER);
+ moneroBox.getChildren().addAll(moneroLabel, moneroAddress, moneroQrCode);
+
+ HBox coinBox = new HBox(5);
+ coinBox.setAlignment(Pos.CENTER);
+ coinBox.setSpacing(50);
+ coinBox.getChildren().addAll(bitcoinBox, ethereumBox, moneroBox);
+ container.setCenter(coinBox);
+ }
+}
diff --git a/src/main/java/ctbrec/ui/DonateTabHtml.java b/src/main/java/ctbrec/ui/DonateTabHtml.java
new file mode 100644
index 00000000..e99a8300
--- /dev/null
+++ b/src/main/java/ctbrec/ui/DonateTabHtml.java
@@ -0,0 +1,36 @@
+package ctbrec.ui;
+
+import java.net.URL;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javafx.scene.control.Tab;
+import javafx.scene.web.WebEngine;
+import javafx.scene.web.WebView;
+
+public class DonateTabHtml extends Tab {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(DonateTabHtml.class);
+
+ private WebView browser;
+
+ public DonateTabHtml() {
+ setClosable(false);
+ setText("Donate");
+
+ browser = new WebView();
+ try {
+ WebEngine webEngine = browser.getEngine();
+ URL donatePage = getClass().getResource("/html/donate.html");
+ webEngine.load(donatePage.toString());
+ webEngine.setJavaScriptEnabled(true);
+ webEngine.setOnAlert((e) -> {
+ System.out.println(e.getData());
+ });
+ setContent(browser);
+ } catch (Exception e) {
+ LOG.error("Couldn't load donate.html", e);
+ }
+ }
+}
diff --git a/src/main/java/ctbrec/ui/FollowedTab.java b/src/main/java/ctbrec/ui/FollowedTab.java
new file mode 100644
index 00000000..6b3bec9e
--- /dev/null
+++ b/src/main/java/ctbrec/ui/FollowedTab.java
@@ -0,0 +1,32 @@
+package ctbrec.ui;
+
+import javafx.concurrent.WorkerStateEvent;
+import javafx.scene.control.Label;
+
+public class FollowedTab extends ThumbOverviewTab {
+ private Label status;
+
+ public FollowedTab(String title, String url) {
+ super(title, url, true);
+ status = new Label("Logging in...");
+ grid.getChildren().add(status);
+ }
+
+ @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();
+ }
+}
diff --git a/src/main/java/ctbrec/ui/HtmlParser.java b/src/main/java/ctbrec/ui/HtmlParser.java
new file mode 100644
index 00000000..d2d545b9
--- /dev/null
+++ b/src/main/java/ctbrec/ui/HtmlParser.java
@@ -0,0 +1,48 @@
+package ctbrec.ui;
+
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+public class HtmlParser {
+
+ /**
+ * Returns the tag selected by the given selector or null
+ *
+ * @param html
+ * @param charset
+ * @param cssSelector
+ * @return the tag selected by the given selector or null
+ */
+ public static Element getTag(String html, String cssSelector) {
+ Elements selection = getTags(html, cssSelector);
+ if (selection.size() == 0) {
+ throw new RuntimeException("Bad selector. No element selected by " + cssSelector);
+ }
+ Element tag = selection.first();
+ return tag;
+ }
+
+ public static Elements getTags(String html, String cssSelector) {
+ Document doc = Jsoup.parse(html);
+ return doc.select(cssSelector);
+ }
+
+ /**
+ *
+ * @param html
+ * @param charset
+ * @param cssSelector
+ * @return The text content of the selected element or an empty string, if nothing has been selected
+ */
+ public static String getText(String html, String cssSelector) {
+ Document doc = Jsoup.parse(html);
+ Elements selection = doc.select(cssSelector);
+ if (selection.size() == 0) {
+ throw new RuntimeException("Bad selector. No element selected by " + cssSelector);
+ }
+ Element elem = selection.first();
+ return elem.text();
+ }
+}
diff --git a/src/main/java/ctbrec/ui/JavaFxModel.java b/src/main/java/ctbrec/ui/JavaFxModel.java
new file mode 100644
index 00000000..f55b7a16
--- /dev/null
+++ b/src/main/java/ctbrec/ui/JavaFxModel.java
@@ -0,0 +1,95 @@
+package ctbrec.ui;
+
+import java.util.List;
+
+import ctbrec.Model;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+
+/**
+ * Just a wrapper for Model, which augments it with JavaFX value binding properties, so that UI widgets get updated proeprly
+ */
+public class JavaFxModel extends Model {
+ private transient BooleanProperty onlineProperty = new SimpleBooleanProperty();
+
+ private Model delegate;
+
+ public JavaFxModel(Model delegate) {
+ this.delegate = delegate;
+ setOnline(delegate.isOnline());
+ }
+
+ @Override
+ public String getUrl() {
+ return delegate.getUrl();
+ }
+
+ @Override
+ public void setUrl(String url) {
+ delegate.setUrl(url);
+ }
+
+ @Override
+ public String getName() {
+ return delegate.getName();
+ }
+
+ @Override
+ public void setName(String name) {
+ delegate.setName(name);
+ }
+
+ @Override
+ public String getPreview() {
+ return delegate.getPreview();
+ }
+
+ @Override
+ public void setPreview(String preview) {
+ delegate.setPreview(preview);
+ }
+
+ @Override
+ public List getTags() {
+ return delegate.getTags();
+ }
+
+ @Override
+ public void setTags(List tags) {
+ delegate.setTags(tags);
+ }
+
+ @Override
+ public boolean isOnline() {
+ return delegate.isOnline();
+ }
+
+ @Override
+ public void setOnline(boolean online) {
+ delegate.setOnline(online);
+ this.onlineProperty.set(online);
+ }
+
+ @Override
+ public int hashCode() {
+ return delegate.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return delegate.equals(obj);
+ }
+
+ @Override
+ public String toString() {
+ return delegate.toString();
+ }
+
+ public BooleanProperty getOnlineProperty() {
+ return onlineProperty;
+ }
+
+ Model getDelegate() {
+ return delegate;
+ }
+}
diff --git a/src/main/java/ctbrec/ui/JavaFxRecording.java b/src/main/java/ctbrec/ui/JavaFxRecording.java
new file mode 100644
index 00000000..f338f9ef
--- /dev/null
+++ b/src/main/java/ctbrec/ui/JavaFxRecording.java
@@ -0,0 +1,152 @@
+package ctbrec.ui;
+
+import java.text.DecimalFormat;
+import java.time.Instant;
+
+import ctbrec.Recording;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+
+public class JavaFxRecording extends Recording {
+
+ private transient StringProperty statusProperty = new SimpleStringProperty();
+ private transient StringProperty progressProperty = new SimpleStringProperty();
+ private transient StringProperty sizeProperty = new SimpleStringProperty();
+
+ private Recording delegate;
+
+ public JavaFxRecording(Recording recording) {
+ this.delegate = recording;
+ }
+
+ @Override
+ public String getModelName() {
+ return delegate.getModelName();
+ }
+
+ @Override
+ public void setModelName(String modelName) {
+ delegate.setModelName(modelName);
+ }
+
+ @Override
+ public Instant getStartDate() {
+ return delegate.getStartDate();
+ }
+
+ @Override
+ public void setStartDate(Instant startDate) {
+ delegate.setStartDate(startDate);
+ }
+
+ @Override
+ public STATUS getStatus() {
+ return delegate.getStatus();
+ }
+
+ public StringProperty getStatusProperty() {
+ return statusProperty;
+ }
+
+ @Override
+ public void setStatus(STATUS status) {
+ delegate.setStatus(status);
+ switch(status) {
+ case RECORDING:
+ statusProperty.set("recording");
+ break;
+ case GENERATING_PLAYLIST:
+ statusProperty.set("generating playlist");
+ break;
+ case FINISHED:
+ statusProperty.set("finished");
+ break;
+ case DOWNLOADING:
+ statusProperty.set("downloading");
+ break;
+ case MERGING:
+ statusProperty.set("merging");
+ break;
+ }
+ }
+
+ @Override
+ public int getProgress() {
+ return delegate.getProgress();
+ }
+
+ @Override
+ public void setProgress(int progress) {
+ delegate.setProgress(progress);
+ if(progress >= 0) {
+ progressProperty.set(progress+"%");
+ } else {
+ progressProperty.set("");
+ }
+ }
+
+ @Override
+ public void setSizeInByte(long sizeInByte) {
+ delegate.setSizeInByte(sizeInByte);
+ double sizeInGiB = sizeInByte / 1024.0 / 1024 / 1024;
+ DecimalFormat df = new DecimalFormat("0.00");
+ sizeProperty.setValue(df.format(sizeInGiB) + " GiB");
+ }
+
+ public StringProperty getProgressProperty() {
+ return progressProperty;
+ }
+
+ @Override
+ public int hashCode() {
+ return delegate.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return delegate.equals(obj);
+ }
+
+ @Override
+ public String toString() {
+ return delegate.toString();
+ }
+
+ public void update(Recording updated) {
+ if(getStatus() != STATUS.DOWNLOADING && getStatus() != STATUS.MERGING) {
+ setStatus(updated.getStatus());
+ setProgress(updated.getProgress());
+ }
+ setSizeInByte(updated.getSizeInByte());
+ }
+
+ @Override
+ public String getPath() {
+ return delegate.getPath();
+ }
+
+ @Override
+ public void setPath(String path) {
+ delegate.setPath(path);
+ }
+
+ @Override
+ public boolean hasPlaylist() {
+ return delegate.hasPlaylist();
+ }
+
+ @Override
+ public void setHasPlaylist(boolean hasPlaylist) {
+ delegate.setHasPlaylist(hasPlaylist);
+ }
+
+ @Override
+ public long getSizeInByte() {
+ return delegate.getSizeInByte();
+ }
+
+ public StringProperty getSizeProperty() {
+ return sizeProperty;
+ }
+
+}
diff --git a/src/main/java/ctbrec/ui/Launcher.java b/src/main/java/ctbrec/ui/Launcher.java
new file mode 100644
index 00000000..2de240a1
--- /dev/null
+++ b/src/main/java/ctbrec/ui/Launcher.java
@@ -0,0 +1,138 @@
+package ctbrec.ui;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.HttpClient;
+import ctbrec.recorder.LocalRecorder;
+import ctbrec.recorder.Recorder;
+import ctbrec.recorder.RemoteRecorder;
+import javafx.application.Application;
+import javafx.application.HostServices;
+import javafx.application.Platform;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.scene.Scene;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TabPane;
+import javafx.scene.control.TabPane.TabClosingPolicy;
+import javafx.scene.image.Image;
+import javafx.stage.Stage;
+
+public class Launcher extends Application {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(Launcher.class);
+ public static final String BASE_URI = "https://chaturbate.com";
+
+ private Recorder recorder;
+ private HttpClient client;
+ private static HostServices hostServices;
+
+ @Override
+ public void start(Stage primaryStage) throws Exception {
+ hostServices = getHostServices();
+ Config config = Config.getInstance();
+ client = HttpClient.getInstance();
+ if(config.getSettings().localRecording) {
+ recorder = new LocalRecorder(config);
+ } else {
+ recorder = new RemoteRecorder(config, client);
+ }
+ if(config.getSettings().username != null && !config.getSettings().username.isEmpty()) {
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ client.login();
+ } catch (IOException e1) {
+ LOG.warn("Initial login failed" , e1);
+ }
+ };
+ }.start();
+ }
+
+ LOG.debug("Creating GUI");
+ primaryStage.setTitle("CTB Recorder");
+ InputStream icon = getClass().getResourceAsStream("/icon.png");
+ primaryStage.getIcons().add(new Image(icon));
+ TabPane root = new TabPane();
+ root.getSelectionModel().selectedItemProperty().addListener(new ChangeListener() {
+ @Override
+ public void changed(ObservableValue extends Tab> ov, Tab from, Tab to) {
+ if(from != null && from instanceof TabSelectionListener) {
+ ((TabSelectionListener) from).deselected();
+ }
+ if(to != null && to instanceof TabSelectionListener) {
+ ((TabSelectionListener) to).selected();
+ }
+ }
+ });
+ root.setTabClosingPolicy(TabClosingPolicy.SELECTED_TAB);
+ root.getTabs().add(createTab("Featured", BASE_URI + "/"));
+ root.getTabs().add(createTab("Female", BASE_URI + "/female-cams/"));
+ root.getTabs().add(createTab("Male", BASE_URI + "/male-cams/"));
+ root.getTabs().add(createTab("Couples", BASE_URI + "/couple-cams/"));
+ root.getTabs().add(createTab("Trans", BASE_URI + "/trans-cams/"));
+ FollowedTab tab = new FollowedTab("Followed", BASE_URI + "/followed-cams/");
+ tab.setRecorder(recorder);
+ root.getTabs().add(tab);
+ RecordedModelsTab modelsTab = new RecordedModelsTab("Recording", recorder);
+ root.getTabs().add(modelsTab);
+ RecordingsTab recordingsTab = new RecordingsTab("Recordings", recorder, config);
+ root.getTabs().add(recordingsTab);
+ root.getTabs().add(new SettingsTab());
+ root.getTabs().add(new DonateTabFx());
+
+ primaryStage.setScene(new Scene(root, 1340, 720));
+ primaryStage.show();
+ primaryStage.setOnCloseRequest((e) -> {
+ e.consume();
+ Alert shutdownInfo = new AutosizeAlert(Alert.AlertType.INFORMATION);
+ shutdownInfo.setTitle("Shutdown");
+ shutdownInfo.setContentText("Shutting down. Please wait a few seconds...");
+ shutdownInfo.show();
+
+ new Thread() {
+ @Override
+ public void run() {
+ recorder.shutdown();
+ client.shutdown();
+ try {
+ Config.getInstance().save();
+ LOG.info("Shutdown complete. Goodbye!");
+ Platform.exit();
+ // This is needed, because OkHttp?! seems to block the shutdown with its writer threads. They are not daemon threads :(
+ System.exit(0);
+ } catch (IOException e1) {
+ Platform.runLater(() -> {
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Error saving settings");
+ alert.setContentText("Couldn't save settings: " + e1.getLocalizedMessage());
+ alert.showAndWait();
+ System.exit(1);
+ });
+ }
+ }
+ }.start();
+ });
+ }
+
+ Tab createTab(String title, String url) {
+ ThumbOverviewTab tab = new ThumbOverviewTab(title, url, false);
+ tab.setRecorder(recorder);
+ return tab;
+ }
+
+ public static void open(String uri) {
+ hostServices.showDocument(uri);
+ }
+
+ public static void main(String[] args) {
+ launch(args);
+ }
+}
diff --git a/src/main/java/ctbrec/ui/Player.java b/src/main/java/ctbrec/ui/Player.java
new file mode 100644
index 00000000..b0b074e6
--- /dev/null
+++ b/src/main/java/ctbrec/ui/Player.java
@@ -0,0 +1,123 @@
+package ctbrec.ui;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.Recording;
+import ctbrec.recorder.OS;
+import ctbrec.recorder.StreamRedirectThread;
+
+public class Player {
+ private static final transient Logger LOG = LoggerFactory.getLogger(Player.class);
+ private static PlayerThread playerThread;
+
+ public static void play(String url) {
+ try {
+ if (playerThread != null && playerThread.isRunning()) {
+ playerThread.stopThread();
+ }
+
+ playerThread = new PlayerThread(url);
+ } catch (Exception e1) {
+ LOG.error("Couldn't start player", e1);
+ }
+ }
+
+ public static void play(Recording rec) {
+ try {
+ if (playerThread != null && playerThread.isRunning()) {
+ playerThread.stopThread();
+ }
+
+ playerThread = new PlayerThread(rec);
+ } catch (Exception e1) {
+ LOG.error("Couldn't start player", e1);
+ }
+ }
+
+ public static void stop() {
+ if (playerThread != null) {
+ playerThread.stopThread();
+ }
+ }
+
+ private static class PlayerThread extends Thread {
+ private boolean running = false;
+ private Process playerProcess;
+ private String url;
+ private Recording rec;
+
+ PlayerThread(String url) {
+ this.url = url;
+ setName(getClass().getName());
+ start();
+ }
+
+ PlayerThread(Recording rec) {
+ this.rec = rec;
+ setName(getClass().getName());
+ start();
+ }
+
+ @Override
+ public void run() {
+ running = true;
+ Runtime rt = Runtime.getRuntime();
+ try {
+ if (Config.getInstance().getSettings().localRecording && rec != null) {
+ File dir = new File(Config.getInstance().getSettings().recordingsDir, rec.getPath());
+ File file = new File(dir, "playlist.m3u8");
+ playerProcess = rt.exec(Config.getInstance().getSettings().mediaPlayer + " " + file, OS.getEnvironment(), dir);
+ } else {
+ playerProcess = rt.exec(Config.getInstance().getSettings().mediaPlayer + " " + url);
+ }
+
+ // create threads, which read stdout and stderr of the player process. these are needed,
+ // because otherwise the internal buffer for these streams fill up and block the process
+ Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), new DevNull()));
+ std.setName("Player stdout pipe");
+ std.setDaemon(true);
+ std.start();
+ Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), new DevNull()));
+ err.setName("Player stderr pipe");
+ err.setDaemon(true);
+ err.start();
+
+ playerProcess.waitFor();
+ LOG.debug("Media player finished.");
+ } catch (Exception e) {
+ LOG.error("Error in player thread", e);
+ }
+ running = false;
+ }
+
+ public boolean isRunning() {
+ return running;
+ }
+
+ public void stopThread() {
+ if (playerProcess != null) {
+ playerProcess.destroy();
+ }
+ }
+ }
+
+ private static class DevNull extends OutputStream {
+ @Override
+ public void write(int b) throws IOException {
+ }
+
+ @Override
+ public void write(byte[] b) throws IOException {
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/ctbrec/ui/RecordedModelsTab.java b/src/main/java/ctbrec/ui/RecordedModelsTab.java
new file mode 100644
index 00000000..affcd6e0
--- /dev/null
+++ b/src/main/java/ctbrec/ui/RecordedModelsTab.java
@@ -0,0 +1,228 @@
+package ctbrec.ui;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Model;
+import ctbrec.recorder.Recorder;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.concurrent.ScheduledService;
+import javafx.concurrent.Task;
+import javafx.geometry.Insets;
+import javafx.scene.Cursor;
+import javafx.scene.control.Alert;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.control.cell.CheckBoxTableCell;
+import javafx.scene.control.cell.PropertyValueFactory;
+import javafx.scene.input.Clipboard;
+import javafx.scene.input.ClipboardContent;
+import javafx.scene.input.ContextMenuEvent;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.FlowPane;
+import javafx.util.Duration;
+
+public class RecordedModelsTab extends Tab implements TabSelectionListener {
+ private static final transient Logger LOG = LoggerFactory.getLogger(RecordedModelsTab.class);
+
+ private ScheduledService> updateService;
+ private Recorder recorder;
+
+ FlowPane grid = new FlowPane();
+ ScrollPane scrollPane = new ScrollPane();
+ TableView table = new TableView();
+ ObservableList observableModels = FXCollections.observableArrayList();
+ ContextMenu popup = createContextMenu();
+
+ public RecordedModelsTab(String title, Recorder recorder) {
+ super(title);
+ this.recorder = recorder;
+ createGui();
+ setClosable(false);
+ initializeUpdateService();
+ }
+
+ @SuppressWarnings("unchecked")
+ private void createGui() {
+ grid.setPadding(new Insets(5));
+ grid.setHgap(5);
+ grid.setVgap(5);
+
+ scrollPane.setContent(grid);
+ scrollPane.setFitToHeight(true);
+ scrollPane.setFitToWidth(true);
+ BorderPane.setMargin(scrollPane, new Insets(5));
+
+ table.setEditable(false);
+ TableColumn name = new TableColumn<>("Model");
+ name.setPrefWidth(200);
+ name.setCellValueFactory(new PropertyValueFactory("name"));
+ TableColumn url = new TableColumn<>("URL");
+ url.setCellValueFactory(new PropertyValueFactory("url"));
+ url.setPrefWidth(400);
+ TableColumn online = new TableColumn<>("Online");
+ online.setCellValueFactory((cdf) -> cdf.getValue().getOnlineProperty());
+ online.setCellFactory(CheckBoxTableCell.forTableColumn(online));
+ online.setPrefWidth(60);
+ table.getColumns().addAll(name, url, online);
+ table.setItems(observableModels);
+ table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
+ popup = createContextMenu();
+ popup.show(table, event.getScreenX(), event.getScreenY());
+ event.consume();
+ });
+ table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+ if(popup != null) {
+ popup.hide();
+ }
+ });
+ scrollPane.setContent(table);
+
+ BorderPane root = new BorderPane();
+ root.setPadding(new Insets(5));
+ root.setCenter(scrollPane);
+ setContent(root);
+ }
+
+ void initializeUpdateService() {
+ updateService = createUpdateService();
+ updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2)));
+ updateService.setOnSucceeded((event) -> {
+ List models = updateService.getValue();
+ if(models == null) {
+ return;
+ }
+ for (Model model : models) {
+ if (!observableModels.contains(model)) {
+ observableModels.add(new JavaFxModel(model));
+ } else {
+ int index = observableModels.indexOf(model);
+ observableModels.get(index).setOnline(model.isOnline());
+ }
+ }
+ for (Iterator iterator = observableModels.iterator(); iterator.hasNext();) {
+ Model model = iterator.next();
+ if (!models.contains(model)) {
+ iterator.remove();
+ }
+ }
+
+ });
+ updateService.setOnFailed((event) -> {
+ LOG.info("Couldn't get list of models from recorder", event.getSource().getException());
+ });
+ }
+
+ private ScheduledService> createUpdateService() {
+ ScheduledService> updateService = new ScheduledService>() {
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public List call() {
+ LOG.debug("Updating recorded models");
+ return recorder.getModelsRecording();
+ }
+ };
+ }
+ };
+ ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() {
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setDaemon(true);
+ t.setName("RecordedModelsTab UpdateService");
+ return t;
+ }
+ });
+ updateService.setExecutor(executor);
+ return updateService;
+ }
+
+ @Override
+ public void selected() {
+ if (updateService != null) {
+ updateService.reset();
+ updateService.restart();
+ }
+ }
+
+ @Override
+ public void deselected() {
+ if (updateService != null) {
+ updateService.cancel();
+ }
+ }
+
+ private ContextMenu createContextMenu() {
+ MenuItem stop = new MenuItem("Stop Recording");
+ stop.setOnAction((e) -> stopAction());
+
+ MenuItem copyUrl = new MenuItem("Copy URL");
+ copyUrl.setOnAction((e) -> {
+ Model selected = table.getSelectionModel().getSelectedItem();
+ final Clipboard clipboard = Clipboard.getSystemClipboard();
+ final ClipboardContent content = new ClipboardContent();
+ content.putString(selected.getUrl());
+ clipboard.setContent(content);
+ });
+
+ MenuItem openInBrowser = new MenuItem("Open in Browser");
+ openInBrowser.setOnAction((e) -> Launcher.open(table.getSelectionModel().getSelectedItem().getUrl()));
+ MenuItem openInPlayer = new MenuItem("Open in Player");
+ openInPlayer.setOnAction((e) -> Player.play(table.getSelectionModel().getSelectedItem().getUrl()));
+
+ return new ContextMenu(stop, copyUrl, openInBrowser, openInPlayer);
+ }
+
+ private void stopAction() {
+ Model selected = table.getSelectionModel().getSelectedItem().getDelegate();
+ if (selected != null) {
+ table.setCursor(Cursor.WAIT);
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ recorder.stopRecording(selected);
+ observableModels.remove(selected);
+ } catch (IOException e1) {
+ LOG.error("Couldn't stop recording", e1);
+ Platform.runLater(() -> {
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Error");
+ alert.setHeaderText("Couldn't stop recording");
+ alert.setContentText("I/O error while stopping the recording: " + e1.getLocalizedMessage());
+ alert.showAndWait();
+ });
+ } catch (InterruptedException e1) {
+ LOG.error("Couldn't stop recording", e1);
+ Platform.runLater(() -> {
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Error");
+ alert.setHeaderText("Couldn't stop recording");
+ alert.setContentText("Error while stopping the recording: " + e1.getLocalizedMessage());
+ alert.showAndWait();
+ });
+ } finally {
+ table.setCursor(Cursor.DEFAULT);
+ }
+ }
+ }.start();
+ }
+ };
+}
diff --git a/src/main/java/ctbrec/ui/RecordingsTab.java b/src/main/java/ctbrec/ui/RecordingsTab.java
new file mode 100644
index 00000000..45adfbc8
--- /dev/null
+++ b/src/main/java/ctbrec/ui/RecordingsTab.java
@@ -0,0 +1,462 @@
+package ctbrec.ui;
+
+import static javafx.scene.control.ButtonType.NO;
+import static javafx.scene.control.ButtonType.YES;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+
+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.MediaPlaylist;
+import com.iheartradio.m3u8.data.Playlist;
+import com.iheartradio.m3u8.data.TrackData;
+
+import ctbrec.Config;
+import ctbrec.Recording;
+import ctbrec.Recording.STATUS;
+import ctbrec.recorder.Recorder;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.concurrent.ScheduledService;
+import javafx.concurrent.Task;
+import javafx.geometry.Insets;
+import javafx.scene.Cursor;
+import javafx.scene.control.Alert.AlertType;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.control.cell.PropertyValueFactory;
+import javafx.scene.input.ContextMenuEvent;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.FlowPane;
+import javafx.stage.FileChooser;
+import javafx.util.Duration;
+
+public class RecordingsTab extends Tab implements TabSelectionListener {
+ private static final transient Logger LOG = LoggerFactory.getLogger(RecordingsTab.class);
+
+ private ScheduledService> updateService;
+ private Config config;
+ private Recorder recorder;
+
+ FlowPane grid = new FlowPane();
+ ScrollPane scrollPane = new ScrollPane();
+ TableView table = new TableView();
+ ObservableList observableRecordings = FXCollections.observableArrayList();
+ ContextMenu popup;
+
+ public RecordingsTab(String title, Recorder recorder, Config config) {
+ super(title);
+ this.recorder = recorder;
+ this.config = config;
+ createGui();
+ setClosable(false);
+ initializeUpdateService();
+ }
+
+ @SuppressWarnings("unchecked")
+ private void createGui() {
+ grid.setPadding(new Insets(5));
+ grid.setHgap(5);
+ grid.setVgap(5);
+
+ scrollPane.setContent(grid);
+ scrollPane.setFitToHeight(true);
+ scrollPane.setFitToWidth(true);
+ BorderPane.setMargin(scrollPane, new Insets(5));
+
+ table.setEditable(false);
+ TableColumn name = new TableColumn<>("Model");
+ name.setPrefWidth(200);
+ name.setCellValueFactory(new PropertyValueFactory("modelName"));
+ TableColumn date = new TableColumn<>("Date");
+ date.setCellValueFactory(new PropertyValueFactory("startDate"));
+ date.setPrefWidth(200);
+ TableColumn status = new TableColumn<>("Status");
+ status.setCellValueFactory((cdf) -> cdf.getValue().getStatusProperty());
+ status.setPrefWidth(300);
+ TableColumn progress = new TableColumn<>("Progress");
+ progress.setCellValueFactory((cdf) -> cdf.getValue().getProgressProperty());
+ progress.setPrefWidth(100);
+ TableColumn size = new TableColumn<>("Size");
+ size.setCellValueFactory((cdf) -> cdf.getValue().getSizeProperty());
+ size.setPrefWidth(100);
+
+ table.getColumns().addAll(name, date, status, progress, size);
+ table.setItems(observableRecordings);
+ table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
+ Recording recording = table.getSelectionModel().getSelectedItem();
+ popup = createContextMenu(recording);
+ if(!popup.getItems().isEmpty()) {
+ popup.show(table, event.getScreenX(), event.getScreenY());
+ }
+ event.consume();
+ });
+ table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+ if(popup != null) {
+ popup.hide();
+ }
+ });
+ scrollPane.setContent(table);
+
+ BorderPane root = new BorderPane();
+ root.setPadding(new Insets(5));
+ root.setCenter(scrollPane);
+ setContent(root);
+ }
+
+ void initializeUpdateService() {
+ updateService = createUpdateService();
+ updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2)));
+ updateService.setOnSucceeded((event) -> {
+ List recordings = updateService.getValue();
+ if (recordings == null) {
+ return;
+ }
+
+ for (Iterator iterator = observableRecordings.iterator(); iterator.hasNext();) {
+ JavaFxRecording old = iterator.next();
+ if (!recordings.contains(old)) {
+ // remove deleted recordings
+ iterator.remove();
+ }
+ }
+ for (JavaFxRecording recording : recordings) {
+ if (!observableRecordings.contains(recording)) {
+ // add new recordings
+ observableRecordings.add(recording);
+ } else {
+ // update existing ones
+ int index = observableRecordings.indexOf(recording);
+ JavaFxRecording old = observableRecordings.get(index);
+ old.update(recording);
+ }
+ }
+ table.sort();
+ });
+ updateService.setOnFailed((event) -> {
+ LOG.info("Couldn't get list of recordings from recorder", event.getSource().getException());
+ AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR);
+ autosizeAlert.setTitle("Whoopsie!");
+ autosizeAlert.setHeaderText("Recordings not available");
+ autosizeAlert.setContentText("An error occured while retrieving the list of recordings");
+ autosizeAlert.showAndWait();
+ });
+ }
+
+ private ScheduledService> createUpdateService() {
+ ScheduledService> updateService = new ScheduledService>() {
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public List call() throws IOException {
+ List recordings = new ArrayList<>();
+ for (Recording rec : recorder.getRecordings()) {
+ recordings.add(new JavaFxRecording(rec));
+ }
+ return recordings;
+ }
+ };
+ }
+ };
+ ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() {
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setDaemon(true);
+ t.setName("RecordingsTab UpdateService");
+ return t;
+ }
+ });
+ updateService.setExecutor(executor);
+ return updateService;
+ }
+
+ @Override
+ public void selected() {
+ if (updateService != null) {
+ updateService.reset();
+ updateService.restart();
+ }
+ }
+
+ @Override
+ public void deselected() {
+ if (updateService != null) {
+ updateService.cancel();
+ }
+ }
+
+ private ContextMenu createContextMenu(Recording recording) {
+ ContextMenu contextMenu = new ContextMenu();
+ contextMenu.setHideOnEscape(true);
+ contextMenu.setAutoHide(true);
+ contextMenu.setAutoFix(true);
+
+ MenuItem openInPlayer = new MenuItem("Open in Player");
+ openInPlayer.setOnAction((e) -> {
+ play(recording);
+ });
+ if(recording.getStatus() == STATUS.FINISHED) {
+ contextMenu.getItems().add(openInPlayer);
+ }
+
+ MenuItem deleteRecording = new MenuItem("Delete");
+ deleteRecording.setOnAction((e) -> {
+ delete(recording);
+ });
+ if(recording.getStatus() == STATUS.FINISHED) {
+ contextMenu.getItems().add(deleteRecording);
+ }
+
+ MenuItem downloadRecording = new MenuItem("Download");
+ downloadRecording.setOnAction((e) -> {
+ try {
+ download(recording);
+ } catch (IOException | ParseException | PlaylistException e1) {
+ showErrorDialog("Error while downloading recording", "The recording could not be downloaded", e1);
+ LOG.error("Error while downloading recording", e1);
+ }
+ });
+ if (!Config.getInstance().getSettings().localRecording && recording.getStatus() == STATUS.FINISHED) {
+ contextMenu.getItems().add(downloadRecording);
+ }
+
+ MenuItem mergeRecording = new MenuItem("Merge segments");
+ mergeRecording.setOnAction((e) -> {
+ try {
+ merge(recording);
+ } catch (IOException e1) {
+ showErrorDialog("Error while merging recording", "The playlist does not exist", e1);
+ LOG.error("Error while merging recording", e);
+ }
+ });
+ if (Config.getInstance().getSettings().localRecording && recording.getStatus() == STATUS.FINISHED) {
+ contextMenu.getItems().add(mergeRecording);
+ }
+
+ return contextMenu;
+ }
+
+ private void merge(Recording recording) throws IOException {
+ File recDir = new File (Config.getInstance().getSettings().recordingsDir, recording.getPath());
+ File playlistFile = new File(recDir, "playlist.m3u8");
+ if(!playlistFile.exists()) {
+ table.setCursor(Cursor.DEFAULT);
+ throw new IOException("Playlist file does not exist");
+ }
+ String filename = recording.getPath().replaceAll("/", "-") + ".ts";
+ File targetFile = new File(recDir, filename);
+ if(targetFile.exists()) {
+ return;
+ }
+
+ Thread t = new Thread() {
+ @Override
+ public void run() {
+ try(
+ FileInputStream fin = new FileInputStream(playlistFile);
+ FileOutputStream fos = new FileOutputStream(targetFile))
+ {
+ PlaylistParser parser = new PlaylistParser(fin, Format.EXT_M3U, Encoding.UTF_8);
+ Playlist playlist = parser.parse();
+ MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist();
+ List tracks = mediaPlaylist.getTracks();
+ for (int i = 0; i < tracks.size(); i++) {
+ TrackData trackData = tracks.get(i);
+ File segment = new File(recDir, trackData.getUri());
+ try(FileInputStream segmentStream = new FileInputStream(segment)) {
+ int length = -1;
+ byte[] b = new byte[1024 * 1024];
+ while( (length = segmentStream.read(b)) >= 0 ) {
+ fos.write(b, 0, length);
+ }
+ int progress = (int)(i * 100.0 / tracks.size());
+ Platform.runLater(() -> {
+ recording.setStatus(STATUS.MERGING);
+ recording.setProgress(progress);
+ });
+ }
+ }
+ } catch (IOException e) {
+ showErrorDialog("Error while merging segments", "The merged file could not be created", e);
+ LOG.error("Error while merging segments", e);
+ } catch (ParseException | PlaylistException e) {
+ showErrorDialog("Error while merging recording", "Couldn't read playlist", e);
+ LOG.error("Error while merging recording", e);
+ } finally {
+ Platform.runLater(() -> {
+ recording.setStatus(STATUS.FINISHED);
+ recording.setProgress(-1);
+ });
+ }
+ };
+ };
+ t.setDaemon(true);
+ t.setName("Segment Merger " + recording.getPath());
+ t.start();
+
+ }
+
+ private void download(Recording recording) throws IOException, ParseException, PlaylistException {
+ String filename = recording.getPath().replaceAll("/", "-") + ".ts";
+ FileChooser chooser = new FileChooser();
+ chooser.setInitialFileName(filename);
+ if(config.getSettings().lastDownloadDir != null && !config.getSettings().lastDownloadDir.equals("")) {
+ File dir = new File(config.getSettings().lastDownloadDir);
+ while(!dir.exists()) {
+ dir = dir.getParentFile();
+ }
+ chooser.setInitialDirectory(dir);
+ }
+ File target = chooser.showSaveDialog(null);
+ if(target != null) {
+ config.getSettings().lastDownloadDir = target.getParent();
+ String hlsBase = "http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/hls";
+ URL url = new URL(hlsBase + "/" + recording.getPath() + "/playlist.m3u8");
+ LOG.info("Downloading {}", recording.getPath());
+ PlaylistParser parser = new PlaylistParser(url.openStream(), Format.EXT_M3U, Encoding.UTF_8);
+ Playlist playlist = parser.parse();
+ MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist();
+ List tracks = mediaPlaylist.getTracks();
+ List segmentUris = new ArrayList<>();
+ for (TrackData trackData : tracks) {
+ String segmentUri = hlsBase + "/" + recording.getPath() + "/" + trackData.getUri();
+ segmentUris.add(segmentUri);
+ }
+
+ Thread t = new Thread() {
+ @Override
+ public void run() {
+ try(FileOutputStream fos = new FileOutputStream(target)) {
+ for (int i = 0; i < segmentUris.size(); i++) {
+ URL segment = new URL(segmentUris.get(i));
+ InputStream in = segment.openStream();
+ byte[] b = new byte[1024];
+ int length = -1;
+ while( (length = in.read(b)) >= 0 ) {
+ fos.write(b, 0, length);
+ }
+ in.close();
+ int progress = (int) (i * 100.0 / segmentUris.size());
+ Platform.runLater(new Runnable() {
+ @Override
+ public void run() {
+ recording.setStatus(STATUS.DOWNLOADING);
+ recording.setProgress(progress);
+ }
+ });
+ }
+
+ Platform.runLater(new Runnable() {
+ @Override
+ public void run() {
+ recording.setStatus(STATUS.FINISHED);
+ recording.setProgress(-1);
+ }
+ });
+ } catch (FileNotFoundException e) {
+ showErrorDialog("Error while downloading recording", "The target file couldn't be created", e);
+ LOG.error("Error while downloading recording", e);
+ } catch (IOException e) {
+ showErrorDialog("Error while downloading recording", "The recording could not be downloaded", e);
+ LOG.error("Error while downloading recording", e);
+ }
+ }
+ };
+ t.setDaemon(true);
+ t.setName("Download Thread " + recording.getPath());
+ t.start();
+ }
+ }
+
+ private void showErrorDialog(final String title, final String msg, final Exception e) {
+ Platform.runLater(new Runnable() {
+ @Override
+ public void run() {
+ AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR);
+ autosizeAlert.setTitle(title);
+ autosizeAlert.setHeaderText(msg);
+ autosizeAlert.setContentText("An error occured: " + e.getLocalizedMessage());
+ autosizeAlert.showAndWait();
+ }
+ });
+ }
+
+ private void play(Recording recording) {
+ final String url;
+ if (Config.getInstance().getSettings().localRecording) {
+ new Thread() {
+ @Override
+ public void run() {
+ Player.play(recording);
+ }
+ }.start();
+ } else {
+ String hlsBase = "http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/hls";
+ url = hlsBase + "/" + recording.getPath() + "/playlist.m3u8";
+ new Thread() {
+ @Override
+ public void run() {
+ Player.play(url);
+ }
+ }.start();
+ }
+
+ }
+
+ private void delete(Recording r) {
+ table.setCursor(Cursor.WAIT);
+ String msg = "Delete " + r.getModelName() + "/" + r.getStartDate() + " for good?";
+ AutosizeAlert confirm = new AutosizeAlert(AlertType.CONFIRMATION, msg, YES, NO);
+ confirm.setTitle("Delete recording?");
+ confirm.setHeaderText(msg);
+ confirm.setContentText("");
+ confirm.showAndWait();
+ if (confirm.getResult() == ButtonType.YES) {
+ Thread deleteThread = new Thread() {
+ @Override
+ public void run() {
+ try {
+ recorder.delete(r);
+ Platform.runLater(() -> observableRecordings.remove(r));
+ } catch (IOException e1) {
+ LOG.error("Error while deleting recording", e1);
+ showErrorDialog("Error while deleting recording", "Recording not deleted", e1);
+ } finally {
+ table.setCursor(Cursor.DEFAULT);
+ }
+ }
+ };
+ deleteThread.start();
+ } else {
+ table.setCursor(Cursor.DEFAULT);
+ }
+ }
+}
diff --git a/src/main/java/ctbrec/ui/SettingsTab.java b/src/main/java/ctbrec/ui/SettingsTab.java
new file mode 100644
index 00000000..1ee69c25
--- /dev/null
+++ b/src/main/java/ctbrec/ui/SettingsTab.java
@@ -0,0 +1,275 @@
+package ctbrec.ui;
+
+import java.io.File;
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.geometry.Insets;
+import javafx.scene.Node;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Alert.AlertType;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.PasswordField;
+import javafx.scene.control.RadioButton;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TextField;
+import javafx.scene.control.ToggleGroup;
+import javafx.scene.control.Tooltip;
+import javafx.scene.layout.Border;
+import javafx.scene.layout.BorderStroke;
+import javafx.scene.layout.BorderStrokeStyle;
+import javafx.scene.layout.BorderWidths;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.Priority;
+import javafx.scene.paint.Color;
+import javafx.stage.DirectoryChooser;
+import javafx.stage.FileChooser;;
+
+public class SettingsTab extends Tab {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(SettingsTab.class);
+
+ private TextField recordingsDirectory;
+ private TextField mediaPlayer;
+ private TextField username;
+ private TextField server;
+ private TextField port;
+ private PasswordField password;
+ private RadioButton recordLocal;
+ private RadioButton recordRemote;
+ private ToggleGroup recordLocation;
+
+ public SettingsTab() {
+ setText("Settings");
+ createGui();
+ setClosable(false);
+ }
+
+ private void createGui() {
+ GridPane layout = new GridPane();
+ layout.setOpacity(1);
+ layout.setPadding(new Insets(5));
+ layout.setHgap(5);
+ layout.setVgap(5);
+ setContent(layout);
+
+ int row = 0;
+ layout.add(new Label("Recordings Directory"), 0, row);
+ recordingsDirectory = new TextField(Config.getInstance().getSettings().recordingsDir);
+ recordingsDirectory.focusedProperty().addListener(createRecordingsDirectoryFocusListener());
+ recordingsDirectory.setPrefWidth(400);
+ GridPane.setFillWidth(recordingsDirectory, true);
+ GridPane.setHgrow(recordingsDirectory, Priority.ALWAYS);
+ GridPane.setColumnSpan(recordingsDirectory, 2);
+ layout.add(recordingsDirectory, 1, row);
+ layout.add(createRecordingsBrowseButton(), 3, row);
+
+ layout.add(new Label("Player"), 0, ++row);
+ mediaPlayer = new TextField(Config.getInstance().getSettings().mediaPlayer);
+ mediaPlayer.focusedProperty().addListener(createMpvFocusListener());
+ GridPane.setFillWidth(mediaPlayer, true);
+ GridPane.setHgrow(mediaPlayer, Priority.ALWAYS);
+ GridPane.setColumnSpan(mediaPlayer, 2);
+ layout.add(mediaPlayer, 1, row);
+ layout.add(createMpvBrowseButton(), 3, row);
+
+ layout.add(new Label("Chaturbate User"), 0, ++row);
+ username = new TextField(Config.getInstance().getSettings().username);
+ username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().username = username.getText());
+ GridPane.setFillWidth(username, true);
+ GridPane.setHgrow(username, Priority.ALWAYS);
+ GridPane.setColumnSpan(username, 2);
+ layout.add(username, 1, row);
+
+ layout.add(new Label("Chaturbate Password"), 0, ++row);
+ password = new PasswordField();
+ password.setText(Config.getInstance().getSettings().password);
+ password.focusedProperty().addListener((e) -> {
+ if(!password.getText().isEmpty()) {
+ Config.getInstance().getSettings().password = password.getText();
+ }
+ });
+ GridPane.setFillWidth(password, true);
+ GridPane.setHgrow(password, Priority.ALWAYS);
+ GridPane.setColumnSpan(password, 2);
+ layout.add(password, 1, row);
+
+ layout.add(new Label(), 0, ++row);
+
+ layout.add(new Label("Record Location"), 0, ++row);
+ recordLocation = new ToggleGroup();
+ recordLocal = new RadioButton("Local");
+ recordRemote = new RadioButton("Remote");
+ recordLocal.setToggleGroup(recordLocation);
+ recordRemote.setToggleGroup(recordLocation);
+ recordLocal.setSelected(Config.getInstance().getSettings().localRecording);
+ recordRemote.setSelected(!recordLocal.isSelected());
+ layout.add(recordLocal, 1, row);
+ layout.add(recordRemote, 2, row);
+ recordLocation.selectedToggleProperty().addListener((e) -> {
+ Config.getInstance().getSettings().localRecording = recordLocal.isSelected();
+ server.setDisable(recordLocal.isSelected());
+ port.setDisable(recordLocal.isSelected());
+
+ Alert restart = new AutosizeAlert(AlertType.INFORMATION);
+ restart.setTitle("Restart required");
+ restart.setHeaderText("Restart required");
+ restart.setContentText("Changes get applied after a restart of the application");
+ restart.show();
+ });
+
+ layout.add(new Label("Server"), 0, ++row);
+ server = new TextField(Config.getInstance().getSettings().httpServer);
+ server.focusedProperty().addListener((e) -> {
+ if(!server.getText().isEmpty()) {
+ Config.getInstance().getSettings().httpServer = server.getText();
+ }
+ });
+ GridPane.setFillWidth(server, true);
+ GridPane.setHgrow(server, Priority.ALWAYS);
+ GridPane.setColumnSpan(server, 2);
+ layout.add(server, 1, row);
+
+ layout.add(new Label("Port"), 0, ++row);
+ port = new TextField(Integer.toString(Config.getInstance().getSettings().httpPort));
+ port.focusedProperty().addListener((e) -> {
+ if(!port.getText().isEmpty()) {
+ try {
+ Config.getInstance().getSettings().httpPort = Integer.parseInt(port.getText());
+ port.setBorder(Border.EMPTY);
+ port.setTooltip(null);
+ } catch (NumberFormatException e1) {
+ port.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
+ port.setTooltip(new Tooltip("Port has to be a number in the range 1 - 65536"));
+ }
+ }
+ });
+ GridPane.setFillWidth(port, true);
+ GridPane.setHgrow(port, Priority.ALWAYS);
+ GridPane.setColumnSpan(port, 2);
+ layout.add(port, 1, row);
+
+ server.setDisable(recordLocal.isSelected());
+ port.setDisable(recordLocal.isSelected());
+ }
+
+ private ChangeListener super Boolean> createRecordingsDirectoryFocusListener() {
+ return new ChangeListener() {
+ @Override
+ public void changed(ObservableValue extends Boolean> arg0, Boolean oldPropertyValue, Boolean newPropertyValue) {
+ if (newPropertyValue) {
+ recordingsDirectory.setBorder(Border.EMPTY);
+ recordingsDirectory.setTooltip(null);
+ } else {
+ String input = recordingsDirectory.getText();
+ File newDir = new File(input);
+ setRecordingsDir(newDir);
+ }
+ }
+ };
+ }
+
+ private ChangeListener super Boolean> createMpvFocusListener() {
+ return new ChangeListener() {
+ @Override
+ public void changed(ObservableValue extends Boolean> arg0, Boolean oldPropertyValue, Boolean newPropertyValue) {
+ if (newPropertyValue) {
+ mediaPlayer.setBorder(Border.EMPTY);
+ mediaPlayer.setTooltip(null);
+ } else {
+ String input = mediaPlayer.getText();
+ File program = new File(input);
+ setMpv(program);
+ }
+ }
+ };
+ }
+
+ private void setMpv(File program) {
+ String msg = validateProgram(program);
+ if (msg != null) {
+ mediaPlayer.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
+ mediaPlayer.setTooltip(new Tooltip(msg));
+ } else {
+ Config.getInstance().getSettings().mediaPlayer = mediaPlayer.getText();
+ }
+ }
+
+ private String validateProgram(File program) {
+ if (program == null || !program.exists()) {
+ return "File does not exist";
+ } else if (!program.isFile() || !program.canExecute()) {
+ return "This is not an executable application";
+ }
+ return null;
+ }
+
+ private Node createRecordingsBrowseButton() {
+ Button button = new Button("Select");
+ button.setOnAction((e) -> {
+ DirectoryChooser chooser = new DirectoryChooser();
+ File currentDir = new File(Config.getInstance().getSettings().recordingsDir);
+ if (currentDir.exists() && currentDir.isDirectory()) {
+ chooser.setInitialDirectory(currentDir);
+ }
+ File selectedDir = chooser.showDialog(null);
+ if(selectedDir != null) {
+ setRecordingsDir(selectedDir);
+ }
+ });
+ return button;
+ }
+
+ private Node createMpvBrowseButton() {
+ Button button = new Button("Select");
+ button.setOnAction((e) -> {
+ FileChooser chooser = new FileChooser();
+ File program = chooser.showOpenDialog(null);
+ if(program != null) {
+ try {
+ mediaPlayer.setText(program.getCanonicalPath());
+ } catch (IOException e1) {
+ LOG.error("Couldn't determine path", e1);
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Whoopsie");
+ alert.setContentText("Couldn't determine path");
+ alert.showAndWait();
+ }
+ setMpv(program);
+ }
+ });
+ return button;
+ }
+
+ private void setRecordingsDir(File dir) {
+ if (dir != null && dir.isDirectory()) {
+ try {
+ String path = dir.getCanonicalPath();
+ Config.getInstance().getSettings().recordingsDir = path;
+ recordingsDirectory.setText(path);
+ } catch (IOException e1) {
+ LOG.error("Couldn't determine directory path", e1);
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Whoopsie");
+ alert.setContentText("Couldn't determine directory path");
+ alert.showAndWait();
+ }
+ } else {
+ recordingsDirectory.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
+ if (!dir.isDirectory()) {
+ recordingsDirectory.setTooltip(new Tooltip("This is not a directory"));
+ }
+ if (!dir.exists()) {
+ recordingsDirectory.setTooltip(new Tooltip("Directory does not exist"));
+ }
+
+ }
+ }
+}
diff --git a/src/main/java/ctbrec/ui/TabSelectionListener.java b/src/main/java/ctbrec/ui/TabSelectionListener.java
new file mode 100644
index 00000000..318dbcb6
--- /dev/null
+++ b/src/main/java/ctbrec/ui/TabSelectionListener.java
@@ -0,0 +1,6 @@
+package ctbrec.ui;
+
+public interface TabSelectionListener {
+ public void selected();
+ public void deselected();
+}
diff --git a/src/main/java/ctbrec/ui/ThumbCell.java b/src/main/java/ctbrec/ui/ThumbCell.java
new file mode 100644
index 00000000..0bfdb56f
--- /dev/null
+++ b/src/main/java/ctbrec/ui/ThumbCell.java
@@ -0,0 +1,396 @@
+package ctbrec.ui;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.HttpClient;
+import ctbrec.Model;
+import ctbrec.recorder.Chaturbate;
+import ctbrec.recorder.Recorder;
+import ctbrec.recorder.StreamInfo;
+import javafx.animation.FadeTransition;
+import javafx.animation.FillTransition;
+import javafx.animation.Interpolator;
+import javafx.animation.ParallelTransition;
+import javafx.animation.Transition;
+import javafx.application.Platform;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.ObservableList;
+import javafx.event.EventHandler;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Cursor;
+import javafx.scene.Node;
+import javafx.scene.control.Alert;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.MenuItem;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.ContextMenuEvent;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.StackPane;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.Circle;
+import javafx.scene.shape.Rectangle;
+import javafx.scene.shape.Shape;
+import javafx.scene.text.Font;
+import javafx.scene.text.Text;
+import javafx.scene.text.TextAlignment;
+import javafx.util.Duration;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+public class ThumbCell extends StackPane {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(ThumbCell.class);
+
+ private static final int WIDTH = 180;
+ private static final int HEIGHT = 135;
+ private static final Duration ANIMATION_DURATION = new Duration(250);
+
+ private Model model;
+ private ImageView iv;
+ private Rectangle nameBackground;
+ private Rectangle topicBackground;
+ private Text name;
+ private Text topic;
+ private Recorder recorder;
+ private Circle recordingIndicator;
+ private FadeTransition recordingAnimation;
+ private int index = 0;
+ ContextMenu popup;
+ private Color colorNormal = Color.BLACK;
+ private Color colorHighlight = Color.WHITE;
+ private Color colorRecording = new Color(0.8, 0.28, 0.28, 1);
+
+ private HttpClient client;
+
+ private ObservableList thumbCellList;
+
+
+ public ThumbCell(ThumbOverviewTab parent, Model model, Recorder recorder, HttpClient client) {
+ this.thumbCellList = parent.grid.getChildren();
+ this.model = model;
+ this.recorder = recorder;
+ this.client = client;
+ boolean recording = recorder.isRecording(model);
+
+ iv = new ImageView();
+ setImage(model.getPreview());
+ iv.setFitWidth(WIDTH);
+ iv.setFitHeight(HEIGHT);
+ iv.setSmooth(true);
+ iv.setCache(true);
+ getChildren().add(iv);
+
+ nameBackground = new Rectangle(WIDTH, 20);
+ nameBackground.setFill(recording ? colorRecording : colorNormal);
+ nameBackground.setOpacity(.7);
+ StackPane.setAlignment(nameBackground, Pos.BOTTOM_CENTER);
+ getChildren().add(nameBackground);
+
+ topicBackground = new Rectangle(WIDTH, 115);
+ topicBackground.setFill(Color.BLACK);
+ topicBackground.setOpacity(0);
+ StackPane.setAlignment(topicBackground, Pos.TOP_LEFT);
+ getChildren().add(topicBackground);
+
+ name = new Text(model.getName());
+ name.setFill(Color.WHITE);
+ name.setFont(new Font("Sansserif", 16));
+ name.setTextAlignment(TextAlignment.CENTER);
+ name.prefHeight(25);
+ StackPane.setAlignment(name, Pos.BOTTOM_CENTER);
+ getChildren().add(name);
+
+ topic = new Text(model.getDescription());
+
+ topic.setFill(Color.WHITE);
+ topic.setFont(new Font("Sansserif", 13));
+ topic.setTextAlignment(TextAlignment.LEFT);
+ topic.setOpacity(0);
+ topic.prefHeight(110);
+ topic.maxHeight(110);
+ int margin = 4;
+ topic.maxWidth(WIDTH-margin*2);
+ topic.setWrappingWidth(WIDTH-margin*2);
+ StackPane.setMargin(topic, new Insets(margin));
+ StackPane.setAlignment(topic, Pos.TOP_CENTER);
+ getChildren().add(topic);
+
+ recordingIndicator = new Circle(8);
+ recordingIndicator.setFill(colorRecording);
+ StackPane.setMargin(recordingIndicator, new Insets(3));
+ StackPane.setAlignment(recordingIndicator, Pos.TOP_RIGHT);
+ getChildren().add(recordingIndicator);
+ recordingAnimation = new FadeTransition(Duration.millis(1000), recordingIndicator);
+ recordingAnimation.setInterpolator(Interpolator.EASE_BOTH);
+ recordingAnimation.setFromValue(1.0);
+ recordingAnimation.setToValue(0);
+ recordingAnimation.setCycleCount(FadeTransition.INDEFINITE);
+ recordingAnimation.setAutoReverse(true);
+
+ setOnMouseEntered((e) -> {
+ new ParallelTransition(changeColor(nameBackground, colorNormal, colorHighlight), changeColor(name, colorHighlight, colorNormal)).playFromStart();
+ new ParallelTransition(changeOpacity(topicBackground, 0.7), changeOpacity(topic, 0.7)).playFromStart();
+ });
+ setOnMouseExited((e) -> {
+ new ParallelTransition(changeColor(nameBackground, colorHighlight, colorNormal), changeColor(name, colorNormal, colorHighlight)).playFromStart();
+ new ParallelTransition(changeOpacity(topicBackground, 0), changeOpacity(topic, 0)).playFromStart();
+ });
+ setOnMouseClicked(doubleClickListener);
+ addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
+ parent.suspendUpdates(true);
+ popup = createContextMenu();
+ popup.show(ThumbCell.this, event.getScreenX(), event.getScreenY());
+ popup.setOnHidden((e) -> parent.suspendUpdates(false));
+ event.consume();
+ });
+ addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+ if(popup != null) {
+ popup.hide();
+ }
+ });
+
+ setMinSize(WIDTH, HEIGHT);
+ setPrefSize(WIDTH, HEIGHT);
+
+ setRecording(recording);
+ }
+
+ private void setImage(String url) {
+ if(!Objects.equals(System.getenv("CTBREC_THUMBS"), "0")) {
+ Image img = new Image(url, true);
+
+ // wait for the image to load, otherwise the ImageView replaces the current image with an "empty" image,
+ // which causes to show the grey background until the image is loaded
+ img.progressProperty().addListener(new ChangeListener() {
+ @Override
+ public void changed(ObservableValue extends Number> observable, Number oldValue, Number newValue) {
+ if(newValue.doubleValue() == 1.0) {
+ iv.setImage(img);
+ }
+ }
+ });
+ }
+ }
+
+ private ContextMenu createContextMenu() {
+ MenuItem openInPlayer = new MenuItem("Open in Player");
+ openInPlayer.setOnAction((e) -> startPlayer());
+
+ MenuItem start = new MenuItem("Start Recording");
+ start.setOnAction((e) -> startStopAction(true));
+ MenuItem stop = new MenuItem("Stop Recording");
+ stop.setOnAction((e) -> startStopAction(false));
+ MenuItem startStop = recorder.isRecording(model) ? stop : start;
+
+ MenuItem follow = new MenuItem("Follow");
+ follow.setOnAction((e) -> follow(true));
+ MenuItem unfollow = new MenuItem("Unfollow");
+ unfollow.setOnAction((e) -> follow(false));
+
+ ContextMenu contextMenu = new ContextMenu();
+ contextMenu.setAutoHide(true);
+ contextMenu.setHideOnEscape(true);
+ contextMenu.setAutoFix(true);
+ contextMenu.getItems().addAll(openInPlayer, startStop , follow, unfollow);
+ return contextMenu;
+ }
+
+ private Transition changeColor(Shape shape, Color from, Color to) {
+ FillTransition transition = new FillTransition(ANIMATION_DURATION, from, to);
+ transition.setShape(shape);
+ return transition;
+ }
+
+ private Transition changeOpacity(Shape shape, double opacity) {
+ FadeTransition transition = new FadeTransition(ANIMATION_DURATION, shape);
+ transition.setFromValue(shape.getOpacity());
+ transition.setToValue(opacity);
+ return transition;
+ }
+
+ private EventHandler doubleClickListener = new EventHandler() {
+ @Override
+ public void handle(MouseEvent e) {
+ if(e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
+ startPlayer();
+ }
+ }
+ };
+
+ private void startPlayer() {
+ try {
+ StreamInfo streamInfo = Chaturbate.getStreamInfo(model, client);
+ if(streamInfo.room_status.equals("public")) {
+ Player.play(streamInfo.url);
+ } else {
+ Alert alert = new AutosizeAlert(Alert.AlertType.INFORMATION);
+ alert.setTitle("Room not public");
+ alert.setHeaderText("Room is currently not public");
+ alert.showAndWait();
+ }
+ } catch (IOException e1) {
+ LOG.error("Couldn't get stream information for model {}", model, e1);
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Error");
+ alert.setHeaderText("Couldn't determine stream URL");
+ alert.showAndWait();
+ }
+ }
+
+ private void setRecording(boolean recording) {
+ if(recording) {
+ recordingAnimation.playFromStart();
+ colorNormal = colorRecording;
+ } else {
+ colorNormal = Color.BLACK;
+ recordingAnimation.stop();
+ }
+ recordingIndicator.setVisible(recording);
+ }
+
+ private void startStopAction(boolean start) {
+ setCursor(Cursor.WAIT);
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ if(start) {
+ recorder.startRecording(model);
+ } else {
+ recorder.stopRecording(model);
+ }
+ setRecording(start);
+ } catch (Exception e1) {
+ LOG.error("Couldn't start/stop recording", e1);
+ Platform.runLater(() -> {
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Error");
+ alert.setHeaderText("Couldn't start/stop recording");
+ alert.setContentText("I/O error while starting/stopping the recording: " + e1.getLocalizedMessage());
+ alert.showAndWait();
+ });
+ } finally {
+ setCursor(Cursor.DEFAULT);
+ }
+ }
+ }.start();
+ }
+
+ private void follow(boolean follow) {
+ setCursor(Cursor.WAIT);
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ Request req = new Request.Builder().url(model.getUrl()).build();
+ Response resp = HttpClient.getInstance().execute(req);
+ resp.close();
+
+ String url = null;
+ if(follow) {
+ url = Launcher.BASE_URI + "/follow/follow/" + model.getName() + "/";
+ } else {
+ url = Launcher.BASE_URI + "/follow/unfollow/" + model.getName() + "/";
+ }
+
+ RequestBody body = RequestBody.create(null, new byte[0]);
+ req = new Request.Builder()
+ .url(url)
+ .method("POST", body)
+ .header("Accept", "*/*")
+ //.header("Accept-Encoding", "gzip, deflate, br")
+ .header("Accept-Language", "en-US,en;q=0.5")
+ .header("Referer", model.getUrl())
+ .header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0")
+ .header("X-CSRFToken", HttpClient.getInstance().getToken())
+ .header("X-Requested-With", "XMLHttpRequest")
+ .build();
+ resp = HttpClient.getInstance().execute(req, true);
+ if(resp.isSuccessful()) {
+ String msg = resp.body().string();
+ if(!msg.equalsIgnoreCase("ok")) {
+ LOG.debug(msg);
+ throw new IOException("Response was " + msg.substring(0, Math.min(msg.length(), 500)));
+ } else {
+ if(!follow) {
+ Platform.runLater(() -> thumbCellList.remove(ThumbCell.this));
+ }
+ }
+ } else {
+ resp.close();
+ throw new IOException("HTTP status " + resp.code() + " " + resp.message());
+ }
+ } catch (Exception e1) {
+ LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1);
+ Platform.runLater(() -> {
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Error");
+ alert.setHeaderText("Couldn't follow/unfollow model");
+ alert.setContentText("I/O error while following/unfollowing model " + model.getName() + ": " + e1.getLocalizedMessage());
+ alert.showAndWait();
+ });
+ } finally {
+ setCursor(Cursor.DEFAULT);
+ }
+ }
+ }.start();
+ }
+
+ public Model getModel() {
+ return model;
+ }
+
+ public void setModel(Model model) {
+ this.model = model;
+ update();
+ }
+
+ public int getIndex() {
+ return index;
+ }
+
+ public void setIndex(int index) {
+ this.index = index;
+ }
+
+ private void update() {
+ setImage(model.getPreview());
+ topic.setText(model.getDescription());
+ setRecording(recorder.isRecording(model));
+ requestLayout();
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((model == null) ? 0 : model.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (getClass() != obj.getClass())
+ return false;
+ ThumbCell other = (ThumbCell) obj;
+ if (model == null) {
+ if (other.model != null)
+ return false;
+ } else if (!model.equals(other.model))
+ return false;
+ return true;
+ }
+}
diff --git a/src/main/java/ctbrec/ui/ThumbOverviewTab.java b/src/main/java/ctbrec/ui/ThumbOverviewTab.java
new file mode 100644
index 00000000..5b013e73
--- /dev/null
+++ b/src/main/java/ctbrec/ui/ThumbOverviewTab.java
@@ -0,0 +1,371 @@
+package ctbrec.ui;
+
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.HttpClient;
+import ctbrec.Model;
+import ctbrec.ModelParser;
+import ctbrec.recorder.Recorder;
+import javafx.collections.ObservableList;
+import javafx.concurrent.ScheduledService;
+import javafx.concurrent.Task;
+import javafx.concurrent.Worker.State;
+import javafx.concurrent.WorkerStateEvent;
+import javafx.geometry.Insets;
+import javafx.scene.Node;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Button;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TextField;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.FlowPane;
+import javafx.scene.layout.HBox;
+import javafx.util.Duration;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class ThumbOverviewTab extends Tab implements TabSelectionListener {
+ private static final transient Logger LOG = LoggerFactory.getLogger(ThumbOverviewTab.class);
+
+ ScheduledService> updateService;
+ Recorder recorder;
+ List filteredThumbCells = Collections.synchronizedList(new ArrayList<>());
+ String filter;
+ FlowPane grid = new FlowPane();
+ ReentrantLock gridLock = new ReentrantLock();
+ ScrollPane scrollPane = new ScrollPane();
+ String url;
+ boolean loginRequired;
+ HttpClient client = HttpClient.getInstance();
+ int page = 1;
+ TextField pageInput = new TextField(Integer.toString(page));
+ Button pagePrev = new Button("◀");
+ Button pageNext = new Button("▶");
+ private volatile boolean updatesSuspended = false;
+
+ public ThumbOverviewTab(String title, String url, boolean loginRequired) {
+ super(title);
+ this.url = url;
+ this.loginRequired = loginRequired;
+ setClosable(false);
+ createGui();
+ initializeUpdateService();
+ }
+
+ private void createGui() {
+ grid.setPadding(new Insets(5));
+ grid.setHgap(5);
+ grid.setVgap(5);
+
+ TextField search = new TextField();
+ search.setPromptText("Filter");
+ search.textProperty().addListener( (observableValue, oldValue, newValue) -> {
+ filter = search.getText();
+ filter();
+ });
+ BorderPane.setMargin(search, new Insets(5));
+
+ scrollPane.setContent(grid);
+ scrollPane.setFitToHeight(true);
+ scrollPane.setFitToWidth(true);
+ BorderPane.setMargin(scrollPane, new Insets(5));
+
+ HBox pagination = new HBox(5);
+ pagination.getChildren().add(pagePrev);
+ pagination.getChildren().add(pageNext);
+ pagination.getChildren().add(pageInput);
+ BorderPane.setMargin(pagination, new Insets(5));
+ pageInput.setPrefWidth(50);
+ pageInput.setOnAction((e) -> handlePageNumberInput());
+ pagePrev.setOnAction((e) -> {
+ page = Math.max(1, --page);
+ pageInput.setText(Integer.toString(page));
+ restartUpdateService();
+ });
+ pageNext.setOnAction((e) -> {
+ page++;
+ pageInput.setText(Integer.toString(page));
+ restartUpdateService();
+ });
+
+ BorderPane root = new BorderPane();
+ root.setPadding(new Insets(5));
+ root.setTop(search);
+ root.setCenter(scrollPane);
+ root.setBottom(pagination);
+ setContent(root);
+ }
+
+ private void handlePageNumberInput() {
+ try {
+ page = Integer.parseInt(pageInput.getText());
+ page = Math.max(1, page);
+ restartUpdateService();
+ } catch(NumberFormatException e) {
+ } finally {
+ pageInput.setText(Integer.toString(page));
+ }
+ }
+
+ private void restartUpdateService() {
+ gridLock.lock();
+ try {
+ grid.getChildren().clear();
+ filteredThumbCells.clear();
+ deselected();
+ selected();
+ } finally {
+ gridLock.unlock();
+ }
+ }
+
+ void initializeUpdateService() {
+ updateService = createUpdateService();
+ updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(10)));
+ updateService.setOnSucceeded((event) -> onSuccess());
+ updateService.setOnFailed((event) -> onFail(event));
+ }
+
+ protected void onSuccess() {
+ if(updatesSuspended) {
+ return;
+ }
+ gridLock.lock();
+ try {
+ List models = updateService.getValue();
+ ObservableList nodes = grid.getChildren();
+
+ // first remove models, which are not in the updated list
+ for (Iterator iterator = nodes.iterator(); iterator.hasNext();) {
+ Node node = iterator.next();
+ if (!(node instanceof ThumbCell)) continue;
+ ThumbCell cell = (ThumbCell) node;
+ if(!models.contains(cell.getModel())) {
+ iterator.remove();
+ }
+ }
+
+ List positionChangedOrNew = new ArrayList<>();
+ int index = 0;
+ for (Model model : models) {
+ boolean found = false;
+ for (Iterator iterator = nodes.iterator(); iterator.hasNext();) {
+ Node node = iterator.next();
+ if (!(node instanceof ThumbCell)) continue;
+ ThumbCell cell = (ThumbCell) node;
+ if(cell.getModel().equals(model)) {
+ found = true;
+ cell.setModel(model);
+ if(index != cell.getIndex()) {
+ cell.setIndex(index);
+ positionChangedOrNew.add(cell);
+ }
+ }
+ }
+ if(!found) {
+ ThumbCell newCell = new ThumbCell(this, model, recorder, client);
+ newCell.setIndex(index);
+ positionChangedOrNew.add(newCell);
+ }
+ index++;
+ }
+ for (ThumbCell thumbCell : positionChangedOrNew) {
+ nodes.remove(thumbCell);
+ if(thumbCell.getIndex() < nodes.size()) {
+ nodes.add(thumbCell.getIndex(), thumbCell);
+ } else {
+ nodes.add(thumbCell);
+ }
+ }
+ } finally {
+ gridLock.unlock();
+ }
+
+ filter();
+ }
+
+ protected void onFail(WorkerStateEvent event) {
+ if(updatesSuspended) {
+ return;
+ }
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Error");
+ alert.setHeaderText("Couldn't fetch model list");
+ if(event.getSource().getException() != null) {
+ alert.setContentText(event.getSource().getException().getLocalizedMessage());
+ } else {
+ alert.setContentText(event.getEventType().toString());
+ }
+ alert.showAndWait();
+ }
+
+ private void filter() {
+ Collections.sort(filteredThumbCells, new Comparator() {
+ @Override
+ public int compare(Node o1, Node o2) {
+ ThumbCell c1 = (ThumbCell) o1;
+ ThumbCell c2 = (ThumbCell) o2;
+
+ if(c1.getIndex() < c2.getIndex()) return -1;
+ if(c1.getIndex() > c2.getIndex()) return 1;
+ return c1.getModel().getName().compareTo(c2.getModel().getName());
+ }
+ });
+
+
+ gridLock.lock();
+ try {
+ if (filter == null || filter.isEmpty()) {
+ for (ThumbCell thumbCell : filteredThumbCells) {
+ insert(thumbCell);
+ }
+ moveActiveRecordingsToFront();
+ return;
+ }
+
+ // remove the ones from grid, which don't match
+ for (Iterator iterator = grid.getChildren().iterator(); iterator.hasNext();) {
+ Node node = iterator.next();
+ ThumbCell cell = (ThumbCell) node;
+ Model m = cell.getModel();
+ if(!matches(m, filter)) {
+ iterator.remove();
+ filteredThumbCells.add(cell);
+ }
+ }
+
+ // add the ones, which might have been filtered before, but now match
+ for (Iterator iterator = filteredThumbCells.iterator(); iterator.hasNext();) {
+ ThumbCell thumbCell = iterator.next();
+ Model m = thumbCell.getModel();
+ if(matches(m, filter)) {
+ iterator.remove();
+ insert(thumbCell);
+ }
+ }
+
+ moveActiveRecordingsToFront();
+ } finally {
+ gridLock.unlock();
+ }
+ }
+
+ private void moveActiveRecordingsToFront() {
+ // move active recordings to the front
+ ObservableList thumbs = grid.getChildren();
+ for (int i = thumbs.size()-1; i > 0; i--) {
+ ThumbCell thumb = (ThumbCell) thumbs.get(i);
+ if(recorder.isRecording(thumb.getModel())) {
+ thumbs.remove(i);
+ thumbs.add(0, thumb);
+ }
+ }
+ }
+
+ private void insert(ThumbCell thumbCell) {
+ if(grid.getChildren().contains(thumbCell)) {
+ return;
+ }
+
+ if(thumbCell.getIndex() < grid.getChildren().size()-1) {
+ grid.getChildren().add(thumbCell.getIndex(), thumbCell);
+ } else {
+ grid.getChildren().add(thumbCell);
+ }
+ }
+
+ private boolean matches(Model m, String filter) {
+ String[] tokens = filter.split(" ");
+ StringBuilder searchTextBuilder = new StringBuilder(m.getName());
+ searchTextBuilder.append(' ');
+ for (String tag : m.getTags()) {
+ searchTextBuilder.append(tag).append(' ');
+ }
+ String searchText = searchTextBuilder.toString().trim();
+ boolean tokensMissing = false;
+ for (String token : tokens) {
+ if(!searchText.contains(token)) {
+ tokensMissing = true;
+ }
+ }
+ return !tokensMissing;
+ }
+
+ private ScheduledService> createUpdateService() {
+ ScheduledService> updateService = new ScheduledService>() {
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public List call() throws IOException {
+ String url = ThumbOverviewTab.this.url + "?page="+page+"&keywords=&_=" + System.currentTimeMillis();
+ LOG.debug("Fetching page {}", url);
+ Request request = new Request.Builder().url(url).build();
+ Response response = client.execute(request, loginRequired);
+ if (response.isSuccessful()) {
+ List models = ModelParser.parseModels(response.body().string());
+ response.close();
+ return models;
+ } else {
+ int code = response.code();
+ response.close();
+ throw new IOException("HTTP status " + code);
+ }
+ }
+ };
+ }
+ };
+ 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;
+ }
+ });
+ updateService.setExecutor(executor);
+ return updateService;
+ }
+
+ public void setRecorder(Recorder recorder) {
+ this.recorder = recorder;
+ }
+
+ @Override
+ public void selected() {
+ if(updateService != null) {
+ State s = updateService.getState();
+ if (s != State.SCHEDULED && s != State.RUNNING) {
+ updateService.reset();
+ updateService.restart();
+ }
+ }
+ }
+
+ @Override
+ public void deselected() {
+ if(updateService != null) {
+ updateService.cancel();
+ }
+ }
+
+ void suspendUpdates(boolean suspend) {
+ this.updatesSuspended = suspend;
+ }
+}
diff --git a/src/main/java/ctbrec/ui/WebbrowserTab.java b/src/main/java/ctbrec/ui/WebbrowserTab.java
new file mode 100644
index 00000000..3def3feb
--- /dev/null
+++ b/src/main/java/ctbrec/ui/WebbrowserTab.java
@@ -0,0 +1,15 @@
+package ctbrec.ui;
+
+import javafx.scene.control.Tab;
+import javafx.scene.web.WebEngine;
+import javafx.scene.web.WebView;
+
+public class WebbrowserTab extends Tab {
+
+ public WebbrowserTab(String uri) {
+ WebView browser = new WebView();
+ WebEngine webEngine = browser.getEngine();
+ webEngine.load(uri);
+ setContent(browser);
+ }
+}
diff --git a/src/main/resources/ctb-logo.png b/src/main/resources/ctb-logo.png
new file mode 100644
index 00000000..5e85af71
Binary files /dev/null and b/src/main/resources/ctb-logo.png differ
diff --git a/src/main/resources/html/bitcoin-address.png b/src/main/resources/html/bitcoin-address.png
new file mode 100644
index 00000000..93986c41
Binary files /dev/null and b/src/main/resources/html/bitcoin-address.png differ
diff --git a/src/main/resources/html/bitcoin.png b/src/main/resources/html/bitcoin.png
new file mode 100644
index 00000000..8a3b2309
Binary files /dev/null and b/src/main/resources/html/bitcoin.png differ
diff --git a/src/main/resources/html/ethereum-address.png b/src/main/resources/html/ethereum-address.png
new file mode 100644
index 00000000..ac9e7fa8
Binary files /dev/null and b/src/main/resources/html/ethereum-address.png differ
diff --git a/src/main/resources/html/ethereum.png b/src/main/resources/html/ethereum.png
new file mode 100644
index 00000000..4672dd0d
Binary files /dev/null and b/src/main/resources/html/ethereum.png differ
diff --git a/src/main/resources/html/monero-address.png b/src/main/resources/html/monero-address.png
new file mode 100644
index 00000000..e7d2ebfe
Binary files /dev/null and b/src/main/resources/html/monero-address.png differ
diff --git a/src/main/resources/html/monero.png b/src/main/resources/html/monero.png
new file mode 100644
index 00000000..4b881482
Binary files /dev/null and b/src/main/resources/html/monero.png differ
diff --git a/src/main/resources/icon.ico b/src/main/resources/icon.ico
new file mode 100644
index 00000000..b59e1427
Binary files /dev/null and b/src/main/resources/icon.ico differ
diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png
new file mode 100644
index 00000000..b1f90d11
Binary files /dev/null and b/src/main/resources/icon.png differ
diff --git a/src/main/resources/icon.svg b/src/main/resources/icon.svg
new file mode 100644
index 00000000..3dad95c4
--- /dev/null
+++ b/src/main/resources/icon.svg
@@ -0,0 +1,103 @@
+
+
+
+
diff --git a/src/main/resources/icon128.png b/src/main/resources/icon128.png
new file mode 100644
index 00000000..ce165a4e
Binary files /dev/null and b/src/main/resources/icon128.png differ
diff --git a/src/main/resources/icon16.png b/src/main/resources/icon16.png
new file mode 100644
index 00000000..4f7f7a8e
Binary files /dev/null and b/src/main/resources/icon16.png differ
diff --git a/src/main/resources/icon32.png b/src/main/resources/icon32.png
new file mode 100644
index 00000000..11ec023d
Binary files /dev/null and b/src/main/resources/icon32.png differ
diff --git a/src/main/resources/icon64.png b/src/main/resources/icon64.png
new file mode 100644
index 00000000..3622998e
Binary files /dev/null and b/src/main/resources/icon64.png differ
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
new file mode 100644
index 00000000..b0fb0437
--- /dev/null
+++ b/src/main/resources/logback.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+ ctbrec.log
+
+ WARN
+
+
+ %date %level [%thread] %logger{10} [%file:%line] %msg%n
+
+
+
+
+
+
+
+
+
+
+
+ ctbrec.LoggingInterceptor
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/splash.bmp b/src/main/resources/splash.bmp
new file mode 100644
index 00000000..6baba557
Binary files /dev/null and b/src/main/resources/splash.bmp differ
diff --git a/src/main/resources/splash.png b/src/main/resources/splash.png
new file mode 100644
index 00000000..22b58b58
Binary files /dev/null and b/src/main/resources/splash.png differ
diff --git a/src/main/resources/splash.svg b/src/main/resources/splash.svg
new file mode 100644
index 00000000..9b11ed90
--- /dev/null
+++ b/src/main/resources/splash.svg
@@ -0,0 +1,103 @@
+
+
+
+
diff --git a/src/test/resources/req-list.json b/src/test/resources/req-list.json
new file mode 100644
index 00000000..20ce603a
--- /dev/null
+++ b/src/test/resources/req-list.json
@@ -0,0 +1 @@
+{"action": "list"}
\ No newline at end of file
diff --git a/src/test/resources/req-start-pink.json b/src/test/resources/req-start-pink.json
new file mode 100644
index 00000000..178414ce
--- /dev/null
+++ b/src/test/resources/req-start-pink.json
@@ -0,0 +1,7 @@
+{
+ "action": "start",
+ "model": {
+ "name": "_pinkrose_",
+ "url": "https://de.chaturbate.com/_pinkrose_/"
+ }
+}
diff --git a/src/test/resources/req-start-queen.json b/src/test/resources/req-start-queen.json
new file mode 100644
index 00000000..8b4aee6d
--- /dev/null
+++ b/src/test/resources/req-start-queen.json
@@ -0,0 +1,7 @@
+{
+ "action": "start",
+ "model": {
+ "name": "queen_squirt_orgasm",
+ "url": "https://de.chaturbate.com/queen_squirt_orgasm/"
+ }
+}
diff --git a/src/test/resources/req-start-uv.json b/src/test/resources/req-start-uv.json
new file mode 100644
index 00000000..2dafad0d
--- /dev/null
+++ b/src/test/resources/req-start-uv.json
@@ -0,0 +1,7 @@
+{
+ "action": "start",
+ "model": {
+ "name": "uv_",
+ "url": "https://de.chaturbate.com/uv_/"
+ }
+}
diff --git a/src/test/resources/req-stop-pink.json b/src/test/resources/req-stop-pink.json
new file mode 100644
index 00000000..418afdd5
--- /dev/null
+++ b/src/test/resources/req-stop-pink.json
@@ -0,0 +1,7 @@
+{
+ "action": "stop",
+ "model": {
+ "name": "_pinkrose_",
+ "url": "https://de.chaturbate.com/_pinkrose_/"
+ }
+}
diff --git a/src/test/resources/req-stop-queen.json b/src/test/resources/req-stop-queen.json
new file mode 100644
index 00000000..bd59dc20
--- /dev/null
+++ b/src/test/resources/req-stop-queen.json
@@ -0,0 +1,7 @@
+{
+ "action": "stop",
+ "model": {
+ "name": "queen_squirt_orgasm",
+ "url": "https://de.chaturbate.com/queen_squirt_orgasm/"
+ }
+}
diff --git a/src/test/resources/req-stop-uv.json b/src/test/resources/req-stop-uv.json
new file mode 100644
index 00000000..717d55a2
--- /dev/null
+++ b/src/test/resources/req-stop-uv.json
@@ -0,0 +1,7 @@
+{
+ "action": "stop",
+ "model": {
+ "name": "uv_",
+ "url": "https://de.chaturbate.com/uv_/"
+ }
+}