From 1ab902892d94321062bd9e2463574d8a89e47730 Mon Sep 17 00:00:00 2001 From: 0xboobface <0xboobface@gmail.com> Date: Sun, 1 Jul 2018 17:38:53 +0200 Subject: [PATCH] initial import --- .classpath | 37 ++ .gitignore | 7 + .project | 23 + .settings/org.eclipse.core.resources.prefs | 6 + .settings/org.eclipse.jdt.core.prefs | 12 + .settings/org.eclipse.m2e.core.prefs | 4 + ctbrec.sh | 7 + pom.xml | 158 ++++++ server.bat | 1 + server.sh | 4 + src/assembly/linux.xml | 24 + src/assembly/win64-jre.xml | 33 ++ src/assembly/win64.xml | 23 + src/main/java/ctbrec/Config.java | 82 +++ src/main/java/ctbrec/HttpClient.java | 125 +++++ src/main/java/ctbrec/InstantJsonAdapter.java | 21 + src/main/java/ctbrec/LoggingInterceptor.java | 29 ++ src/main/java/ctbrec/Model.java | 103 ++++ src/main/java/ctbrec/ModelParser.java | 42 ++ src/main/java/ctbrec/Recording.java | 119 +++++ src/main/java/ctbrec/Settings.java | 18 + src/main/java/ctbrec/recorder/Chaturbate.java | 37 ++ .../java/ctbrec/recorder/LocalRecorder.java | 466 ++++++++++++++++++ src/main/java/ctbrec/recorder/OS.java | 77 +++ src/main/java/ctbrec/recorder/Recorder.java | 27 + .../java/ctbrec/recorder/RemoteRecorder.java | 214 ++++++++ src/main/java/ctbrec/recorder/StreamInfo.java | 8 + .../ctbrec/recorder/StreamRedirectThread.java | 34 ++ .../ctbrec/recorder/download/Download.java | 14 + .../ctbrec/recorder/download/HlsDownload.java | 240 +++++++++ .../ctbrec/recorder/server/HlsServlet.java | 88 ++++ .../ctbrec/recorder/server/HttpServer.java | 92 ++++ .../recorder/server/PlaylistGenerator.java | 206 ++++++++ .../recorder/server/ProgressListener.java | 6 + .../recorder/server/RecorderServlet.java | 134 +++++ src/main/java/ctbrec/ui/AutosizeAlert.java | 23 + src/main/java/ctbrec/ui/CookieJarImpl.java | 76 +++ src/main/java/ctbrec/ui/DonateTabFx.java | 79 +++ src/main/java/ctbrec/ui/DonateTabHtml.java | 36 ++ src/main/java/ctbrec/ui/FollowedTab.java | 32 ++ src/main/java/ctbrec/ui/HtmlParser.java | 48 ++ src/main/java/ctbrec/ui/JavaFxModel.java | 95 ++++ src/main/java/ctbrec/ui/JavaFxRecording.java | 152 ++++++ src/main/java/ctbrec/ui/Launcher.java | 138 ++++++ src/main/java/ctbrec/ui/Player.java | 123 +++++ .../java/ctbrec/ui/RecordedModelsTab.java | 228 +++++++++ src/main/java/ctbrec/ui/RecordingsTab.java | 462 +++++++++++++++++ src/main/java/ctbrec/ui/SettingsTab.java | 275 +++++++++++ .../java/ctbrec/ui/TabSelectionListener.java | 6 + src/main/java/ctbrec/ui/ThumbCell.java | 396 +++++++++++++++ src/main/java/ctbrec/ui/ThumbOverviewTab.java | 371 ++++++++++++++ src/main/java/ctbrec/ui/WebbrowserTab.java | 15 + src/main/resources/ctb-logo.png | Bin 0 -> 13960 bytes src/main/resources/html/bitcoin-address.png | Bin 0 -> 1889 bytes src/main/resources/html/bitcoin.png | Bin 0 -> 1853 bytes src/main/resources/html/ethereum-address.png | Bin 0 -> 1949 bytes src/main/resources/html/ethereum.png | Bin 0 -> 1441 bytes src/main/resources/html/monero-address.png | Bin 0 -> 2138 bytes src/main/resources/html/monero.png | Bin 0 -> 1738 bytes src/main/resources/icon.ico | Bin 0 -> 90022 bytes src/main/resources/icon.png | Bin 0 -> 27199 bytes src/main/resources/icon.svg | 103 ++++ src/main/resources/icon128.png | Bin 0 -> 6192 bytes src/main/resources/icon16.png | Bin 0 -> 686 bytes src/main/resources/icon32.png | Bin 0 -> 1262 bytes src/main/resources/icon64.png | Bin 0 -> 2729 bytes src/main/resources/logback.xml | 59 +++ src/main/resources/splash.bmp | Bin 0 -> 518454 bytes src/main/resources/splash.png | Bin 0 -> 6115 bytes src/main/resources/splash.svg | 103 ++++ src/test/resources/req-list.json | 1 + src/test/resources/req-start-pink.json | 7 + src/test/resources/req-start-queen.json | 7 + src/test/resources/req-start-uv.json | 7 + src/test/resources/req-stop-pink.json | 7 + src/test/resources/req-stop-queen.json | 7 + src/test/resources/req-stop-uv.json | 7 + 77 files changed, 5384 insertions(+) create mode 100644 .classpath create mode 100644 .gitignore create mode 100644 .project create mode 100644 .settings/org.eclipse.core.resources.prefs create mode 100644 .settings/org.eclipse.jdt.core.prefs create mode 100644 .settings/org.eclipse.m2e.core.prefs create mode 100755 ctbrec.sh create mode 100644 pom.xml create mode 100755 server.bat create mode 100755 server.sh create mode 100644 src/assembly/linux.xml create mode 100644 src/assembly/win64-jre.xml create mode 100644 src/assembly/win64.xml create mode 100644 src/main/java/ctbrec/Config.java create mode 100644 src/main/java/ctbrec/HttpClient.java create mode 100644 src/main/java/ctbrec/InstantJsonAdapter.java create mode 100644 src/main/java/ctbrec/LoggingInterceptor.java create mode 100644 src/main/java/ctbrec/Model.java create mode 100644 src/main/java/ctbrec/ModelParser.java create mode 100644 src/main/java/ctbrec/Recording.java create mode 100644 src/main/java/ctbrec/Settings.java create mode 100644 src/main/java/ctbrec/recorder/Chaturbate.java create mode 100644 src/main/java/ctbrec/recorder/LocalRecorder.java create mode 100644 src/main/java/ctbrec/recorder/OS.java create mode 100644 src/main/java/ctbrec/recorder/Recorder.java create mode 100644 src/main/java/ctbrec/recorder/RemoteRecorder.java create mode 100644 src/main/java/ctbrec/recorder/StreamInfo.java create mode 100644 src/main/java/ctbrec/recorder/StreamRedirectThread.java create mode 100644 src/main/java/ctbrec/recorder/download/Download.java create mode 100644 src/main/java/ctbrec/recorder/download/HlsDownload.java create mode 100644 src/main/java/ctbrec/recorder/server/HlsServlet.java create mode 100644 src/main/java/ctbrec/recorder/server/HttpServer.java create mode 100644 src/main/java/ctbrec/recorder/server/PlaylistGenerator.java create mode 100644 src/main/java/ctbrec/recorder/server/ProgressListener.java create mode 100644 src/main/java/ctbrec/recorder/server/RecorderServlet.java create mode 100644 src/main/java/ctbrec/ui/AutosizeAlert.java create mode 100644 src/main/java/ctbrec/ui/CookieJarImpl.java create mode 100644 src/main/java/ctbrec/ui/DonateTabFx.java create mode 100644 src/main/java/ctbrec/ui/DonateTabHtml.java create mode 100644 src/main/java/ctbrec/ui/FollowedTab.java create mode 100644 src/main/java/ctbrec/ui/HtmlParser.java create mode 100644 src/main/java/ctbrec/ui/JavaFxModel.java create mode 100644 src/main/java/ctbrec/ui/JavaFxRecording.java create mode 100644 src/main/java/ctbrec/ui/Launcher.java create mode 100644 src/main/java/ctbrec/ui/Player.java create mode 100644 src/main/java/ctbrec/ui/RecordedModelsTab.java create mode 100644 src/main/java/ctbrec/ui/RecordingsTab.java create mode 100644 src/main/java/ctbrec/ui/SettingsTab.java create mode 100644 src/main/java/ctbrec/ui/TabSelectionListener.java create mode 100644 src/main/java/ctbrec/ui/ThumbCell.java create mode 100644 src/main/java/ctbrec/ui/ThumbOverviewTab.java create mode 100644 src/main/java/ctbrec/ui/WebbrowserTab.java create mode 100644 src/main/resources/ctb-logo.png create mode 100644 src/main/resources/html/bitcoin-address.png create mode 100644 src/main/resources/html/bitcoin.png create mode 100644 src/main/resources/html/ethereum-address.png create mode 100644 src/main/resources/html/ethereum.png create mode 100644 src/main/resources/html/monero-address.png create mode 100644 src/main/resources/html/monero.png create mode 100644 src/main/resources/icon.ico create mode 100644 src/main/resources/icon.png create mode 100644 src/main/resources/icon.svg create mode 100644 src/main/resources/icon128.png create mode 100644 src/main/resources/icon16.png create mode 100644 src/main/resources/icon32.png create mode 100644 src/main/resources/icon64.png create mode 100644 src/main/resources/logback.xml create mode 100644 src/main/resources/splash.bmp create mode 100644 src/main/resources/splash.png create mode 100644 src/main/resources/splash.svg create mode 100644 src/test/resources/req-list.json create mode 100644 src/test/resources/req-start-pink.json create mode 100644 src/test/resources/req-start-queen.json create mode 100644 src/test/resources/req-start-uv.json create mode 100644 src/test/resources/req-stop-pink.json create mode 100644 src/test/resources/req-stop-queen.json create mode 100644 src/test/resources/req-stop-uv.json 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 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 createRecordingsDirectoryFocusListener() { + return new ChangeListener() { + @Override + public void changed(ObservableValue 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 createMpvFocusListener() { + return new ChangeListener() { + @Override + public void changed(ObservableValue 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 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 0000000000000000000000000000000000000000..5e85af7145a606273f8c6023ec666ea9f751bcad GIT binary patch literal 13960 zcmcgzg;N~O)4s!9g9mqa2*KSQ0wFlT-CYh23+}GL-GjTkyBzKk9D;njzdz!u+TGdR znwh7o`>E;fsh)5ZC23S7LL>kHfGR5^q4v>s|92z6eLU6HZQwr|7;{lYQ2?Me2KmJZ z_T!t(L`F>!0Pvy#00O`O!1KqifFl6Fl??zmF$4g3z2luG>JIbSh`8auwOJY5Y3-qG*YZszn5O-Eh3?5 z2tUUYY2<5FJ7NF)mPi0D$^&H=pInrkq@OBycbz^A`6(}yRyiwax@$pec63eM2)Qb0 zX4MxrF18)OvK#k@VNyy(;{SiW><){Vts?U3d#nF7#rgt$1ic7E@!5y|iyh^ta%E1p zodea5?m>CALIFMQmbeZLR1+bdhFr*2dRJat26ZE-075{KKSY_B%rKx61#Sr;Opq9m zFcfZcXk~pSWB~KQ$B?fENgI_3_5fgeu}KAT85yb`2`oyCjpo9SD^*d7daaXmi&j&_G(d13gF~A2XbRK>0IkbmA zze5OKQrNb60o8Duu)4Y-R}tV@p$LtPuaKV*EC_cuqR0(q%>tH4=nq!-DhK{P!R{## zJYjW5>7|7s%saK*V>M6+;>V+OJrnX>8R6$;H{?t0z<Vj}iHD?xW6uAk|qhvrW< zv|Xkn_S*-{f$X7q`9g#(cC^44;sMeRN}fpEV|iu@jPF%q zE|YMU2kMxglZ4)wk0%d@6<{*6Vv2KsvI1UVLbgBTOUK~{y+dJ(B6$!yWJT;K4gEE! zs||=h6+(P$P^VW@?DmCJt zs4ypqIWi_OWpA^j>W1*)X24VfI``)nzW2afLt*uBK&&7gB4hL6-F@Yqo?B=6y}>E7 zeGW_K(Dn{9C)h>Td=ZB1T=BaaZccFmV|+G7u!8fdB7pKh;(ae*+_kbAkohfzA|UR_ z94$tV>zYjdDL8xhmB}PvUV~uU$h=vYd(IZ>9hM9Vy&0o1?d+M1I&O+w znbdU3L#v6*%3f-4Nwq&zj8#sD^#%pk3`T8NU93&sm`{k&APW{j!6%h1Adl;%vhG#} zT?WzL(Dh;100RYdhV!17M9GnjfWJr8j!pPF+C>*{Fe08hI|JAFk}I&kYF;@(E;EtV zh%W&P;gBhiGT^4@dIb;z=hPk&)vio(@X77U&zJV04ompgGHDn;h!+}~wTX(M<-)D_ z+Q)%|^3U*@Gr<9~Ku_DAYV>bpbZQOJ0TGJec`mX>#n>w8mrwYt@h|GW?Pa;-grCTG zBheo6()@aU10~p|WM3JJc8(F) zsSrJJh@N6!vYl7N(qjR6Sqj|LT?;KZ@cI<}U7sGkA(aUmTNvW$0rDOpjQj4Hy(}mg zWf=IRrPlpi4btFC+98C^QtscD_A<9pIP=LE?Sbqj<76c9x8#};A07Dn97tEYQph+( zfn*8sP zGP?)_Mb2TBIbl??s?`8g6giBz!$2c@^LJWeUKl9e{?ghkb+OHnofi_T5EQUas}V!W3iauO9kHP`80?~P1d(m8gYZ)^ZV3{E@r^PZQCt(Eglp41 zBn{@YqITe_vfw(+^&DZmdJ@VQV`*u)=@$f;;1BvKLC;=W;SC1lNT|8W=y9qo1v2Rboo_0AsfodL z@{zJL=~}#IT+~n#c2wQ158yZ7VS#kv_g7ESI`XVNg4YN5Y(xV@P~*!68khn!h~1Xv zEbwbIyksr4Wzo35ppOx=Qv79gqKDQD7w(^yOD^Bj-<%Sk$85H)>SI$6u?XQlSZ zjTH~|o&jBi0d2JNcjR4vP>W(YN5*Ft(^=hMRYiAmJIK z1I=kUI}6OeX!2kuCjCrlX`m5=wiZfmvW2rZy}7ZhMNlYSD;?dCRc}>L2N7p%~X&> zV#NsStK_aaYRh`{S`K_8^l<04Lb=kGr!`2^C` z%Tv8#x*_1kY^)*b_X={x$a{wT_>#u29c9Gh$U~Ldi<8; zurAns)ZeL$(oc558258T!#L^X0;Xu_znZq}o{P1UFa!Neg_s&PD^@NQh+-XDR~;+M z`5C}@o=pTXFY82&$f_K(XFy;m$_cr6mH<<<4vy2GyU15|uaqt+AlRm1|KSoAQy{V4 zw0z94lrG#rc!4X(Qq7kIR_NHR>lgo~r_D~zUh!vTt%A!X9tz~>DE!Xbblb4=)#!~^ zrmq|-`3LQf2<({?-*9IQ6cH**rJ(B<;7VYtprT7@o>+IjcTxx+;y-7O&Vj#sd{*1y zkb=1wJQ2a$o-Cf(6#PA?AGW=JmU;%%Ju54XUS?6b?x1a!v5;;&6^hdH`R><)1($zX zWj#1<5LI9`gzaw!v-b3PwVtxo%+qrJ{yW$cr8dNOd6bHCXn}gl4~~u;ti`umoxk@b zE6YdJ&QwSo%pl{-Xx3x(B^Dv@v)h!E&7st=`5-1pQd>gUye_KdntsZ8KS6pG-wyaj zTho*t^Dc3Jjq}7eb`(+7Rz<*RPfewUtp1m{xnKrQ`PD1%ueaj)eh0SVjUXTZ;u zxb&{R=}So>L?(dTI57@Q^Gm(le3MX}P@}*<9=h;?JKw^y%rZsj8M=o1F3lWlrMm6j z43%KH1uJ)!B@DZ%p{T=R9Kbrv$`{vR(l6YF^7p~rh(9Nz`-)X_+@wZFZ71@S-Bkuf zMNE6Bg*Z3RTed~#Vt@b4rPW@Y3Md~=I^Ef^tT&avvRx*DvEzul_9aSh!3~54(K*Zz z!YZ5k-Yv~&d-qF4y1RIPDYLKXsCDixH5x~CR_ECD!Oi%=Hac>GK;#9AD?ZKYwOh3o zI`hzLA#uTf$_Uwhdn8O7o4&w=?=k$ZM2)|e1dN*#g-UMt=gYXza)Q2QwLW3!cFRrJ z)yy$xS#FA!_^rP**%Z?|uGIlQz1sIK&m-Ffcv9H|)j>j5flB5z5q75u-7_#nRfUj?fXA?`rU@Iq{5B{WSyCEP@|a_Kh?Fwt|j^VL5;_u z@P(z7ez0#`5U#W|r{vzno^|492K&)ss})ZGi7m>sYPgKn%*2XKlmKaIE8XaKIT&cY zkM$#zEEr5vr%V4Z1G(NS$M5$?&kSckUGlw*WvSa{?K1H3j34t+&}cnKy1iO*f{)r$ z4NL9pw0&_!zn9?84&jVex%N2AElfv^4L$jBW1|i#_#CbSPFPVSB1jy*8n_t>BjW_0 zuYThLS!2O?w5LzB2h(OUtNaCJ0-l*coRJJsOFj<}dnJ4_EA?Dbw{rwW4``^Wgf_lx z7W1<|yurTEm1r7QBu8?YP?ra&HH;&$|<_Luth&uI3;;knG}s# zawWCs$ak96kwAicfwM0`9(u8X61*PC=0S@UzHfPMn8IVsRpHXb8+H;_GHC~gVO`E* zBECxSt4S`dR!tDorJ0oYg5QRs24U=ISKW!f@@t?@rJGW5*y?3|-m{@hTfVB@Jq z#Bz;lMTc62E$hR^VlT(Wlyq~4j2$ekj>uLmK2z6ZJ^APD^%H*#r#NP}L=cEaHWPOPu*{8Vv z6(wlDjkcvcqz*}?<&HiTN}lse-r*)%t7I+RZrjh^&e%uGzQ_1rt4eCHVl*Bm{UgyR zU4SCxK%bVQVd$_;izzrAtYSx*zBp$ocQTXnM<3(-8kz&9HRdXDQFbnQGpOYW%JPi7 zAe}`c1JiZvQ)Pr+N80X+w#CX%NCl3QeO->?nk2q~Q>^z}6#<>T@&os`0u!XIC(8_B zuV6vE6zf#q?9ZD|PXAq#e<5z_xg)fqyS%?`p7zeZnMkIq=hh45a#hgqE$Z1UElQUl zA#mIXBm)uKW_Uctlh%3o2!095YRZ$7Ef65jcZX`YvnkVW|4M`}Qj?nw?b+@6l^=2` zr-ZtNuC$%G=XQcnv%$}H>{VhL824tG5{8KlqkFlf$n0r{q(m)3gSr#x@5OPMCg~G&K#iogOE-lkb^t%f2uO;O6Ac7J_qy*>#RkWb2*|sz1Z>18_9} z9Yo9ORa-IA!}fjDFVYTJq_?CI=^Uiw1S>;Gr*!9JI@0;z8a9~0+ zx9$Y4ZBYR4uJ&YC zK07TeNXcMA=w+)wxeF#2?^a_Pdyt`JFc~%*;n2C_*f{BU01g(xNu~}CY2}!Aan`kj zAsdx%V{O9f_`s4s)K_O$12)UXGnY8G8b*+3=Q{jdOCEHZ@KB@nTw~1UoGf)NFQwPf zaYQcBLYwgHi6V@)cs23@yG~e@uceM~Lbxt9*2j@6`@w0)&aPm$kzC`egGwiKdkZo%w^|R53;_YRW2|rsff-*tR)BpgQkr>2J|t*( zb*nD*y|*TPfeCNY-P&rg&)?>C5TL(h=uh4>O&42c|EYvwP@ni&p&f)@vaPwvQ>7pf zQv7S4$9Oe4Qr63Q#`lMu96|9ekX|qS;PU$LIwqL&XqNDW;guhCw1$RnY;DA`2);a! zYjS|ALDY;4O9*FjqOVT;^vUC%f&>LmX_4Tfn8HUD?6Vccned372CR!&mwIl9P+g`! zVwg@Cla&uDR90UnWibn18e*99-iz<%0_O&Bxk^~s3rT1EZ0X+W<(M1dS?d?7BtGxu zE1FUEeFFPXcnY&}>c!pK8w7r$+@Lrz{FqLjwTUeej=$YcHBGgEJ`S}w`&HAU$5>xEhL(CeB-th)U-O;Jgkeg`9A5jbNF zoFKChio1dqN`^+JPZo(#hhF?&mbO5mD|t~UR=uu7X}a8X2$#xLyJG(QxS~3^65JEJ z1@GCG6n>^{i_WeG6c@;Nii@S5nZKIWi6F?_?)B@W-ExWPMpi^apT)%WRCT3b*vX7r zD78l$DeY*4^T^f6?dy0P%1JTB?viq(r(f$I#fS@giDT*!1M|tKI`exa6{hm24q5u3h_k61;gb^WGMl zR{b}%LH}wtGN!jlBI%?Og~8`OURSz)v4WsTsT)AqSrEgOv&1{rf*k_>{-GXw*YWEc{J65LM9z&8}Y*Q#Rbiv2!Q4(Zy&C$po zfu*D*16`AfNM+%`-j|KFG1HNW%#`IGF#-w+{CO&ZsDnZeI;}zylgcGxf-YP6e55}V zuD3QZsg++!3sD@kUN(RsM={RjPoZ&hdXazDUn5qF%f;G$wlH<1P0hb&Gg@UAZH!4O zx>*ylpO59TMV_xfL3?BV=zd7FQ@qKFOKA3kd?sNXXC}xhHjSYz!MRQ8ZlvQEPwer3 z<*J9BEAr-gJw_b}EcJp|X;hcbv+n>dsmb9;O{13jP7^XbZH*br+x_CT59`xH<}`ve zCg`v&Vk25rQ0{VrKj-5rQ>gNmAo7Gv2z(mfedzIlvq`UB*qSir45K-{!p%9F{R%S? zgK^=*z~B-W)V@Z2DOfA;-D0=HH>GB5(MYP}^5R+ZYGBsNDKqxyfV-49Gi80`M1oZI z%;Gty;DlTcenpf^I1-Re_nHogJ~MtZy)qThNsajW!$d8E@C`Li8M;mMI7hO7D(*^} z#}DERkk#0N^9nk}7nD?%zs2CE>f;OlAVy1)7S%nQGRGs`wh^cJUSQJdUK~mbF?D}KInWU`g}!5^&y3R)Ao-gO374G z2cCnq8@()|tZ`{V0B(Y5oN#}7zn_wli?di=s7X8UL;*OU5Q%^f&C@R<@#xFbla)5` zVtC|1GCDz745wW5Qv*F{{YXyUyMjS{t1EjYgp!lu_%%lo7IU9V^)*on_C($8hsB2W z^rz&)O)~Q*?=QkFLF%t8Ull?Mw0Y?+$lB&*M(oIT1SF#|`*VX8xY$h#SP3aV?fw1= zkB2>|yu+*=R6rypjTYRth|SkiPzKJqDtDYU@cbg2k`q}`Fteu(L9Op5HS6RR3*{9{ z7*S^gnsNP7J!sw777TZJnyg7~<3xTMnYJPscZ*dIVpV7Pw!(bXrLpJTJ>30ZM!HR^ z3J0+8+Cp8HVQ30F@!p_{!-4l6&!1N|`u;OSJLBk}qMt-b{>-G5S0;fT(xS8H2qsrw z$CQY55u9a%FSVA$;*K+?9u~Djm_W;KF=B(8<037!C3($^zWvBJr8LK|?h`j-vcnA( zj_L&V0bAca16kj~XoPh_xO<*`kl(r66W2mgYHFtyfR5@s#dO{fQ3xDPi#plo$2arT zi7(eCYFqB&pU}mXUDUC+pjQ1HLKju_mRt8K%o5Dgc)Q221T)i*cKZAMa6QGj(%Mgn zs1i5_nu&q^%H)j`GI~F_3I}d}i!52BN{L#ype$%6tWDBh!BJO=BqEJSYxF08hPU#X%0gR6NNW1 zbFCgLBi$xm=BO}*C+aM|ET}B5((}Ly?>ST$Pbtr?G|CvO-q*=C5u(e9Zpz>6Ul4@) zWaicD@^g7q^oK0@6~b_eL^IAX)Q3p*pHV$T<&#dfZg|5y0F5P`SRUv{!Bs=kMp^QX zp(;_GG}Po#Mcq*LV97U(u@~Ss8aVAebk3=%X*;L+Gy!g!e(9l!be2)?@@U$2n0Px0 zHPqOOV73^v3(=dtBzP?a)n4g()a81cVA!sk9Jz9^^ns81&Jp^+V7 zFA@;%T;)J8^!vSMv{ZZZKv{~Otgk$B*$F}1fT~(nC(t8!4JUfIAJcU+u>`CwWe=wP z#hOfrD4VORvNWP~xTP#6Uf?KHWXk?^I{bV~kiGE7==$_8E7aRyA%o>V$+UIMC_i#~ z*hT^r*^C9Dh@I^Ti=jrgi(?sUupekaMnIl!Nl7@FfA;8C}{w zeACxW)}*XdSTWFBuzE*>*jdMB36*YTYUeH-^-zsibxhp&Ny7iahZ(u#*2y(*?#nX6 z&6&{AC$~X)_ah8dXxZj@cpik!$jzvG^f&~!K>=^1{(MFEX}y5LqYl%CIT5tw^{dGk zdpN&BIX#Wo>Y>&ZLsn-?JmgGoZr2h8Bb@XEzT^U_8mJvwJ%w&=03(Xx&j5N3ak5@k zFFa0suScxtRkF}9rfI|qXg~OM__`kUK(yDR2=?G97q~ANLA?H@bOoUSQFoV*%ThIe z#3&GFp~mq{3vcKu;gVZLNo++-5^IY!DYeRaef+@(5-h90cy0(^xhOJ*+2Ht1&@yZ! z-H+?W^@T0aOnzE4;pTBMhYz~2>H9U}9;P<4u&5xuFB1*k@IJ-e~o)Aw% z)|x-Zwo9)dy6_FHJlqD!B(&*)$q;S)b6l^uF?~Tpcs3?EG}TMk5+s`M$b+gbvC`ba z_t}4eJ60%05-sKSB>Z)jk;`&X*J4XHhTX@1`9V>+Q^2Y4VX0Ua)ez0;Jko)XGt~*e zzRJ!9!e?^SX8LRNg|1htL$yTp0M1eNfRef|+>w{`dE7D-#0&FKgl4~YN=i#a;ID=e zNV+c0yUTVWzNuMuBCR93FxE|FdBfE_T@deGK2N1unZSKP`Lk5AypZhFA7d@j$d(kI zDL`Z8b1K$k!G>i?X-wncURElWO*e(ceSKsoui^ef|5+G)9pMbWo%)-pG29m{6hrBa z=<}pskG_VTCII$qYx*4*r;I3zuCnv5>bc(5o&1T=+;s;3(7luKvFQ0^eCuK z6GK2$PbQHL*iuK(KsTASxFLGaUoQ0GyIPdHK^-N#MDRy;5WX^)uz#8ED-|mfzC1jB z9r$Acm#WDxvImzWhoZ6@OeJtSOBQb;1g%@JsCwN9GwbPhNEcGn^rj`Z@rY7Zx)-IWA1~*BHU5v7x=tKvP_U^_;$2rMgQ> z{_Wd!4*FPUxEn^>=L+ThUB#yK9(|@GcbM1CTyv93OTWIH0TebyH-KhPnStXnsgBqS zwY>P!`e+C5M+9X`XxL(kVly#6op3Yc77%WorJW&omicbc^c`w}IspYG^Q27l&Pn5) zm%_hHuJ!oNAD6Rxy!d?vl#HG2c^mjwZ}V5b{x}qOf{W+xM1sVLKJGmz=>21+;?d`l ze+PM-A~&SIxgM%_WlR|)&!l=|8c*=k zPY$Cf8h7$n2Th1KE|1PJdP|uG-Ee+?PI#36qmc|Z6dc4CH5f( zSjN)(ZOxXPE2Oc^mN8X~ImrDGrfo#2Dd7=c1%q*szU4F5deNT1O)JwsH;FOG-k56j z>*uB&!}hjHv>*-sY=9mv`FrZ(+shu96ZlVEg(GjM(h%TV& z>_n3LOD0V7!BWScN4;v1yZ!xW?2wu=$5a!-dexGNYH{^*us$<+NCBPs6fxxV&<=7r z<(bS^DzK}d5%J_?sFj0=H2T7$&i z;PaKRo~rXqA6--=hpu&idrFi;o3qD4?<*HgpM{;%U1)C z(XKv))A@p=iwt$WtLmiOS)M&D=rJa$eYO7fvcPn-SNyfk<4!uTU9Kfjndm_3((%cxqKna>o zc+i`Whm?KNGm4~l3{Hn$Eb(?xcwV9vQ`5(vare~hjf5pfLqo;uYHNlbm%h`{$QUaV z#+}re9tuD2i{s~62z+$?%@dA$|NDuBY9YkZi{Mc;t-md1tm9fiqVq8?30?977VlpeSd1L zZtl)?>LdS0j=3e3R*2(s7T8qa`xo5}-lFq}c)1;RpEyt-@g(Wr8yQ%O^{^o{6)dkNHYkaNmyZLZL%QuP^k1+X+L8rO{-x*c%T_Btea z+^D7buaSy1pKiZGvk9%J1}mziCt5;mQ@;64M8UFD#FnFgS`+`(E~^-XN}BS*5i%3#1DGGG)Xs!2kf3*6QjlQnM^}6-d4tIv*ZUJvq)!s z?-MdZtP&lg4u{45jlF1NTq@8klUyL|WYP(2Qe#6ZQzv{WAi9kq7JyIMo=BO?@LNUx zTd#VwHL+u1s_Bx?G^eO#>qjLC#`_N-cY%lC6{?1o2E~V(=MGS>g_YK#E>lyUajM#} zd9f>rP~qjyK;eh>ksQTMD$FF1-zOgqWRA&bhN&!LlrvzK`>fuCZl8uKDquo#w9`N< zPJ0k`KyI`89KWc@0$xE^Ht`|%D_(FBV^x~WBi=i^;$au8#;J-B`1;(c-@cOLPf_>X zp{=>BI5{D{W<9=aqK3>W)Lv6m8UOEyT8U0$Aq{KWJVI#a68(x)p#C^eUquO{2ycii?Q zly6Jz1W2$!C$im3qFpepx$*Y;nzFyH!=2q6Hh1z*<^ty{=}HaQRF+XiV?Nd<`|cR` z8!_^Nd`w}#34upxW@rhIN$PyXu1}!#*-x5-=LaAmrUQGf;~w z?BtKtA@zT=b`BdbQ~@!-h)-{VD!x^J+j&u?7hsrxEI5zY8s$nW8YyG9arJIDosD{N zA`faZuYV!bLvRPdeHLzWj{?OE%lw)6cgUl<5--!F1zTsAWtBq97CF(*Lzyt0t@ zawZ#TIu~yS@b^@O6{iKA5f?V*y@U2@aM!!VQ;*2^=~pQ%cXtkg?gb$PO`>nzHP3p- zlt%SL1!vQHRH#bOo7d7r^>1wJF8R!k_DrMuxD5D4 z{&BbopBfu!%_~7fd;77q>qse@z%YcM_t$|4DO2$j!l=mP)j#69s?T)+xLy?EUslw5 zV9T&kMvawJ)Dp`lSNm3@F#}a-1pkztEac%>`Ha!(H)g!M{zXeqh<-f%CVu`4O9}_! z-}D(=Y)#-lw-;6kLwlN@rq@!2(wSClmoI4G3AW)>{#&Dzc-<)|%u04)vxvCvX zQm3l@L9)_{qP58gjw{wxnM%a)JpS5U@{ox7Z}yD%mZGup#Zw>()wveWqG$#9@J2D( zt3op3vFc3fTb8~;@T8>rNQ!Q&QAwDlep_n{X{R&x<@dH6FxK%)qjj#?oyWH&fYhSsa!?rkZp$nZvfrP^ zXa$hmVZ7wI`o)ehTxvMJpPxN%nR`ltT_8s+j@{ZaMx$&&z=6ADPl1GYc$s=#rA7t1 z8$YNQb_nG<$ldPX8v9u$Tn!<$;*l!dkU+}CT}?WKwbKutx5JmQT01t}?5n3`!Nt## z?~Va-*kW#iFj>(j=+k%d1dTt#LD>~Uz8RgI(nzf0zA`)qs#!7iDoL%U6xM~vAd-cQM_P1uBew$aUtuNx@ zrDA<8qvWATprE*F;8FW?!2UVkBFm_Qv2^l*i%cSHC1gR=imxD9YVgTIu5RplT!3Kz z*%0l>OwTfNLR)w!uy-ok!srVtl>oE(t`3AnLmI6-o!Kk^Z-exgu{UfBj)yB>Bb!F^ zemKphmlJ6vV#I+v#c`pG75k^wvT!5kr*sTz_S1N?>3xsuL%y^||gpKr| zlC-K%V+_oyCmp51dtx%o!0OI^lf9-~!>+j}Z-T4?@_oYeWPfH7frYf4X!Dc51;)Im zdPwf-XZh^n+r;O$ms2^Raa1f!%&wT6Zis8Rn=bjR8u3+@5X&}JAFa`v0*-CuuaKWj1AkOSgfVVm zFw$>Cd9@-BqnA44$02W>mctf80riXHwH#iSqmx!X?&=@;KY^b)K@QWen+lFm=%&aak%U73-qTzB zt&yrNek%9-2Ko>TqX=D{e)##X*KIrrx(%5YI=`w^_8Xku%|TKB)G`~Lz`=^-;J!G9 zgtIUzdrK}?G15DyyM##x)PK1(v=j%%p_uHy6X=KRNsJ0MVZ15Bb2dKt`nm9?W==CQ z{{_F-SA|rs&p-$VQY61g2o;y1lf`becuEO-hmQ!0y)v@U-K+B>KbB^&2m?jw+TZ%g zJO5oMrTeJ-&AXT%rcU4T3{arY!lhSNk^S}T(_1P;Px~aVJ$;a0QiVI@okma4^tT>pHPm&hJBMicCYrA z;oa^tyOy^4$&B&fgEMKA3{gLxU(4p9yF8PXnmeSZj4y`kr1y<*|CCP@Q)R=Q(=#GZTE-*4JY;OL7O2o$Qp-mPQLwRO7b*N-EfNE;#~hTaJ5b9C9nmn zf7dW<0F|SPKPljKS>d(#p%HBNtTh=X?GPgUnM)_|Xf!c5T+N#zU7)38zoH5U>($|pT=z~az(u^OuwJT9pB4!!Alc6zJrY@z!=H4_^) z$-buoX+S0bWPxqn-`0!+N|7MJpkBRGK{a}#2@07fI2R7_dyInSL~AjPs5BH~uo0ur z;h(FKA58rJXf?e#x(`7a0DhwIkDag5OQ`P`BrluN9soj@=Z5y04s21Mecu$D|!j8K5gk?2Lr!LS3(_{B1b>X7sXOcDGrO*$L)8mlB z2`o&=(e$tt_JiCkZo%r-&}Kx$@2F3Z&M5M&*5BP5L}GQ@1!`S%Pw&K!M0l9yJn|n( zL(4+M1>`t$w!|m+vbZ?^(Hh5O5gc(i9tlanBhaxLOa&%Ei>au+IwtMig;{$WgR02) zd8a~Oj^3cUJb7}3i3nQNJ*K}yGA>%5qv3X92q0IfxdhFG&1Jfniqujn!GoVZ^KLmD z$n~W$3M?|QjoWi@6?C&k8#6lveOpFdU(*HNqcA96Hm%<&fVON9R;CH!5h=oj2V7oX zmA2lc9L@gDi2SEZAxbI`FK|ig7k)}mD+`4c`M&s%j+$mo?G+>KMKkSbD~obGLThjX zYr~>c482C4xdg#5gZID0O1)%re(~%T;<~mej?5SZz$L`XSKxB;y;x-1g8U^FSme(8 z1@Vz_2&EDJpP-u$D@&wEZ$z>^IFabc7-XYQ4~|97pCYg7==yxy#{~ET^hXvzI+~IC zkV%0Ro9j#8BLgJy_^Rd@j0eBm7aDEb9W^<$<;qnm@FBFPlr~g5o(Fo(HT9xT#n%4a zOG3i)y#@6LY%~)kz9G()8)SmTE!Haz@tp)>0OAM5{r-M3gmU$DQ}VerkUpu8u~9U^Ev)hipsw!5BCQMmocV?TvlZJ^D- zjROx_mTV)@BXDxQFGi`Zp8L7+b!-kwkp6!jkoq5%KH=j#5cG*`ttc?l_T$_bKvq&o KqDss#@c#f7Evgv+ literal 0 HcmV?d00001 diff --git a/src/main/resources/html/bitcoin-address.png b/src/main/resources/html/bitcoin-address.png new file mode 100644 index 0000000000000000000000000000000000000000..93986c416b37b157450372d02a0509077d5d8bd8 GIT binary patch literal 1889 zcmaJ?e^66b77peW5-8?LR;cKb$C?rqL|5xzl%#n9*&s@)Bq-2G?W8CLg9}Xp5)dD_ znqu|U2#y3u#V85_vqYo?2eox<$V#Gs3StVbiL5c9CIKaaxEE(-)}8eva-w;ur3DvjDVJSL0;Z4^a1C8Un0mZw>$4f zP+N=L-95ITZ7jREJts3~u4SsL)x_vzpd>Dg!P=pp%GYV^wNNMt2yh8yg-y{~7@$=m z!fF?x)!i5}pc*Ek5(`yq@|d`IPZ7{+oih(ELsE)Jxr;SOp>cm8mOT~&W7@!f$2l`>JEI}2bKynNW{Xtz?)%XapAc>7eR-1dZJ~X}BMb3Q$jDDED zTd3*VaM;%8@cA+jqfflG(%=`%!3WroMq7>fUdg>*A3b7MV2aU-PPWx+*C^GpzUAS>Qp(!(8f3V@+RQJw zHt2yo#&CI&foL=`^zw~wZmDb1@7CL*bu?MKRL%kEpSZO{?gNGLQcd>%Cpg_GU}^1a zGsLti>dptaU!FvuP<_7R_DT#?we-LC*O7HYr5aI`S~6wmYSF(AC_{$a? zKH!8hYghZBpZ`qN78oNSGPAAdg+7E4;aIB?a zizZ9R0JVR3Vrovn)$fsVlV|K5&<`mk$S&)K~j2JP8?g?5G3?x+i{=-64Wd(kkIvwIm3Iu}$-h=Y}?buaXYxp33Hdj0ygu60r4q z+do^G)|~=nigZOdO!oq4xmbpf0aSK<2*m@Ou);%mb0DQ7g^UIuylm6PqXA->*u5><2AkA1eS1V0EdKJXCP;hIvy9QN(oP~h?KB5{`6?<0WHt4a`vnTi{JWQ;$(JQiUdYh0xP zboy?nNce3&pzu#P>%62FY#x9)$PnJiRAie(rCFe3< z@TQ^KE?F3{|}*Q@nD24Yv*R^Ljk>K}1}@V6BGEhRw}99@jT^aj() ztjw*YKZ7L1PAte+zS%bdM(-Xp9{t$5Q#_m_<6{r)F*u7UWRY`q6!e8fFhSS%u%SU! z(p?o2H3f_LiNfEMj#24T*TpZDsZr}0U!R|P;4InoYQ-0hB^>PD1>K#Mox;XV||1 DuyR!B literal 0 HcmV?d00001 diff --git a/src/main/resources/html/bitcoin.png b/src/main/resources/html/bitcoin.png new file mode 100644 index 0000000000000000000000000000000000000000..8a3b230902364af5ac1e62df68f3989d85bd6e1f GIT binary patch literal 1853 zcmWktX;f3!77idPAP%LEA`r$f$Ph%qf?za|Ad?6J3eTZv>rm9HJg7xjMJbgMDJ;Yg zl0af0A*4V6DPX{gpwL@v%w# z*w4>F27_#h04gqo3|te62p!-L$uQ3o{=gurT!1_fsCk%*V-Pw5bp)aag7uq$*cnJ& z@%k`O7Y@|nSUsQAMgrw_I)jN_5wV9s9!L33yWs-WZKF#vl?etbPYl6N1S6(b{0zK^1{W zl^>?shAFl{O$b^WVvu?xQeTuVkg-A421B`>;TjXGw5Jh>>_CMbCAEjO4xllDkU9Yg z3y4{?16!g=Z4@qX#KjJDJ5Uouw8VmHUsB>glb~{6*t!eT#^MqOC}9%v_jycYhgNOC z=q|NExYPlyvd1dyAcR1+3DC1Bfx zQ0YcAM8notvVks$q4fcZU_uoNTe!H~pHc-uO(><g z<0@iQLk!pA_wtF~u43J1(53|Pk1q1nFZiz|SkHNU;9F|(1=gBM8T9zTWgy%5{@2O; z$(Q#iWS*KGLdtEZr9Y{EW|1;$Vyp>V|Cku5gXmkdVHZL3ytoYs0Fec>c`v<6N#p5Og zJxDIkN=ZzxDeUPrUT3yKAh;yRXI9p)tVuh6e(uu!zScdXtHz(%_@`~0<=UQV?KbJ| zw(6e`LJl3jmG|tm3_)_{x4qUOCQgbrMpvwOxIIT5H`P5T88^MiFU&h@{ANryX*d|t zsZyPu)Sb-f9Arj)UT9jpq%Z&SrY>vo!6Rhu#oryS8Zv14XkNzd1=gX977J_GMiZ=s zGQ)BU+pUdnGm6}bMo)a2Ju#N@QFGR6OU-4gfU_xcHoFc#%sVDv2dwKXImxtLi(hZE z`Tl8$cJxHR-twY-Um0GB1CLu=UCL;Gi4CmEwYL6oa@aGN&1R1)M+WSJFU9rn{VMjG zJvosWV*h(+&al-7C&0)W*(ZZhYptcv`RS3#&4nJ8*Ps6(b1_zEKRo=Fbw64*YeNH}-Da>c2)mt-0yfcJ~pt9(!o|Oy@McwvXI# zrZV(Sb$EhZHrr0oU>vp7X`AUDUm(OU@*n5rP1y)0z>$LaxpdLdS5EyXm`WL$dhUqr z9k;N5u;H`8c1}eSXQZ|~dUZnmtOb?uWhU`-<-$^QoN?Isp;aG8&8+#!CC|z&rh$tM z9hkQHxzUSxG>7}eiXe>7O-0&-2I^A zlr80&naLEzGfYztXv<=rOazYT z%t|h%xBUD?Mfavx8Nnrs$%C1~GYW-CfaY=9aG(TNG~#OYiJfCEWLWdh=>;mw#Uq PeddfPejHC5mX`m2=YRzf literal 0 HcmV?d00001 diff --git a/src/main/resources/html/ethereum-address.png b/src/main/resources/html/ethereum-address.png new file mode 100644 index 0000000000000000000000000000000000000000..ac9e7fa815fe09e5d71d79aa27a6c86bdd737234 GIT binary patch literal 1949 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 nbvcb=;f+7$|HBVU7MY?@gt&LHeD@giXrP3HW literal 0 HcmV?d00001 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icon128.png b/src/main/resources/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..ce165a4e1ba44ef88b23cffce0cfda848e53b3b7 GIT binary patch literal 6192 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 E2ju>x;{X5v literal 0 HcmV?d00001 diff --git a/src/main/resources/icon16.png b/src/main/resources/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..4f7f7a8e7104a0f48251f476cca7f9809ba95029 GIT binary patch literal 686 zcmV;f0#W^mP)CUP!t{6maP8hjMx~eUS31y^1#lcdX^|a3I>2HmMD*=DvQM|hUBxsJ z!yxzkIi{|6p%)gGdsk(DMlelx;*38ZJ|ca81>$fZN`VMj%}qo{Mu>eLUj=HN24KW2 zYr-j_Szf6Q32c4aE%(6} z!Qe3wX#`#aS}!f3EenA;pvR%qzT#l;erikqj8JWD_RO>1`Y#O)r6VU!m}}vF0|NlH UH;@~GvH$=807*qoM6N<$g5ORktN;K2 literal 0 HcmV?d00001 diff --git a/src/main/resources/icon32.png b/src/main/resources/icon32.png new file mode 100644 index 0000000000000000000000000000000000000000..11ec023df12cf616ead8fe02b78475518210f32d GIT binary patch literal 1262 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 + + + + + %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 0000000000000000000000000000000000000000..6baba557bd0d1b004b432f2f1ebae6845a82d2e9 GIT binary patch literal 518454 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 BSzrJF literal 0 HcmV?d00001 diff --git a/src/main/resources/splash.png b/src/main/resources/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..22b58b589c04be3fec18d01996478e9b94f10734 GIT binary patch literal 6115 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 R_#h85x^ms1Ob_$uzW^hT^^5=j literal 0 HcmV?d00001 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + 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_/" + } +}