initial import

This commit is contained in:
0xboobface 2018-07-01 17:38:53 +02:00
commit 1ab902892d
77 changed files with 5384 additions and 0 deletions

37
.classpath Normal file
View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-10">
<attributes>
<attribute name="module" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/bin/
/target/
*~
*.bak
/ctbrec.log
/ctbrec-tunnel.sh
/jre/

23
.project Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>ctbrec</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
<nature>org.eclipse.jdt.core.javanature</nature>
</natures>
</projectDescription>

View File

@ -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/<project>=UTF-8

View File

@ -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

View File

@ -0,0 +1,4 @@
activeProfiles=
eclipse.preferences.version=1
resolveWorkspaceProjects=true
version=1

7
ctbrec.sh Executable file
View File

@ -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

158
pom.xml Normal file
View File

@ -0,0 +1,158 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ctbrec</groupId>
<artifactId>ctbrec</artifactId>
<version>1.0.0</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<name.final>${project.artifactId}-${project.version}-final</name.final>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<finalName>${name.final}</finalName>
<appendAssemblyId>false</appendAssemblyId>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</execution>
<execution>
<id>zip</id>
<phase>verify</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<descriptors>
<descriptor>src/assembly/win64.xml</descriptor>
<descriptor>src/assembly/win64-jre.xml</descriptor>
<descriptor>src/assembly/linux.xml</descriptor>
</descriptors>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.akathist.maven.plugins.launch4j</groupId>
<artifactId>launch4j-maven-plugin</artifactId>
<version>1.7.22</version>
<executions>
<execution>
<id>l4j-clui</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<headerType>gui</headerType>
<outfile>target/ctbrec.exe</outfile>
<jar>${name.final}.jar</jar>
<dontWrapJar>true</dontWrapJar>
<icon>src/main/resources/icon.ico</icon>
<errTitle>ctbrec</errTitle>
<classPath>
<mainClass>ctbrec.ui.Launcher</mainClass>
<addDependencies>false</addDependencies>
<preCp>anything</preCp>
</classPath>
<jre>
<path>jre</path>
<bundledJre64Bit>true</bundledJre64Bit>
<minVersion>1.8.0</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>Recorder for Charturbate streams</fileDescription>
<copyright>2018 blaueelise</copyright>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productName>CTB Recorder</productName>
<internalName>ctbrec</internalName>
<originalFilename>ctbrec.exe</originalFilename>
</versionInfo>
<splash>
<file>src/main/resources/splash.bmp</file>
<waitForWindow>true</waitForWindow>
<timeout>60</timeout>
<timeoutErr>true</timeoutErr>
</splash>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.10.3</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>3.10.0</version>
</dependency>
<dependency>
<groupId>com.squareup.moshi</groupId>
<artifactId>moshi</artifactId>
<version>1.5.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.25</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mozilla</groupId>
<artifactId>rhino</artifactId>
<version>1.7.7.1</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>9.3.8.v20160314</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-servlet</artifactId>
<version>9.3.8.v20160314</version>
</dependency>
<dependency>
<groupId>com.iheartradio.m3u8</groupId>
<artifactId>open-m3u8</artifactId>
<version>0.2.4</version>
</dependency>
<dependency>
<groupId>org.jcodec</groupId>
<artifactId>jcodec</artifactId>
<version>0.2.3</version>
</dependency>
</dependencies>
</project>

1
server.bat Executable file
View File

