initial import
|
@ -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>
|
|
@ -0,0 +1,7 @@
|
|||
/bin/
|
||||
/target/
|
||||
*~
|
||||
*.bak
|
||||
/ctbrec.log
|
||||
/ctbrec-tunnel.sh
|
||||
/jre/
|
|
@ -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>
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
activeProfiles=
|
||||
eclipse.preferences.version=1
|
||||
resolveWorkspaceProjects=true
|
||||
version=1
|
|
@ -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
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
java -cp ${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
|
||||
JAVA=java
|
||||
$JAVA -cp ${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>();
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package ctbrec.recorder;
|
||||
|
||||
public class StreamInfo {
|
||||
public String url;
|
||||
public String room_status;
|
||||
public String hidden_message;
|
||||
public boolean success;
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package ctbrec.recorder.server;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ProgressListener {
|
||||
public void update(int percentage);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package ctbrec.ui;
|
||||
|
||||
public interface TabSelectionListener {
|
||||
public void selected();
|
||||
public void deselected();
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 88 KiB |
After Width: | Height: | Size: 27 KiB |
|
@ -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 |
After Width: | Height: | Size: 6.0 KiB |
After Width: | Height: | Size: 686 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 2.7 KiB |
|
@ -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>
|
After Width: | Height: | Size: 506 KiB |
After Width: | Height: | Size: 6.0 KiB |
|
@ -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 |
|
@ -0,0 +1 @@
|
|||
{"action": "list"}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"action": "start",
|
||||
"model": {
|
||||
"name": "_pinkrose_",
|
||||
"url": "https://de.chaturbate.com/_pinkrose_/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"action": "start",
|
||||
"model": {
|
||||
"name": "queen_squirt_orgasm",
|
||||
"url": "https://de.chaturbate.com/queen_squirt_orgasm/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"action": "start",
|
||||
"model": {
|
||||
"name": "uv_",
|
||||
"url": "https://de.chaturbate.com/uv_/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"action": "stop",
|
||||
"model": {
|
||||
"name": "_pinkrose_",
|
||||
"url": "https://de.chaturbate.com/_pinkrose_/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"action": "stop",
|
||||
"model": {
|
||||
"name": "queen_squirt_orgasm",
|
||||
"url": "https://de.chaturbate.com/queen_squirt_orgasm/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"action": "stop",
|
||||
"model": {
|
||||
"name": "uv_",
|
||||
"url": "https://de.chaturbate.com/uv_/"
|
||||
}
|
||||
}
|