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