@ -0,0 +1 @@
java -cp ${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer

4
server.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
JAVA=java
$JAVA -cp ${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer

24
src/assembly/linux.xml Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<assembly>
<id>linux</id>
<formats>
<format>zip</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<files>
<file>
<source>${project.basedir}/ctbrec.sh</source>
<outputDirectory>ctbrec</outputDirectory>
<filtered>true</filtered>
</file>
<file>
<source>${project.basedir}/server.sh</source>
<outputDirectory>ctbrec</outputDirectory>
<filtered>true</filtered>
</file>
<file>
<source>${project.build.directory}/${name.final}.jar</source>
<outputDirectory>ctbrec</outputDirectory>
</file>
</files>
</assembly>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<assembly>
<id>win64-jre</id>
<formats>
<format>zip</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<files>
<file>
<source>${project.build.directory}/ctbrec.exe</source>
<outputDirectory>ctbrec</outputDirectory>
</file>
<file>
<source>${project.basedir}/server.bat</source>
<outputDirectory>ctbrec</outputDirectory>
<filtered>true</filtered>
</file>
<file>
<source>${project.build.directory}/${name.final}.jar</source>
<outputDirectory>ctbrec</outputDirectory>
</file>
</files>
<fileSets>
<fileSet>
<directory>jre</directory>
<includes>
<include>**/*</include>
</includes>
<outputDirectory>ctbrec/jre</outputDirectory>
<filtered>false</filtered>
</fileSet>
</fileSets>
</assembly>

23
src/assembly/win64.xml Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<assembly>
<id>win64</id>
<formats>
<format>zip</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<files>
<file>
<source>${project.build.directory}/ctbrec.exe</source>
<outputDirectory>ctbrec</outputDirectory>
</file>
<file>
<source>${project.basedir}/server.bat</source>
<outputDirectory>ctbrec</outputDirectory>
<filtered>true</filtered>
</file>
<file>
<source>${project.build.directory}/${name.final}.jar</source>
<outputDirectory>ctbrec</outputDirectory>
</file>
</files>
</assembly>

View File

@ -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<Settings> 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<Settings> 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);
}
}

View File

@ -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();
}
}

View File

@ -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<Instant> {
@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());
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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<String> getTags() {
return tags;
}
public void setTags(List<String> 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";
}
}

View File

@ -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<Model> parseModels(String html) {
List<Model> 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;
}
}

View File

@ -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;
}
}

View File

@ -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<Model> models = new ArrayList<Model>();
}

View File

@ -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<StreamInfo> adapter = moshi.adapter(StreamInfo.class);
StreamInfo streamInfo = adapter.fromJson(content);
return streamInfo;
}
}

View File

@ -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<Model> models = Collections.synchronizedList(new ArrayList<>());
private Lock lock = new ReentrantLock();
private Map<Model, Download> recordingProcesses = Collections.synchronizedMap(new HashMap<>());
private Map<File, PlaylistGenerator> 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<Model> 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<Model> restart = new ArrayList<Model>();
for (Iterator<Entry<Model,Download>> iterator = recordingProcesses.entrySet().iterator(); iterator.hasNext();) {
Entry<Model, Download> 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<Recording> 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<Model, Download> 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<Recording> getRecordings() {
List<Recording> 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);
}
}
}

View File

@ -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<String, String> entry : System.getenv().entrySet()) {
env[index++] = entry.getKey() + "=" + entry.getValue();
}
return env;
}
}

View File

@ -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<Model> getModelsRecording();
public List<Recording> getRecordings() throws IOException;
public void delete(Recording recording) throws IOException;
public void shutdown();
}

View File

@ -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<ModelListResponse> modelListResponseAdapter = moshi.adapter(ModelListResponse.class);
private JsonAdapter<RecordingListResponse> recordingListResponseAdapter = moshi.adapter(RecordingListResponse.class);
private JsonAdapter<Model> modelAdapter = moshi.adapter(Model.class);
private List<Model> 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\": \"<<action>>\", \"model\": <<model>>}";
requestTemplate = requestTemplate.replaceAll("<<action>>", action);
requestTemplate = requestTemplate.replaceAll("<<model>>", 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<Model> 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<Model> models;
}
private static class RecordingListResponse {
public String status;
public String msg;
public List<Recording> recordings;
}
@Override
public List<Recording> 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<Recording> 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);
}
}
}

View File

@ -0,0 +1,8 @@
package ctbrec.recorder;
public class StreamInfo {
public String url;
public String room_status;
public String hidden_message;
public boolean success;
}

View File

@ -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());
}
}
}

View File

@ -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();
}

View File

@ -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<TrackData> 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<String> segments = new ArrayList<>();
}
private static class SegmentDownload implements Callable<Boolean> {
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();
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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<ProgressListener> 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<TrackData> 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<TrackData> 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<Integer,Demuxer> 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<Integer, Demuxer> createM2TSDemuxer(FileChannelWrapper ch, TrackType targetTrack) throws IOException {
MTSDemuxer mts = new MTSDemuxer(ch);
Set<Integer> programs = mts.getPrograms();
if (programs.size() == 0) {
LOG.error("The MPEG TS stream contains no programs");
return null;
}
Tuple._2<Integer, Demuxer> 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);
}
}
}

View File

@ -0,0 +1,6 @@
package ctbrec.recorder.server;
@FunctionalInterface
public interface ProgressListener {
public void update(int percentage);
}

View File

@ -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<Request> 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<Model> modelAdapter = moshi.adapter(Model.class);
List<Model> models = recorder.getModelsRecording();
for (Iterator<Model> 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<Recording> recAdapter = moshi.adapter(Recording.class);
List<Recording> recordings = recorder.getRecordings();
for (Iterator<Recording> 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;
}
}

View File

@ -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);
}
}

View File

@ -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<String, List<Cookie>> cookieStore = new HashMap<>();
@Override
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
String host = getHost(url);
List<Cookie> cookiesForUrl = cookieStore.get(host);
if (cookiesForUrl != null) {
cookiesForUrl = new ArrayList<Cookie>(cookiesForUrl); //unmodifiable
for (Iterator<Cookie> 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<Cookie> loadForRequest(HttpUrl url) {
String host = getHost(url);
List<Cookie> cookies = cookieStore.get(host);
LOG.debug("Cookies for {}: {}", url.host(), cookies);
return cookies != null ? cookies : new ArrayList<Cookie>();
}
public Cookie getCookie(HttpUrl url, String name) {
List<Cookie> 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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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<String> getTags() {
return delegate.getTags();
}
@Override
public void setTags(List<String> 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;
}
}

View File

@ -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;
}
}

View File

@ -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<Tab>() {
@Override
public void changed(ObservableValue<? extends Tab> ov, Tab from, Tab to) {
if(from != null && from instanceof TabSelectionListener) {
((TabSelectionListener) from).deselected();
}
if(to != null && to instanceof TabSelectionListener) {
((TabSelectionListener) to).selected();
}
}
});
root.setTabClosingPolicy(TabClosingPolicy.SELECTED_TAB);
root.getTabs().add(createTab("Featured", BASE_URI + "/"));
root.getTabs().add(createTab("Female", BASE_URI + "/female-cams/"));
root.getTabs().add(createTab("Male", BASE_URI + "/male-cams/"));
root.getTabs().add(createTab("Couples", BASE_URI + "/couple-cams/"));
root.getTabs().add(createTab("Trans", BASE_URI + "/trans-cams/"));
FollowedTab tab = new FollowedTab("Followed", BASE_URI + "/followed-cams/");
tab.setRecorder(recorder);
root.getTabs().add(tab);
RecordedModelsTab modelsTab = new RecordedModelsTab("Recording", recorder);
root.getTabs().add(modelsTab);
RecordingsTab recordingsTab = new RecordingsTab("Recordings", recorder, config);
root.getTabs().add(recordingsTab);
root.getTabs().add(new SettingsTab());
root.getTabs().add(new DonateTabFx());
primaryStage.setScene(new Scene(root, 1340, 720));
primaryStage.show();
primaryStage.setOnCloseRequest((e) -> {
e.consume();
Alert shutdownInfo = new AutosizeAlert(Alert.AlertType.INFORMATION);
shutdownInfo.setTitle("Shutdown");
shutdownInfo.setContentText("Shutting down. Please wait a few seconds...");
shutdownInfo.show();
new Thread() {
@Override
public void run() {
recorder.shutdown();
client.shutdown();
try {
Config.getInstance().save();
LOG.info("Shutdown complete. Goodbye!");
Platform.exit();
// This is needed, because OkHttp?! seems to block the shutdown with its writer threads. They are not daemon threads :(
System.exit(0);
} catch (IOException e1) {
Platform.runLater(() -> {
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
alert.setTitle("Error saving settings");
alert.setContentText("Couldn't save settings: " + e1.getLocalizedMessage());
alert.showAndWait();
System.exit(1);
});
}
}
}.start();
});
}
Tab createTab(String title, String url) {
ThumbOverviewTab tab = new ThumbOverviewTab(title, url, false);
tab.setRecorder(recorder);
return tab;
}
public static void open(String uri) {
hostServices.showDocument(uri);
}
public static void main(String[] args) {
launch(args);
}
}

View File

@ -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 {
}
}
}

View File

@ -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<List<Model>> updateService;
private Recorder recorder;
FlowPane grid = new FlowPane();
ScrollPane scrollPane = new ScrollPane();
TableView<JavaFxModel> table = new TableView<JavaFxModel>();
ObservableList<JavaFxModel> 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<JavaFxModel, String> name = new TableColumn<>("Model");
name.setPrefWidth(200);
name.setCellValueFactory(new PropertyValueFactory<JavaFxModel, String>("name"));
TableColumn<JavaFxModel, String> url = new TableColumn<>("URL");
url.setCellValueFactory(new PropertyValueFactory<JavaFxModel, String>("url"));
url.setPrefWidth(400);
TableColumn<JavaFxModel, Boolean> 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<Model> 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<JavaFxModel> 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<List<Model>> createUpdateService() {
ScheduledService<List<Model>> updateService = new ScheduledService<List<Model>>() {
@Override
protected Task<List<Model>> createTask() {
return new Task<List<Model>>() {
@Override
public List<Model> 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();
}
};
}

View File

@ -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<List<JavaFxRecording>> updateService;
private Config config;
private Recorder recorder;
FlowPane grid = new FlowPane();
ScrollPane scrollPane = new ScrollPane();
TableView<JavaFxRecording> table = new TableView<JavaFxRecording>();
ObservableList<JavaFxRecording> 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<JavaFxRecording, String> name = new TableColumn<>("Model");
name.setPrefWidth(200);
name.setCellValueFactory(new PropertyValueFactory<JavaFxRecording, String>("modelName"));
TableColumn<JavaFxRecording, String> date = new TableColumn<>("Date");
date.setCellValueFactory(new PropertyValueFactory<JavaFxRecording, String>("startDate"));
date.setPrefWidth(200);
TableColumn<JavaFxRecording, String> status = new TableColumn<>("Status");
status.setCellValueFactory((cdf) -> cdf.getValue().getStatusProperty());
status.setPrefWidth(300);
TableColumn<JavaFxRecording, String> progress = new TableColumn<>("Progress");
progress.setCellValueFactory((cdf) -> cdf.getValue().getProgressProperty());
progress.setPrefWidth(100);
TableColumn<JavaFxRecording, String> 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<JavaFxRecording> recordings = updateService.getValue();
if (recordings == null) {
return;
}
for (Iterator<JavaFxRecording> 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<List<JavaFxRecording>> createUpdateService() {
ScheduledService<List<JavaFxRecording>> updateService = new ScheduledService<List<JavaFxRecording>>() {
@Override
protected Task<List<JavaFxRecording>> createTask() {
return new Task<List<JavaFxRecording>>() {
@Override
public List<JavaFxRecording> call() throws IOException {
List<JavaFxRecording> 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<TrackData> 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<TrackData> tracks = mediaPlaylist.getTracks();
List<String> 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);
}
}
}

View File

@ -0,0 +1,275 @@
package ctbrec.ui;
import java.io.File;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ctbrec.Config;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Tab;
import javafx.scene.control.TextField;
import javafx.scene.control.ToggleGroup;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.layout.BorderWidths;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
import javafx.scene.paint.Color;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;;
public class SettingsTab extends Tab {
private static final transient Logger LOG = LoggerFactory.getLogger(SettingsTab.class);
private TextField recordingsDirectory;
private TextField mediaPlayer;
private TextField username;
private TextField server;
private TextField port;
private PasswordField password;
private RadioButton recordLocal;
private RadioButton recordRemote;
private ToggleGroup recordLocation;
public SettingsTab() {
setText("Settings");
createGui();
setClosable(false);
}
private void createGui() {
GridPane layout = new GridPane();
layout.setOpacity(1);
layout.setPadding(new Insets(5));
layout.setHgap(5);
layout.setVgap(5);
setContent(layout);
int row = 0;
layout.add(new Label("Recordings Directory"), 0, row);
recordingsDirectory = new TextField(Config.getInstance().getSettings().recordingsDir);
recordingsDirectory.focusedProperty().addListener(createRecordingsDirectoryFocusListener());
recordingsDirectory.setPrefWidth(400);
GridPane.setFillWidth(recordingsDirectory, true);
GridPane.setHgrow(recordingsDirectory, Priority.ALWAYS);
GridPane.setColumnSpan(recordingsDirectory, 2);
layout.add(recordingsDirectory, 1, row);
layout.add(createRecordingsBrowseButton(), 3, row);
layout.add(new Label("Player"), 0, ++row);
mediaPlayer = new TextField(Config.getInstance().getSettings().mediaPlayer);
mediaPlayer.focusedProperty().addListener(createMpvFocusListener());
GridPane.setFillWidth(mediaPlayer, true);
GridPane.setHgrow(mediaPlayer, Priority.ALWAYS);
GridPane.setColumnSpan(mediaPlayer, 2);
layout.add(mediaPlayer, 1, row);
layout.add(createMpvBrowseButton(), 3, row);
layout.add(new Label("Chaturbate User"), 0, ++row);
username = new TextField(Config.getInstance().getSettings().username);
username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().username = username.getText());
GridPane.setFillWidth(username, true);
GridPane.setHgrow(username, Priority.ALWAYS);
GridPane.setColumnSpan(username, 2);
layout.add(username, 1, row);
layout.add(new Label("Chaturbate Password"), 0, ++row);
password = new PasswordField();
password.setText(Config.getInstance().getSettings().password);
password.focusedProperty().addListener((e) -> {
if(!password.getText().isEmpty()) {
Config.getInstance().getSettings().password = password.getText();
}
});
GridPane.setFillWidth(password, true);
GridPane.setHgrow(password, Priority.ALWAYS);
GridPane.setColumnSpan(password, 2);
layout.add(password, 1, row);
layout.add(new Label(), 0, ++row);
layout.add(new Label("Record Location"), 0, ++row);
recordLocation = new ToggleGroup();
recordLocal = new RadioButton("Local");
recordRemote = new RadioButton("Remote");
recordLocal.setToggleGroup(recordLocation);
recordRemote.setToggleGroup(recordLocation);
recordLocal.setSelected(Config.getInstance().getSettings().localRecording);
recordRemote.setSelected(!recordLocal.isSelected());
layout.add(recordLocal, 1, row);
layout.add(recordRemote, 2, row);
recordLocation.selectedToggleProperty().addListener((e) -> {
Config.getInstance().getSettings().localRecording = recordLocal.isSelected();
server.setDisable(recordLocal.isSelected());
port.setDisable(recordLocal.isSelected());
Alert restart = new AutosizeAlert(AlertType.INFORMATION);
restart.setTitle("Restart required");
restart.setHeaderText("Restart required");
restart.setContentText("Changes get applied after a restart of the application");
restart.show();
});
layout.add(new Label("Server"), 0, ++row);
server = new TextField(Config.getInstance().getSettings().httpServer);
server.focusedProperty().addListener((e) -> {
if(!server.getText().isEmpty()) {
Config.getInstance().getSettings().httpServer = server.getText();
}
});
GridPane.setFillWidth(server, true);
GridPane.setHgrow(server, Priority.ALWAYS);
GridPane.setColumnSpan(server, 2);
layout.add(server, 1, row);
layout.add(new Label("Port"), 0, ++row);
port = new TextField(Integer.toString(Config.getInstance().getSettings().httpPort));
port.focusedProperty().addListener((e) -> {
if(!port.getText().isEmpty()) {
try {
Config.getInstance().getSettings().httpPort = Integer.parseInt(port.getText());
port.setBorder(Border.EMPTY);
port.setTooltip(null);
} catch (NumberFormatException e1) {
port.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
port.setTooltip(new Tooltip("Port has to be a number in the range 1 - 65536"));
}
}
});
GridPane.setFillWidth(port, true);
GridPane.setHgrow(port, Priority.ALWAYS);
GridPane.setColumnSpan(port, 2);
layout.add(port, 1, row);
server.setDisable(recordLocal.isSelected());
port.setDisable(recordLocal.isSelected());
}
private ChangeListener<? super Boolean> createRecordingsDirectoryFocusListener() {
return new ChangeListener<Boolean>() {
@Override
public void changed(ObservableValue<? extends Boolean> arg0, Boolean oldPropertyValue, Boolean newPropertyValue) {
if (newPropertyValue) {
recordingsDirectory.setBorder(Border.EMPTY);
recordingsDirectory.setTooltip(null);
} else {
String input = recordingsDirectory.getText();
File newDir = new File(input);
setRecordingsDir(newDir);
}
}
};
}
private ChangeListener<? super Boolean> createMpvFocusListener() {
return new ChangeListener<Boolean>() {
@Override
public void changed(ObservableValue<? extends Boolean> arg0, Boolean oldPropertyValue, Boolean newPropertyValue) {
if (newPropertyValue) {
mediaPlayer.setBorder(Border.EMPTY);
mediaPlayer.setTooltip(null);
} else {
String input = mediaPlayer.getText();
File program = new File(input);
setMpv(program);
}
}
};
}
private void setMpv(File program) {
String msg = validateProgram(program);
if (msg != null) {
mediaPlayer.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
mediaPlayer.setTooltip(new Tooltip(msg));
} else {
Config.getInstance().getSettings().mediaPlayer = mediaPlayer.getText();
}
}
private String validateProgram(File program) {
if (program == null || !program.exists()) {
return "File does not exist";
} else if (!program.isFile() || !program.canExecute()) {
return "This is not an executable application";
}
return null;
}
private Node createRecordingsBrowseButton() {
Button button = new Button("Select");
button.setOnAction((e) -> {
DirectoryChooser chooser = new DirectoryChooser();
File currentDir = new File(Config.getInstance().getSettings().recordingsDir);
if (currentDir.exists() && currentDir.isDirectory()) {
chooser.setInitialDirectory(currentDir);
}
File selectedDir = chooser.showDialog(null);
if(selectedDir != null) {
setRecordingsDir(selectedDir);
}
});
return button;
}
private Node createMpvBrowseButton() {
Button button = new Button("Select");
button.setOnAction((e) -> {
FileChooser chooser = new FileChooser();
File program = chooser.showOpenDialog(null);
if(program != null) {
try {
mediaPlayer.setText(program.getCanonicalPath());
} catch (IOException e1) {
LOG.error("Couldn't determine path", e1);
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
alert.setTitle("Whoopsie");
alert.setContentText("Couldn't determine path");
alert.showAndWait();
}
setMpv(program);
}
});
return button;
}
private void setRecordingsDir(File dir) {
if (dir != null && dir.isDirectory()) {
try {
String path = dir.getCanonicalPath();
Config.getInstance().getSettings().recordingsDir = path;
recordingsDirectory.setText(path);
} catch (IOException e1) {
LOG.error("Couldn't determine directory path", e1);
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
alert.setTitle("Whoopsie");
alert.setContentText("Couldn't determine directory path");
alert.showAndWait();
}
} else {
recordingsDirectory.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
if (!dir.isDirectory()) {
recordingsDirectory.setTooltip(new Tooltip("This is not a directory"));
}
if (!dir.exists()) {
recordingsDirectory.setTooltip(new Tooltip("Directory does not exist"));
}
}
}
}

View File

@ -0,0 +1,6 @@
package ctbrec.ui;
public interface TabSelectionListener {
public void selected();
public void deselected();
}

View File

@ -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<Node> 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<Number>() {
@Override
public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
if(newValue.doubleValue() == 1.0) {
iv.setImage(img);
}
}
});
}
}
private ContextMenu createContextMenu() {
MenuItem openInPlayer = new MenuItem("Open in Player");
openInPlayer.setOnAction((e) -> startPlayer());
MenuItem start = new MenuItem("Start Recording");
start.setOnAction((e) -> startStopAction(true));
MenuItem stop = new MenuItem("Stop Recording");
stop.setOnAction((e) -> startStopAction(false));
MenuItem startStop = recorder.isRecording(model) ? stop : start;
MenuItem follow = new MenuItem("Follow");
follow.setOnAction((e) -> follow(true));
MenuItem unfollow = new MenuItem("Unfollow");
unfollow.setOnAction((e) -> follow(false));
ContextMenu contextMenu = new ContextMenu();
contextMenu.setAutoHide(true);
contextMenu.setHideOnEscape(true);
contextMenu.setAutoFix(true);
contextMenu.getItems().addAll(openInPlayer, startStop , follow, unfollow);
return contextMenu;
}
private Transition changeColor(Shape shape, Color from, Color to) {
FillTransition transition = new FillTransition(ANIMATION_DURATION, from, to);
transition.setShape(shape);
return transition;
}
private Transition changeOpacity(Shape shape, double opacity) {
FadeTransition transition = new FadeTransition(ANIMATION_DURATION, shape);
transition.setFromValue(shape.getOpacity());
transition.setToValue(opacity);
return transition;
}
private EventHandler<MouseEvent> doubleClickListener = new EventHandler<MouseEvent>() {
@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;
}
}

View File

@ -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<List<Model>> updateService;
Recorder recorder;
List<ThumbCell> 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<Model> models = updateService.getValue();
ObservableList<Node> nodes = grid.getChildren();
// first remove models, which are not in the updated list
for (Iterator<Node> 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<ThumbCell> positionChangedOrNew = new ArrayList<>();
int index = 0;
for (Model model : models) {
boolean found = false;
for (Iterator<Node> 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<Node>() {
@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<Node> 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<ThumbCell> 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<Node> 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<List<Model>> createUpdateService() {
ScheduledService<List<Model>> updateService = new ScheduledService<List<Model>>() {
@Override
protected Task<List<Model>> createTask() {
return new Task<List<Model>>() {
@Override
public List<Model> 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<Model> 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;
}
}

View File

@ -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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src/main/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
src/main/resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

103
src/main/resources/icon.svg Normal file
View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg1148"
width="512"
height="512"
viewBox="0 0 512 512"
sodipodi:docname="neu.svg"
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
inkscape:export-xdpi="109"
inkscape:export-ydpi="109">
<metadata
id="metadata1154">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1152" />
<sodipodi:namedview
pagecolor="#000000"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1127"
id="namedview1150"
showgrid="false"
inkscape:zoom="1.3304969"
inkscape:cx="215.65539"
inkscape:cy="83.064673"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g1839" />
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="punkt"
style="display:inline"
sodipodi:insensitive="true">
<circle
style="opacity:1;fill:#dc4444;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:10.54784775;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path5174"
cx="250"
cy="262"
r="225" />
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="trace"
transform="translate(0,-434)"
style="display:none"
sodipodi:insensitive="true">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#00ff03;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 73.521484,123.88672 c 0.250993,0.30345 0.470324,0.68165 0.732422,1.00781 9.397635,11.57167 16.545559,22.71263 26.089844,36.61133 12.27683,18.39505 25.22588,37.30655 37.86719,55.93555 12.27141,20.27346 21.43017,39.83595 29.96094,57.73632 1.11687,4.33459 2.48642,7.53662 4.03515,11.4336 1.07954,4.57588 3.04672,7.44617 3.69727,8.99023 l -0.0391,-0.0957 c 1.65002,4.05869 3.63581,6.7789 4.09571,7.91016 l 0.17578,0.43164 0.22266,0.4082 c 1.73987,3.18429 3.07526,5.28183 4.52343,8.2168 2.06739,3.02033 4.34241,6.68694 6.61914,8.67578 1.34112,1.83493 4.14485,4.76088 7.24145,9.11327 6.56481,9.36884 11.63836,19.6551 13.42258,24.31255 3.73338,8.82662 6.7394,18.04086 9.91994,26.5664 3.71276,9.42713 7.07927,21.00113 9.45703,31.51368 3.71106,19.2681 4.7465,40.52348 5.14429,57.57023 -0.1578,6.66983 -0.4875,10.61338 -1.1328,15.32227 -0.0657,0.16254 -0.23323,0.32756 -0.28321,0.48632 -0.13128,10.06636 17.34803,15.45078 16.50017,-3.63088 -0.0161,-0.4244 0.0487,-0.476 0.10157,-0.63672 0.091,-27.27982 -0.16385,-47.94818 3.21493,-73.71283 1.28467,-11.88406 2.50974,-22.2616 3.76962,-30.41211 l 0.24219,-0.48046 0.17578,-0.50586 c 1.0954,-5.95237 5.49845,-12.70007 8.82028,-17.14063 5.00574,-6.3766 9.06164,-11.25874 14.11914,-18.39453 0.88675,-1.44645 0.75182,-0.89473 1.62696,-2.23047 0.9664,-1.47664 1.58474,-2.52603 2.54297,-4.00781 4.87677,-5.99581 6.37465,-8.01517 10.23437,-12.05859 1.55998,-1.41522 2.52326,-2.14929 3.09766,-2.9668 0.78977,-0.74328 1.63181,-1.64556 2.46484,-2.79102 8.37635,-9.37722 21.01072,-32.833 23.61133,-38.3789 2.52507,-4.88994 22.84654,-39.41835 36.61719,-59.09766 6.94477,-10.22399 17.3911,-22.59444 26.17275,-32.18376 2.29015,-3.14458 30.80847,-29.77655 44.11045,-43.90218 -0.17039,0.15374 -0.32089,0.2966 -0.49414,0.45117 l 1.03516,-1.11328 c -0.18251,0.23724 -0.36038,0.4417 -0.54102,0.66211 20.66706,-19.9448 12.20395,-33.94238 2.95898,-25.42773 -2.31401,2.11975 -4.24349,4.00947 -6.15429,6.36133 -21.11022,17.59396 -46.01551,42.96135 -58.60547,58.73633 -21.48425,26.94258 -30.25514,41.38521 -49.44927,71.44371 -2.84094,3.52301 -14.52893,25.68453 -22.04878,38.70665 l -0.39844,0.48242 -0.32031,0.53515 c -0.87463,1.46604 -2.32368,3.4148 -3.78125,6.18164 -3.69575,5.73078 -7.41028,8.11964 -14.87305,13.93551 -1.88656,0.69662 0,0 -4.45502,1.56087 -3.04374,0.93051 -3.70222,0.96109 -7.58978,0.12115 -1.42138,-0.0923 -1.64176,-0.73073 -3.15832,-0.85968 -1.98527,-4.34566 -2.23534,-8.59954 -2.47266,-12.16207 -0.15997,-12.26407 1.08724,-26.07817 1.10648,-37.69263 0.18218,-10.91295 -0.34028,-16.03658 -0.51068,-28.09253 l -1.4992,-31.37379 c 4.76239,-47.11787 -14.44583,-30.16497 -14.43545,-25.25754 0.01,10.7032 -0.37543,47.68479 -0.3758,56.92235 l 0.004,0.0957 c -0.47922,13.63372 -0.19959,25.53598 -0.58779,39.83789 v 24.70508 c -0.33728,5.55211 -1.32196,9.24087 -2.85547,12.83398 l -0.40039,0.93946 c -0.3746,0.55504 -0.40305,0.54806 -0.99024,1.16015 l -1.31445,1.00196 c -1.03072,0.7958 -1.49562,1.24372 -3.6875,1.40039 -0.49223,0.0598 -0.87911,-0.0272 -1.32422,0.004 -3.11965,0.0202 -5.86002,-0.37465 -10.41602,-0.5664 -4.25205,-0.38992 -8.19291,-0.65147 -11.73828,-1.80274 -2.17427,-0.82558 -3.32302,-1.81936 -4.8125,-2.92383 -0.99472,-0.6378 -1.77528,-1.22711 -2.50586,-1.98047 -2.81713,-2.30187 -5.00526,-5.76644 -7.00589,-8.00976 -1.51898,-2.96253 -2.8967,-5.29724 -4.16601,-7.60547 0.0806,0.1887 0.16295,0.36565 0.24219,0.56055 l -0.39844,-0.83985 c 0.0496,0.0908 0.10627,0.18842 0.15625,0.2793 -1.5974,-3.74103 -3.41571,-6.27465 -3.85352,-7.35156 l -0.0176,-0.0469 -0.0215,-0.0469 c -1.66092,-3.94216 -2.74174,-5.36366 -2.99805,-6.79297 l -0.14257,-0.79883 -0.29883,-0.75781 c -4.37153,-15.11797 -9.81346,-26.86045 -16.46875,-40.98828 l -0.20508,-0.47852 -0.26367,-0.44726 c -16.3314,-26.09693 -33.03081,-56.85703 -51.5625,-80.08203 v 0 C 109.00577,143.83148 98.562834,129.31942 87.361698,115.24807 71.564249,96.36709 69.268167,120.08793 73.521484,123.88672 Z M 253.51953,328.92578 c 1.67698,0.74315 3.96647,1.47519 6.48242,1.80078 3.11906,0.97542 5.75735,1.00301 8.00782,0.95703 0.32891,-0.007 0.58384,-0.0152 0.90039,-0.0234 -0.0878,0.13246 -0.21436,0.30696 -0.27344,0.40625 -6.67225,6.93293 -9.01475,11.16817 -12.55657,16.23823 -5.37108,8.17595 -9.27436,14.75591 -12.06642,22.59366 0.0888,-0.19377 0.16855,-0.38305 0.26953,-0.58399 -1.05809,2.12603 -1.71749,4.54803 -2.36544,6.77545 -1.0068,3.66226 -1.02872,7.09779 -1.18924,10.10145 -3.09707,-8.0928 -5.27145,-16.45296 -7.64846,-23.31446 -2.54193,-6.67879 -4.56048,-11.96081 -6.97656,-17.67383 -1.59938,-4.0977 -3.52432,-7.06936 -5.76954,-11.64257 0.19329,0.0231 0.2624,0.013 0.008,-0.11133 1.12508,0.77559 5.28635,1.69018 6.59273,1.71873 2.89596,0.0636 3.50757,-0.0287 8.88383,-0.32221 6.01088,-0.55437 11.4497,-2.41311 14.21484,-4.44726 1.26307,-0.65219 2.43234,-1.49911 3.48633,-2.47266 z"
transform="translate(0,434)"
id="path1159"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" />
</g>
<g
style="display:inline"
transform="translate(0,-434)"
inkscape:label="trace Kopie 1"
id="g1839"
inkscape:groupmode="layer">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#fffffb;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 75.416476,128.11649 c 0.761399,1.00889 15.382989,19.49065 24.927274,33.38935 12.27683,18.39505 25.22588,37.30655 37.86719,55.93555 12.27141,20.27346 21.43017,39.83595 29.96094,57.73632 1.11687,4.33459 2.48642,7.53662 4.03515,11.4336 1.07954,4.57588 3.04672,7.44617 3.69727,8.99023 l -0.0391,-0.0957 c 1.65002,4.05869 3.63581,6.7789 4.09571,7.91016 l 0.17578,0.43164 0.22266,0.4082 c 1.73986,3.18429 3.07526,5.28183 4.52343,8.2168 2.06739,3.02033 4.34241,6.68694 6.61914,8.67578 1.34112,1.83493 4.14559,4.76089 7.24219,9.11328 6.56481,9.36884 11.63766,19.65505 13.42188,24.3125 3.73338,8.82662 6.73938,18.04087 9.91992,26.5664 3.71276,9.42713 7.07927,21.00113 9.45703,31.51368 3.71106,19.2681 3.21879,40.58999 3.61658,57.63674 0.11604,7.50513 0.13643,6.28502 0.2434,11.15195 5.84654,0.3751 13.1437,0.67056 18.09231,0.48045 1.09989,-27.56568 -1.27101,-51.29321 1.59266,-73.87065 1.28467,-11.88406 2.50965,-22.2616 3.76953,-30.41211 l 0.24219,-0.48046 0.17578,-0.50586 c 1.0954,-5.95237 5.49848,-12.70007 8.82031,-17.14063 5.00574,-6.3766 9.06164,-11.25874 14.11914,-18.39453 0.88675,-1.44645 0.75182,-0.89473 1.62696,-2.23047 0.9664,-1.47664 1.58474,-2.52603 2.54297,-4.00781 4.87677,-5.99581 6.37465,-8.01517 10.23437,-12.05859 1.55998,-1.41522 2.52326,-2.14929 3.09766,-2.9668 0.78977,-0.74328 1.63181,-1.64556 2.46484,-2.79102 8.37635,-9.37722 21.01072,-32.833 23.61133,-38.3789 2.52507,-4.88994 22.84654,-39.41835 36.61719,-59.09766 6.94477,-10.22399 17.39022,-22.59427 26.17187,-32.18359 13.85434,-16.09403 32.2935,-29.74285 45.5904,-45.5415 -2.91416,-4.39287 -6.779,-9.9442 -9.99446,-14.12159 -20.89276,17.55391 -40.89257,39.9027 -53.28539,55.43067 -21.48425,26.94257 -30.25509,41.38485 -49.44922,71.44335 -2.84094,3.52301 -14.52898,25.68492 -22.04883,38.70704 l -0.39844,0.48242 -0.32031,0.53515 c -0.87463,1.46604 -2.32368,3.4148 -3.78125,6.18164 -3.69575,5.73078 -7.41028,8.11968 -14.87305,13.93555 -1.88656,0.69662 -5e-5,-3.2e-4 -4.45507,1.56055 -3.04374,0.93051 -3.70229,0.96103 -7.58985,0.12109 -1.42138,-0.0923 -1.64164,-0.73042 -3.1582,-0.85937 -1.98527,-4.34566 -2.23534,-8.59958 -2.47266,-12.16211 -0.15997,-12.26407 1.08819,-26.0789 1.10742,-37.69336 0.18219,-10.91295 -0.34131,-16.03585 -0.51171,-28.0918 l -1.5,-31.37305 c 4.76239,-47.11787 -14.44398,-30.16524 -14.4336,-25.25781 0.01,10.7032 -0.37658,47.68432 -0.37695,56.92188 l 0.004,0.0957 c -0.47922,13.63372 -0.1997,25.53598 -0.58789,39.83789 v 24.70508 c -0.33728,5.55211 -1.32196,9.24087 -2.85547,12.83398 l -0.40039,0.93946 c -0.3746,0.55504 -0.40305,0.54806 -0.99024,1.16015 l -1.31445,1.00196 c -1.03072,0.7958 -1.49562,1.24372 -3.6875,1.40039 -0.49223,0.0598 -0.87911,-0.0273 -1.32422,0.004 -3.11965,0.0202 -5.86002,-0.37465 -10.41602,-0.5664 -4.25205,-0.38992 -8.19291,-0.65147 -11.73828,-1.80274 -2.17427,-0.82558 -3.32302,-1.81936 -4.8125,-2.92383 -0.99472,-0.6378 -1.77528,-1.2271 -2.50586,-1.98047 -2.81713,-2.30187 -5.00523,-5.76644 -7.00586,-8.00976 -1.51898,-2.96253 -2.8967,-5.29724 -4.16601,-7.60547 0.0806,0.1887 0.16295,0.36565 0.24219,0.56055 l -0.39844,-0.83985 c 0.0496,0.0908 0.10627,0.18842 0.15625,0.2793 -1.5974,-3.74103 -3.41571,-6.27465 -3.85352,-7.35156 l -0.0176,-0.0469 -0.0215,-0.0469 c -1.66092,-3.94216 -2.74174,-5.36366 -2.99805,-6.79297 l -0.14257,-0.79883 -0.29883,-0.75781 C 183.12652,265.8 177.68459,254.05752 171.0293,239.92969 l -0.20508,-0.47852 -0.26367,-0.44726 c -16.3314,-26.09693 -33.03081,-56.85704 -51.5625,-80.08203 -11.31721,-16.77981 -31.139626,-42.91386 -32.234615,-44.33816 -4.600939,5.0014 -11.346959,13.53277 -11.346959,13.53277 z M 253.51953,328.92578 c 1.67698,0.74315 3.96647,1.47519 6.48242,1.80078 3.11906,0.97542 5.75735,1.00301 8.00782,0.95703 0.32891,-0.007 0.58384,-0.0152 0.90039,-0.0234 -0.0878,0.13246 -0.21436,0.30696 -0.27344,0.40625 -6.67225,6.93293 -9.01482,11.16822 -12.55664,16.23828 -5.37108,8.17595 -9.27435,14.756 -12.06641,22.59375 0.0888,-0.19377 0.16855,-0.38305 0.26953,-0.58399 -1.05809,2.12603 -1.71728,4.54797 -2.36523,6.77539 -1.0068,3.66226 -1.02893,7.09791 -1.18945,10.10157 -3.09707,-8.0928 -5.27143,-16.45296 -7.64844,-23.31446 -2.54193,-6.67879 -4.56048,-11.9608 -6.97656,-17.67383 -1.59938,-4.09769 -3.52432,-7.06936 -5.76954,-11.64257 0.19329,0.0231 0.26222,0.013 0.008,-0.11133 1.12508,0.77559 5.28737,1.6902 6.59375,1.71875 2.89596,0.0636 3.50655,-0.0288 8.88281,-0.32227 6.01088,-0.55437 11.4497,-2.41311 14.21484,-4.44726 1.26307,-0.65219 2.43234,-1.49911 3.48633,-2.47266 z"
transform="translate(0,434)"
id="path1837"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" />
</g>
<g
style="display:none"
transform="translate(0,-434)"
inkscape:label="trace Kopie"
id="g1828"
inkscape:groupmode="layer" />
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,59 @@
<configuration scan="true" scanPeriod="30 seconds">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder
by default -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>ctbrec.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>WARN</level>
</filter>
<encoder>
<pattern>%date %level [%thread] %logger{10} [%file:%line] %msg%n
</pattern>
</encoder>
</appender>
<!--
<appender name="SOCKET" class="ch.qos.logback.classic.net.SocketAppender">
<RemoteHost>localhost</RemoteHost>
<Port>4560</Port>
<ReconnectionDelay>170</ReconnectionDelay>
<IncludeCallerData>true</IncludeCallerData>
</appender>
-->
<root level="debug">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
<!--
<appender-ref ref="SOCKET" />
-->
</root>
ctbrec.LoggingInterceptor
<logger name="ctbrec.LoggingInterceptor" level="INFO"/>
<logger name="ctbrec.recorder.Chaturbate" level="INFO" />
<logger name="ctbrec.recorder.server.HlsServlet" level="INFO"/>
<logger name="ctbrec.recorder.server.RecorderServlet" level="INFO"/>
<logger name="ctbrec.ui.CookieJarImpl" level="INFO"/>
<logger name="ctbrec.ui.ThumbOverviewTab" level="DEBUG"/>
<logger name="org.eclipse.jetty" level="INFO" />
<!--
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="WARN"/>
<logger name="SignalR" level="INFO"/>
<logger name="org.hampelratte.parkett.exchange.bittrex.Bittrex" level="INFO"/>
<logger name="org.hampelratte.parkett.price.tracker.bittrex.ws.PriceTracker" level="INFO"/>
<logger name="org.ta4j" level="ERROR"/>
-->
</configuration>

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg1148"
width="640"
height="480"
viewBox="0 0 640.00003 479.99999"
sodipodi:docname="splash.svg"
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
inkscape:export-xdpi="58.069363"
inkscape:export-ydpi="58.069363">
<metadata
id="metadata1154">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1152" />
<sodipodi:namedview
pagecolor="#000000"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1127"
id="namedview1150"
showgrid="false"
inkscape:zoom="0.33262423"
inkscape:cx="162.11306"
inkscape:cy="287.33271"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer5"
units="px" />
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="punkt"
style="display:inline"
transform="translate(0,-32.000036)">
<ellipse
style="opacity:1;fill:#dc4444;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:19.40897942;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path5174"
cx="324.89761"
cy="266.24524"
rx="484.23657"
ry="353.98505" />
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="trace"
transform="translate(0,-466.00004)"
style="display:none">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#00ff03;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 73.521484,123.88672 c 0.250993,0.30345 0.470324,0.68165 0.732422,1.00781 9.397635,11.57167 16.545559,22.71263 26.089844,36.61133 12.27683,18.39505 25.22588,37.30655 37.86719,55.93555 12.27141,20.27346 21.43017,39.83595 29.96094,57.73632 1.11687,4.33459 2.48642,7.53662 4.03515,11.4336 1.07954,4.57588 3.04672,7.44617 3.69727,8.99023 l -0.0391,-0.0957 c 1.65002,4.05869 3.63581,6.7789 4.09571,7.91016 l 0.17578,0.43164 0.22266,0.4082 c 1.73987,3.18429 3.07526,5.28183 4.52343,8.2168 2.06739,3.02033 4.34241,6.68694 6.61914,8.67578 1.34112,1.83493 4.14485,4.76088 7.24145,9.11327 6.56481,9.36884 11.63836,19.6551 13.42258,24.31255 3.73338,8.82662 6.7394,18.04086 9.91994,26.5664 3.71276,9.42713 7.07927,21.00113 9.45703,31.51368 3.71106,19.2681 4.7465,40.52348 5.14429,57.57023 -0.1578,6.66983 -0.4875,10.61338 -1.1328,15.32227 -0.0657,0.16254 -0.23323,0.32756 -0.28321,0.48632 -0.13128,10.06636 17.34803,15.45078 16.50017,-3.63088 -0.0161,-0.4244 0.0487,-0.476 0.10157,-0.63672 0.091,-27.27982 -0.16385,-47.94818 3.21493,-73.71283 1.28467,-11.88406 2.50974,-22.2616 3.76962,-30.41211 l 0.24219,-0.48046 0.17578,-0.50586 c 1.0954,-5.95237 5.49845,-12.70007 8.82028,-17.14063 5.00574,-6.3766 9.06164,-11.25874 14.11914,-18.39453 0.88675,-1.44645 0.75182,-0.89473 1.62696,-2.23047 0.9664,-1.47664 1.58474,-2.52603 2.54297,-4.00781 4.87677,-5.99581 6.37465,-8.01517 10.23437,-12.05859 1.55998,-1.41522 2.52326,-2.14929 3.09766,-2.9668 0.78977,-0.74328 1.63181,-1.64556 2.46484,-2.79102 8.37635,-9.37722 21.01072,-32.833 23.61133,-38.3789 2.52507,-4.88994 22.84654,-39.41835 36.61719,-59.09766 6.94477,-10.22399 17.3911,-22.59444 26.17275,-32.18376 2.29015,-3.14458 30.80847,-29.77655 44.11045,-43.90218 -0.17039,0.15374 -0.32089,0.2966 -0.49414,0.45117 l 1.03516,-1.11328 c -0.18251,0.23724 -0.36038,0.4417 -0.54102,0.66211 20.66706,-19.9448 12.20395,-33.94238 2.95898,-25.42773 -2.31401,2.11975 -4.24349,4.00947 -6.15429,6.36133 -21.11022,17.59396 -46.01551,42.96135 -58.60547,58.73633 -21.48425,26.94258 -30.25514,41.38521 -49.44927,71.44371 -2.84094,3.52301 -14.52893,25.68453 -22.04878,38.70665 l -0.39844,0.48242 -0.32031,0.53515 c -0.87463,1.46604 -2.32368,3.4148 -3.78125,6.18164 -3.69575,5.73078 -7.41028,8.11964 -14.87305,13.93551 -1.88656,0.69662 0,0 -4.45502,1.56087 -3.04374,0.93051 -3.70222,0.96109 -7.58978,0.12115 -1.42138,-0.0923 -1.64176,-0.73073 -3.15832,-0.85968 -1.98527,-4.34566 -2.23534,-8.59954 -2.47266,-12.16207 -0.15997,-12.26407 1.08724,-26.07817 1.10648,-37.69263 0.18218,-10.91295 -0.34028,-16.03658 -0.51068,-28.09253 l -1.4992,-31.37379 c 4.76239,-47.11787 -14.44583,-30.16497 -14.43545,-25.25754 0.01,10.7032 -0.37543,47.68479 -0.3758,56.92235 l 0.004,0.0957 c -0.47922,13.63372 -0.19959,25.53598 -0.58779,39.83789 v 24.70508 c -0.33728,5.55211 -1.32196,9.24087 -2.85547,12.83398 l -0.40039,0.93946 c -0.3746,0.55504 -0.40305,0.54806 -0.99024,1.16015 l -1.31445,1.00196 c -1.03072,0.7958 -1.49562,1.24372 -3.6875,1.40039 -0.49223,0.0598 -0.87911,-0.0272 -1.32422,0.004 -3.11965,0.0202 -5.86002,-0.37465 -10.41602,-0.5664 -4.25205,-0.38992 -8.19291,-0.65147 -11.73828,-1.80274 -2.17427,-0.82558 -3.32302,-1.81936 -4.8125,-2.92383 -0.99472,-0.6378 -1.77528,-1.22711 -2.50586,-1.98047 -2.81713,-2.30187 -5.00526,-5.76644 -7.00589,-8.00976 -1.51898,-2.96253 -2.8967,-5.29724 -4.16601,-7.60547 0.0806,0.1887 0.16295,0.36565 0.24219,0.56055 l -0.39844,-0.83985 c 0.0496,0.0908 0.10627,0.18842 0.15625,0.2793 -1.5974,-3.74103 -3.41571,-6.27465 -3.85352,-7.35156 l -0.0176,-0.0469 -0.0215,-0.0469 c -1.66092,-3.94216 -2.74174,-5.36366 -2.99805,-6.79297 l -0.14257,-0.79883 -0.29883,-0.75781 c -4.37153,-15.11797 -9.81346,-26.86045 -16.46875,-40.98828 l -0.20508,-0.47852 -0.26367,-0.44726 c -16.3314,-26.09693 -33.03081,-56.85703 -51.5625,-80.08203 v 0 C 109.00577,143.83148 98.562834,129.31942 87.361698,115.24807 71.564249,96.36709 69.268167,120.08793 73.521484,123.88672 Z M 253.51953,328.92578 c 1.67698,0.74315 3.96647,1.47519 6.48242,1.80078 3.11906,0.97542 5.75735,1.00301 8.00782,0.95703 0.32891,-0.007 0.58384,-0.0152 0.90039,-0.0234 -0.0878,0.13246 -0.21436,0.30696 -0.27344,0.40625 -6.67225,6.93293 -9.01475,11.16817 -12.55657,16.23823 -5.37108,8.17595 -9.27436,14.75591 -12.06642,22.59366 0.0888,-0.19377 0.16855,-0.38305 0.26953,-0.58399 -1.05809,2.12603 -1.71749,4.54803 -2.36544,6.77545 -1.0068,3.66226 -1.02872,7.09779 -1.18924,10.10145 -3.09707,-8.0928 -5.27145,-16.45296 -7.64846,-23.31446 -2.54193,-6.67879 -4.56048,-11.96081 -6.97656,-17.67383 -1.59938,-4.0977 -3.52432,-7.06936 -5.76954,-11.64257 0.19329,0.0231 0.2624,0.013 0.008,-0.11133 1.12508,0.77559 5.28635,1.69018 6.59273,1.71873 2.89596,0.0636 3.50757,-0.0287 8.88383,-0.32221 6.01088,-0.55437 11.4497,-2.41311 14.21484,-4.44726 1.26307,-0.65219 2.43234,-1.49911 3.48633,-2.47266 z"
transform="translate(0,434)"
id="path1159"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" />
</g>
<g
style="display:inline"
transform="translate(0,-466.00004)"
inkscape:label="trace Kopie 1"
id="g1839"
inkscape:groupmode="layer">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#fffffb;stroke-width:3.6516118;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 270.27055,632.04195 c 0.27803,0.36841 5.61727,7.11722 9.10247,12.19249 4.48303,6.71715 9.21152,13.62288 13.82763,20.42547 4.48104,7.40307 7.82547,14.54653 10.94057,21.08304 0.40784,1.58283 0.90795,2.75208 1.47348,4.17511 0.39421,1.67093 1.11255,2.71904 1.3501,3.28287 l -0.0143,-0.0349 c 0.60253,1.48207 1.32766,2.47539 1.4956,2.88848 l 0.0642,0.15762 0.0813,0.14906 c 0.63533,1.16278 1.12296,1.92872 1.65178,3.00045 0.75493,1.10291 1.58568,2.44181 2.41705,3.16806 0.48973,0.67005 1.51381,1.73848 2.64457,3.32781 2.39721,3.42114 4.24962,7.17724 4.90115,8.87794 1.36328,3.2231 2.46096,6.5878 3.62237,9.701 1.35575,3.4424 2.58507,7.6688 3.45334,11.5076 1.35513,7.036 1.17538,14.8219 1.32063,21.0467 0.0424,2.7406 0.0498,2.295 0.0889,4.0722 2.13493,0.137 4.79957,0.2449 6.60661,0.1755 0.40164,-10.0659 -0.46412,-18.7303 0.58158,-26.9747 0.46911,-4.3396 0.91643,-8.1291 1.37649,-11.1053 l 0.0884,-0.1755 0.0642,-0.1847 c 0.39999,-2.1736 2.00783,-4.6376 3.22083,-6.2591 1.8279,-2.3285 3.30896,-4.1112 5.15576,-6.717 0.32381,-0.5281 0.27454,-0.3267 0.59411,-0.8144 0.35289,-0.5392 0.57868,-0.9224 0.92859,-1.4635 1.78081,-2.18943 2.32777,-2.92682 3.73719,-4.40331 0.56965,-0.51678 0.9214,-0.78484 1.13115,-1.08336 0.28839,-0.27142 0.59587,-0.60089 0.90006,-1.01917 3.05872,-3.4242 7.6723,-11.98933 8.62194,-14.01448 0.92206,-1.78561 8.34267,-14.39404 13.37118,-21.58015 2.53596,-3.7334 6.35023,-8.25055 9.55695,-11.75219 5.05907,-5.87691 11.79233,-10.86092 16.64785,-16.62997 -1.06414,-1.60411 -2.47543,-3.63124 -3.64959,-5.15665 -7.62923,6.41 -14.93238,14.5709 -19.45776,20.24111 -7.84521,9.83837 -11.04798,15.11213 -18.05693,26.08832 -1.03741,1.28646 -5.30542,9.37912 -8.05138,14.13429 l -0.1455,0.17616 -0.11696,0.19541 c -0.31938,0.53534 -0.84852,1.24696 -1.38077,2.2573 -1.34954,2.09266 -2.70594,2.96499 -5.43106,5.08872 -0.6889,0.25438 -2e-5,-1.2e-4 -1.62682,0.56985 -1.11145,0.33979 -1.35193,0.35093 -2.77151,0.0442 -0.51904,-0.0337 -0.59947,-0.26672 -1.15326,-0.31381 -0.72494,-1.58687 -0.81626,-3.14023 -0.90292,-4.44113 -0.0584,-4.47835 0.39737,-9.52299 0.40439,-13.76414 0.0665,-3.98498 -0.12463,-5.85566 -0.18686,-10.25803 l -0.54774,-11.45621 c 1.73904,-17.20559 -5.27438,-11.01516 -5.27059,-9.22316 0.004,3.90839 -0.13751,17.41244 -0.13765,20.78564 l 0.001,0.035 c -0.17499,4.9785 -0.0729,9.32474 -0.21467,14.54724 v 9.02133 c -0.12316,2.02741 -0.48273,3.3744 -1.04271,4.68647 l -0.1462,0.34305 c -0.13679,0.20268 -0.14718,0.20013 -0.3616,0.42364 l -0.47999,0.36588 c -0.37638,0.2906 -0.54614,0.45416 -1.34653,0.51137 -0.17974,0.0218 -0.32102,-0.01 -0.48355,10e-4 -1.13918,0.007 -2.13985,-0.13681 -3.80353,-0.20683 -1.55268,-0.14238 -2.99173,-0.23789 -4.28636,-0.65829 -0.79396,-0.30147 -1.21344,-0.66436 -1.75734,-1.06767 -0.36323,-0.2329 -0.64826,-0.44809 -0.91504,-0.72319 -1.02871,-0.84055 -1.82772,-2.10568 -2.55827,-2.92485 -0.55467,-1.0818 -1.05776,-1.93435 -1.52127,-2.77723 0.0294,0.0689 0.0595,0.13353 0.0884,0.2047 l -0.14549,-0.30668 c 0.0181,0.0332 0.0388,0.0688 0.0571,0.10198 -0.58331,-1.36607 -1.24729,-2.29124 -1.40716,-2.68449 l -0.006,-0.0171 -0.008,-0.0171 c -0.6065,-1.43953 -1.00117,-1.9586 -1.09477,-2.48053 l -0.0521,-0.2917 -0.10912,-0.27673 c -1.59633,-5.52052 -3.58351,-9.80841 -6.01377,-14.96735 l -0.0749,-0.17473 -0.0963,-0.16333 c -5.96359,-9.52957 -12.06157,-20.76196 -18.82862,-29.24282 -4.13261,-6.12733 -11.37098,-15.67046 -11.77083,-16.19056 -1.68009,1.82632 -4.14347,4.94164 -4.14347,4.94164 z m 65.03632,73.32769 c 0.61237,0.27137 1.4484,0.53871 2.36713,0.65761 1.13896,0.3562 2.10236,0.3662 2.92415,0.3494 0.1201,0 0.21319,-0.01 0.32878,-0.01 -0.0321,0.048 -0.0783,0.1121 -0.0999,0.1483 -2.43644,2.5317 -3.29186,4.0782 -4.58519,5.9296 -1.96131,2.9856 -3.38664,5.3883 -4.40619,8.2504 0.0324,-0.071 0.0616,-0.1399 0.0984,-0.2133 -0.38637,0.7764 -0.62708,1.6608 -0.86369,2.4741 -0.36764,1.3374 -0.37572,2.5919 -0.43434,3.6887 -1.13093,-2.9551 -1.92492,-6.0079 -2.79291,-8.5135 -0.92822,-2.4388 -1.66531,-4.3676 -2.54757,-6.4538 -0.58403,-1.4963 -1.28695,-2.5814 -2.10681,-4.2514 0.0706,0.01 0.0958,0 0.003,-0.041 0.41083,0.2833 1.93074,0.6172 2.40778,0.6277 1.05749,0.023 1.28045,-0.01 3.24365,-0.1177 2.19494,-0.2024 4.18099,-0.8812 5.19071,-1.624 0.46123,-0.2381 0.8882,-0.5474 1.27307,-0.9029 z"
id="path1837"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" />
</g>
<g
style="display:none"
transform="translate(0,-466.00004)"
inkscape:label="trace Kopie"
id="g1828"
inkscape:groupmode="layer" />
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1 @@
{"action": "list"}

View File

@ -0,0 +1,7 @@
{
"action": "start",
"model": {
"name": "_pinkrose_",
"url": "https://de.chaturbate.com/_pinkrose_/"
}
}

View File

@ -0,0 +1,7 @@
{
"action": "start",
"model": {
"name": "queen_squirt_orgasm",
"url": "https://de.chaturbate.com/queen_squirt_orgasm/"
}
}

View File

@ -0,0 +1,7 @@
{
"action": "start",
"model": {
"name": "uv_",
"url": "https://de.chaturbate.com/uv_/"
}
}

View File

@ -0,0 +1,7 @@
{
"action": "stop",
"model": {
"name": "_pinkrose_",
"url": "https://de.chaturbate.com/_pinkrose_/"
}
}

View File

@ -0,0 +1,7 @@
{
"action": "stop",
"model": {
"name": "queen_squirt_orgasm",
"url": "https://de.chaturbate.com/queen_squirt_orgasm/"
}
}

View File

@ -0,0 +1,7 @@
{
"action": "stop",
"model": {
"name": "uv_",
"url": "https://de.chaturbate.com/uv_/"
}
}