diff --git a/.classpath b/.classpath index b9241a5e..0f3dcb1f 100644 --- a/.classpath +++ b/.classpath @@ -1,6 +1,6 @@ - + @@ -11,7 +11,7 @@ - + diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a025d00..13a13303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +1.8.0 +======================== +* Added BongaCams +* Added possibility to suspend the recording for a model. The model stays in + the list of recorded models, but the actual recording is suspended +* HTTP sessions are restored on startup. This should reduce the number of + logins needed (especially for Cam4, BongaCams and CamSoda). +* Server can run now run on OpenJRE +* Added JVM parameter to define the configuration directory + (``-Dctbrec.config.dir``) +* Improved memory management for MyFreeCams + 1.7.0 ======================== * Added CamSoda diff --git a/pom.xml b/pom.xml index 5994a6db..e0c984d2 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 ctbrec ctbrec - 1.7.0 + 1.8.0 UTF-8 diff --git a/src/assembly/linux.xml b/src/assembly/linux.xml index 26c75a93..83413326 100644 --- a/src/assembly/linux.xml +++ b/src/assembly/linux.xml @@ -16,6 +16,10 @@ ctbrec true + + ${project.basedir}/src/main/resources/pp.sh + ctbrec + ${project.build.directory}/${name.final}.jar ctbrec diff --git a/src/assembly/macos-jre.xml b/src/assembly/macos-jre.xml index c8a1c261..84a87a90 100644 --- a/src/assembly/macos-jre.xml +++ b/src/assembly/macos-jre.xml @@ -16,6 +16,10 @@ ctbrec true + + ${project.basedir}/src/main/resources/pp.sh + ctbrec + ${project.build.directory}/${name.final}.jar ctbrec diff --git a/src/assembly/win32-jre.xml b/src/assembly/win32-jre.xml index 7efe1c9d..c76fff23 100644 --- a/src/assembly/win32-jre.xml +++ b/src/assembly/win32-jre.xml @@ -15,6 +15,14 @@ ctbrec true + + ${project.basedir}/src/main/resources/pp.bat + ctbrec + + + ${project.basedir}/src/main/resources/pp.ps1 + ctbrec + ${project.build.directory}/${name.final}.jar ctbrec diff --git a/src/assembly/win64-jre.xml b/src/assembly/win64-jre.xml index 65b22409..81000d7e 100644 --- a/src/assembly/win64-jre.xml +++ b/src/assembly/win64-jre.xml @@ -15,6 +15,14 @@ ctbrec true + + ${project.basedir}/src/main/resources/pp.bat + ctbrec + + + ${project.basedir}/src/main/resources/pp.ps1 + ctbrec + ${project.build.directory}/${name.final}.jar ctbrec diff --git a/src/assembly/win64.xml b/src/assembly/win64.xml index 62bb8a6f..f637dd44 100644 --- a/src/assembly/win64.xml +++ b/src/assembly/win64.xml @@ -15,6 +15,14 @@ ctbrec true + + ${project.basedir}/src/main/resources/pp.bat + ctbrec + + + ${project.basedir}/src/main/resources/pp.ps1 + ctbrec + ${project.build.directory}/${name.final}.jar ctbrec diff --git a/src/main/java/ctbrec/AbstractModel.java b/src/main/java/ctbrec/AbstractModel.java index bf0395d0..ce62e3cd 100644 --- a/src/main/java/ctbrec/AbstractModel.java +++ b/src/main/java/ctbrec/AbstractModel.java @@ -16,6 +16,7 @@ public abstract class AbstractModel implements Model { private String description; private List tags = new ArrayList<>(); private int streamUrlIndex = -1; + private boolean suspended = false; @Override public boolean isOnline() throws IOException, ExecutionException, InterruptedException { @@ -92,6 +93,16 @@ public abstract class AbstractModel implements Model { // noop default implementation, can be overriden by concrete models } + @Override + public boolean isSuspended() { + return suspended; + } + + @Override + public void setSuspended(boolean suspended) { + this.suspended = suspended; + } + @Override public int hashCode() { final int prime = 31; diff --git a/src/main/java/ctbrec/Config.java b/src/main/java/ctbrec/Config.java index 4ecf0f10..c421d96f 100644 --- a/src/main/java/ctbrec/Config.java +++ b/src/main/java/ctbrec/Config.java @@ -29,9 +29,16 @@ public class Config { private Settings settings; private String filename; private List sites; + private File configDir; private Config(List sites) throws FileNotFoundException, IOException { this.sites = sites; + if(System.getProperty("ctbrec.config.dir") != null) { + configDir = new File(System.getProperty("ctbrec.config.dir")); + } else { + configDir = OS.getConfigDir(); + } + if(System.getProperty("ctbrec.config") != null) { filename = System.getProperty("ctbrec.config"); } else { @@ -45,7 +52,6 @@ public class Config { .add(Model.class, new ModelJsonAdapter(sites)) .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()) { @@ -86,7 +92,6 @@ public class Config { .build(); JsonAdapter adapter = moshi.adapter(Settings.class).indent(" "); 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()); @@ -96,4 +101,8 @@ public class Config { public boolean isServerMode() { return Objects.equals(System.getProperty("ctbrec.server.mode"), "1"); } + + public File getConfigDir() { + return configDir; + } } diff --git a/src/main/java/ctbrec/Model.java b/src/main/java/ctbrec/Model.java index 351dda3e..3144f777 100644 --- a/src/main/java/ctbrec/Model.java +++ b/src/main/java/ctbrec/Model.java @@ -38,4 +38,7 @@ public interface Model { public Site getSite(); public void writeSiteSpecificData(JsonWriter writer) throws IOException; public void readSiteSpecificData(JsonReader reader) throws IOException; + public boolean isSuspended(); + public void setSuspended(boolean suspended); + } \ No newline at end of file diff --git a/src/main/java/ctbrec/Settings.java b/src/main/java/ctbrec/Settings.java index a8809ddf..2d5f7866 100644 --- a/src/main/java/ctbrec/Settings.java +++ b/src/main/java/ctbrec/Settings.java @@ -17,11 +17,15 @@ public class Settings { public boolean localRecording = true; public int httpPort = 8080; public int httpTimeout = 10000; + public String httpUserAgent = "Mozilla/5.0 Gecko/20100101 Firefox/62.0"; public String httpServer = "localhost"; public String recordingsDir = System.getProperty("user.home") + File.separator + "ctbrec"; public String mediaPlayer = "/usr/bin/mpv"; + public String postProcessing = ""; public String username = ""; // chaturbate username TODO maybe rename this onetime public String password = ""; // chaturbate password TODO maybe rename this onetime + public String bongaUsername = ""; + public String bongaPassword = ""; public String mfcUsername = ""; public String mfcPassword = ""; public String camsodaUsername = ""; diff --git a/src/main/java/ctbrec/io/CookieContainerJsonAdapter.java b/src/main/java/ctbrec/io/CookieContainerJsonAdapter.java new file mode 100644 index 00000000..9498e85d --- /dev/null +++ b/src/main/java/ctbrec/io/CookieContainerJsonAdapter.java @@ -0,0 +1,64 @@ +package ctbrec.io; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map.Entry; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonReader.Token; +import com.squareup.moshi.JsonWriter; + +import ctbrec.io.HttpClient.CookieContainer; +import okhttp3.Cookie; + +public class CookieContainerJsonAdapter extends JsonAdapter { + + private CookieJsonAdapter cookieAdapter = new CookieJsonAdapter(); + + @Override + public CookieContainer fromJson(JsonReader reader) throws IOException { + CookieContainer cookies = new CookieContainer(); + reader.beginArray(); + while(reader.hasNext()) { + reader.beginObject(); + reader.nextName(); // "domain" + String domain = reader.nextString(); + reader.nextName(); // "cookies" + reader.beginArray(); + List cookieList = new ArrayList<>(); + while(reader.hasNext()) { + Token token = reader.peek(); + if(token == Token.END_ARRAY) { + break; + } + Cookie cookie = cookieAdapter.fromJson(reader); + cookieList.add(cookie); + } + reader.endArray(); + reader.endObject(); + cookies.put(domain, cookieList); + } + reader.endArray(); + return cookies; + } + + @Override + public void toJson(JsonWriter writer, CookieContainer cookieContainer) throws IOException { + writer.beginArray(); + for (Entry> entry : cookieContainer.entrySet()) { + writer.beginObject(); + writer.name("domain").value(entry.getKey()); + writer.name("cookies"); + writer.beginArray(); + for (Cookie cookie : entry.getValue()) { + cookieAdapter.toJson(writer, cookie); + } + writer.endArray(); + writer.endObject(); + } + writer.endArray(); + } + +} diff --git a/src/main/java/ctbrec/io/CookieJarImpl.java b/src/main/java/ctbrec/io/CookieJarImpl.java index 712ff30c..deaaab2c 100644 --- a/src/main/java/ctbrec/io/CookieJarImpl.java +++ b/src/main/java/ctbrec/io/CookieJarImpl.java @@ -4,6 +4,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; @@ -78,5 +80,16 @@ public class CookieJarImpl implements CookieJar { return host; } + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + for (Entry> entry : cookieStore.entrySet()) { + sb.append(entry.getKey()).append(": ").append(entry.getValue()).append('\n'); + } + return sb.toString(); + } + public Map> getCookies() { + return cookieStore; + } } diff --git a/src/main/java/ctbrec/io/CookieJsonAdapter.java b/src/main/java/ctbrec/io/CookieJsonAdapter.java new file mode 100644 index 00000000..fa549814 --- /dev/null +++ b/src/main/java/ctbrec/io/CookieJsonAdapter.java @@ -0,0 +1,81 @@ +package ctbrec.io; + +import java.io.IOException; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; + +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; + +public class CookieJsonAdapter extends JsonAdapter { + + @Override + public Cookie fromJson(JsonReader reader) throws IOException { + reader.beginObject(); + Builder builder = new Cookie.Builder(); + // domain + reader.nextName(); + String domain = reader.nextString(); + builder.domain(domain); + + // expiresAt + reader.nextName(); + builder.expiresAt(reader.nextLong()); + + // host only + reader.nextName(); + if(reader.nextBoolean()) { + builder.hostOnlyDomain(domain); + } + + // http only + reader.nextName(); + if(reader.nextBoolean()) { + builder.httpOnly(); + } + + // name + reader.nextName(); + builder.name(reader.nextString()); + + // path + reader.nextName(); + builder.path(reader.nextString()); + + // persistent + reader.nextName(); + if(reader.nextBoolean()) { + // noop + } + + // secure + reader.nextName(); + if(reader.nextBoolean()) { + builder.secure(); + } + + // value + reader.nextName(); + builder.value(reader.nextString()); + + reader.endObject(); + return builder.build(); + } + + @Override + public void toJson(JsonWriter writer, Cookie cookie) throws IOException { + writer.beginObject(); + writer.name("domain").value(cookie.domain()); + writer.name("expiresAt").value(cookie.expiresAt()); + writer.name("hostOnly").value(cookie.hostOnly()); + writer.name("httpOnly").value(cookie.httpOnly()); + writer.name("name").value(cookie.name()); + writer.name("path").value(cookie.path()); + writer.name("persistent").value(cookie.persistent()); + writer.name("secure").value(cookie.secure()); + writer.name("value").value(cookie.value()); + writer.endObject(); + } +} diff --git a/src/main/java/ctbrec/io/HttpClient.java b/src/main/java/ctbrec/io/HttpClient.java index 02c8f818..f1afed9b 100644 --- a/src/main/java/ctbrec/io/HttpClient.java +++ b/src/main/java/ctbrec/io/HttpClient.java @@ -1,13 +1,28 @@ package ctbrec.io; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.net.Authenticator; import java.net.PasswordAuthentication; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; + import ctbrec.Config; import ctbrec.Settings.ProxyType; import okhttp3.ConnectionPool; +import okhttp3.Cookie; import okhttp3.Credentials; import okhttp3.OkHttpClient; import okhttp3.OkHttpClient.Builder; @@ -16,12 +31,16 @@ import okhttp3.Response; import okhttp3.Route; public abstract class HttpClient { + private static final transient Logger LOG = LoggerFactory.getLogger(HttpClient.class); + protected OkHttpClient client; protected CookieJarImpl cookieJar = new CookieJarImpl(); protected boolean loggedIn = false; protected int loginTries = 0; + private String name; - protected HttpClient() { + protected HttpClient(String name) { + this.name = name; reconfigure(); } @@ -92,6 +111,7 @@ public abstract class HttpClient { public void reconfigure() { loadProxySettings(); + loadCookies(); Builder builder = new OkHttpClient.Builder() .cookieJar(cookieJar) .connectTimeout(Config.getInstance().getSettings().httpTimeout, TimeUnit.MILLISECONDS) @@ -112,10 +132,61 @@ public abstract class HttpClient { } public void shutdown() { + persistCookies(); client.connectionPool().evictAll(); client.dispatcher().executorService().shutdown(); } + private void persistCookies() { + try { + CookieContainer cookies = new CookieContainer(); + cookies.putAll(cookieJar.getCookies()); + Moshi moshi = new Moshi.Builder() + .add(CookieContainer.class, new CookieContainerJsonAdapter()) + .build(); + JsonAdapter adapter = moshi.adapter(CookieContainer.class).indent(" "); + String json = adapter.toJson(cookies); + + File cookieFile = new File(Config.getInstance().getConfigDir(), "cookies-" + name + ".json"); + try(FileOutputStream fout = new FileOutputStream(cookieFile)) { + fout.write(json.getBytes("utf-8")); + } + } catch (Exception e) { + LOG.error("Couldn't persist cookies for {}", name, e); + } + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void loadCookies() { + try { + File cookieFile = new File(Config.getInstance().getConfigDir(), "cookies-" + name + ".json"); + if(!cookieFile.exists()) { + return; + } + byte[] jsonBytes = Files.readAllBytes(cookieFile.toPath()); + String json = new String(jsonBytes, "utf-8"); + + Map> cookies = cookieJar.getCookies(); + Moshi moshi = new Moshi.Builder() + .add(CookieContainer.class, new CookieContainerJsonAdapter()) + .build(); + JsonAdapter adapter = moshi.adapter(CookieContainer.class).indent(" "); + CookieContainer fromJson = adapter.fromJson(json); + Set entries = fromJson.entrySet(); + for (Object _entry : entries) { + Entry entry = (Entry) _entry; + cookies.put((String)entry.getKey(), (List)entry.getValue()); + } + + } catch (Exception e) { + LOG.error("Couldn't load cookies for {}", name, e); + } + } + + public static class CookieContainer extends HashMap> { + + } + private okhttp3.Authenticator createHttpProxyAuthenticator(String username, String password) { return new okhttp3.Authenticator() { @Override diff --git a/src/main/java/ctbrec/io/ModelJsonAdapter.java b/src/main/java/ctbrec/io/ModelJsonAdapter.java index 804c77fa..2a900282 100644 --- a/src/main/java/ctbrec/io/ModelJsonAdapter.java +++ b/src/main/java/ctbrec/io/ModelJsonAdapter.java @@ -32,6 +32,7 @@ public class ModelJsonAdapter extends JsonAdapter { String url = null; String type = null; int streamUrlIndex = -1; + boolean suspended = false; Model model = null; while(reader.hasNext()) { @@ -55,6 +56,9 @@ public class ModelJsonAdapter extends JsonAdapter { } else if(key.equals("streamUrlIndex")) { streamUrlIndex = reader.nextInt(); model.setStreamUrlIndex(streamUrlIndex); + } else if(key.equals("suspended")) { + suspended = reader.nextBoolean(); + model.setSuspended(suspended); } else if(key.equals("siteSpecific")) { reader.beginObject(); model.readSiteSpecificData(reader); @@ -87,6 +91,7 @@ public class ModelJsonAdapter extends JsonAdapter { writeValueIfSet(writer, "description", model.getDescription()); writeValueIfSet(writer, "url", model.getUrl()); writer.name("streamUrlIndex").value(model.getStreamUrlIndex()); + writer.name("suspended").value(model.isSuspended()); writer.name("siteSpecific"); writer.beginObject(); model.writeSiteSpecificData(writer); diff --git a/src/main/java/ctbrec/recorder/LocalRecorder.java b/src/main/java/ctbrec/recorder/LocalRecorder.java index e1916d93..a35f2f56 100644 --- a/src/main/java/ctbrec/recorder/LocalRecorder.java +++ b/src/main/java/ctbrec/recorder/LocalRecorder.java @@ -10,6 +10,7 @@ import java.security.NoSuchAlgorithmException; import java.text.SimpleDateFormat; import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; @@ -28,7 +29,9 @@ import com.iheartradio.m3u8.PlaylistException; import ctbrec.Config; import ctbrec.Model; +import ctbrec.OS; import ctbrec.Recording; +import ctbrec.io.StreamRedirectThread; import ctbrec.recorder.PlaylistGenerator.InvalidPlaylistException; import ctbrec.recorder.download.Download; import ctbrec.recorder.download.HlsDownload; @@ -46,7 +49,7 @@ public class LocalRecorder implements Recorder { private Config config; private ProcessMonitor processMonitor; private OnlineMonitor onlineMonitor; - private PlaylistGeneratorTrigger playlistGenTrigger; + private PostProcessingTrigger postProcessingTrigger; private volatile boolean recording = true; private List deleteInProgress = Collections.synchronizedList(new ArrayList<>()); private RecorderHttpClient client = new RecorderHttpClient(); @@ -68,9 +71,9 @@ public class LocalRecorder implements Recorder { onlineMonitor = new OnlineMonitor(); onlineMonitor.start(); - playlistGenTrigger = new PlaylistGeneratorTrigger(); + postProcessingTrigger = new PostProcessingTrigger(); if(Config.getInstance().isServerMode()) { - playlistGenTrigger.start(); + postProcessingTrigger.start(); } LOG.debug("Recorder initialized"); @@ -112,7 +115,12 @@ public class LocalRecorder implements Recorder { } private void startRecordingProcess(Model model) throws IOException { - LOG.debug("Restart recording for model {}", model.getName()); + if(model.isSuspended()) { + LOG.info("Recording for model {} is suspended.", model); + return; + } + + LOG.debug("Starting recording for model {}", model.getName()); if (recordingProcesses.containsKey(model)) { LOG.error("A recording for model {} is already running", model); return; @@ -148,10 +156,51 @@ public class LocalRecorder implements Recorder { }.start(); } - private void stopRecordingProcess(Model model) throws IOException { + private void stopRecordingProcess(Model model) { Download download = recordingProcesses.get(model); download.stop(); recordingProcesses.remove(model); + if(!Config.getInstance().isServerMode()) { + postprocess(download); + } + } + + private void postprocess(Download download) { + if(!(download instanceof MergedHlsDownload)) { + throw new IllegalArgumentException("Download should be of type MergedHlsDownload"); + } + String postProcessing = Config.getInstance().getSettings().postProcessing; + if (postProcessing != null && !postProcessing.isEmpty()) { + new Thread(() -> { + Runtime rt = Runtime.getRuntime(); + try { + MergedHlsDownload d = (MergedHlsDownload) download; + String[] args = new String[] { + postProcessing, + d.getDirectory().getAbsolutePath(), + d.getTargetFile().getAbsolutePath(), + d.getModel().getName(), + d.getModel().getSite().getName(), + Long.toString(download.getStartTime().getEpochSecond()) + }; + LOG.debug("Running {}", Arrays.toString(args)); + Process process = rt.exec(args, OS.getEnvironment(), download.getDirectory()); + Thread std = new Thread(new StreamRedirectThread(process.getInputStream(), System.out)); + std.setName("Process stdout pipe"); + std.setDaemon(true); + std.start(); + Thread err = new Thread(new StreamRedirectThread(process.getErrorStream(), System.err)); + err.setName("Process stderr pipe"); + err.setDaemon(true); + err.start(); + + process.waitFor(); + LOG.debug("Process finished."); + } catch (Exception e) { + LOG.error("Error in process thread", e); + } + }).start(); + } } @Override @@ -164,6 +213,22 @@ public class LocalRecorder implements Recorder { } } + @Override + public boolean isSuspended(Model model) { + lock.lock(); + try { + int index = models.indexOf(model); + if(index >= 0) { + Model m = models.get(index); + return m.isSuspended(); + } else { + return false; + } + } finally { + lock.unlock(); + } + } + @Override public List getModelsRecording() { lock.lock(); @@ -181,7 +246,7 @@ public class LocalRecorder implements Recorder { LOG.debug("Stopping monitor threads"); onlineMonitor.running = false; processMonitor.running = false; - playlistGenTrigger.running = false; + postProcessingTrigger.running = false; LOG.debug("Stopping all recording processes"); stopRecordingProcesses(); client.shutdown(); @@ -246,10 +311,14 @@ public class LocalRecorder implements Recorder { LOG.debug("Recording terminated for model {}", m.getName()); iterator.remove(); restart.add(m); - try { - finishRecording(d.getDirectory()); - } catch(Exception e) { - LOG.error("Error while finishing recording for model {}", m.getName(), e); + if(config.isServerMode()) { + try { + finishRecording(d.getDirectory()); + } catch(Exception e) { + LOG.error("Error while finishing recording for model {}", m.getName(), e); + } + } else { + postprocess(d); } } } @@ -269,17 +338,17 @@ public class LocalRecorder implements Recorder { } private void finishRecording(File directory) { - Thread t = new Thread() { - @Override - public void run() { - if(Config.getInstance().isServerMode()) { + if(Config.getInstance().isServerMode()) { + Thread t = new Thread() { + @Override + public void run() { generatePlaylist(directory); } - } - }; - t.setDaemon(true); - t.setName("Postprocessing" + directory.toString()); - t.start(); + }; + t.setDaemon(true); + t.setName("Post-Processing " + directory.toString()); + t.start(); + } } private void generatePlaylist(File recDir) { @@ -315,7 +384,7 @@ public class LocalRecorder implements Recorder { while (running) { for (Model model : getModelsRecording()) { try { - if (!recordingProcesses.containsKey(model)) { + if (!model.isSuspended() && !recordingProcesses.containsKey(model)) { boolean isOnline = model.isOnline(IGNORE_CACHE); LOG.trace("Checking online state for {}: {}", model, (isOnline ? "online" : "offline")); if (isOnline) { @@ -339,11 +408,11 @@ public class LocalRecorder implements Recorder { } } - private class PlaylistGeneratorTrigger extends Thread { + private class PostProcessingTrigger extends Thread { private volatile boolean running = false; - public PlaylistGeneratorTrigger() { - setName("PlaylistGeneratorTrigger"); + public PostProcessingTrigger() { + setName("PostProcessingTrigger"); setDaemon(true); } @@ -365,7 +434,7 @@ public class LocalRecorder implements Recorder { } if (!recordingProcessFound) { if (deleteInProgress.contains(recDir)) { - LOG.debug("{} is being deleted. Not going to generate a playlist", recDir); + LOG.debug("{} is being deleted. Not going to start post-processing", recDir); } else { finishRecording(recDir); } @@ -529,4 +598,45 @@ public class LocalRecorder implements Recorder { stopRecordingProcess(model); tryRestartRecording(model); } + + @Override + public void suspendRecording(Model model) { + lock.lock(); + try { + if (models.contains(model)) { + int index = models.indexOf(model); + models.get(index).setSuspended(true); + model.setSuspended(true); + } else { + LOG.warn("Couldn't suspend model {}. Not found in list", model.getName()); + return; + } + } finally { + lock.unlock(); + } + + Download download = recordingProcesses.get(model); + if(download != null) { + stopRecordingProcess(model); + } + } + + @Override + public void resumeRecording(Model model) throws IOException { + lock.lock(); + try { + if (models.contains(model)) { + int index = models.indexOf(model); + Model m = models.get(index); + m.setSuspended(false); + startRecordingProcess(m); + model.setSuspended(false); + } else { + LOG.warn("Couldn't resume model {}. Not found in list", model.getName()); + return; + } + } finally { + lock.unlock(); + } + } } diff --git a/src/main/java/ctbrec/recorder/Recorder.java b/src/main/java/ctbrec/recorder/Recorder.java index 9effa208..b22fe1a4 100644 --- a/src/main/java/ctbrec/recorder/Recorder.java +++ b/src/main/java/ctbrec/recorder/Recorder.java @@ -28,4 +28,9 @@ public interface Recorder { public void delete(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException; public void shutdown(); + + public void suspendRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException; + public void resumeRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException; + + public boolean isSuspended(Model model); } diff --git a/src/main/java/ctbrec/recorder/RemoteRecorder.java b/src/main/java/ctbrec/recorder/RemoteRecorder.java index 2cb7b216..2e07d10c 100644 --- a/src/main/java/ctbrec/recorder/RemoteRecorder.java +++ b/src/main/java/ctbrec/recorder/RemoteRecorder.java @@ -88,7 +88,7 @@ public class RemoteRecorder implements Recorder { if("start".equals(action)) { models.add(model); - } else { + } else if("stop".equals(action)) { models.remove(model); } } else { @@ -109,6 +109,17 @@ public class RemoteRecorder implements Recorder { return models != null && models.contains(model); } + @Override + public boolean isSuspended(Model model) { + int index = models.indexOf(model); + if(index >= 0) { + Model m = models.get(index); + return m.isSuspended(); + } else { + return false; + } + } + @Override public List getModelsRecording() { if(lastSync.isBefore(Instant.now().minusSeconds(60))) { @@ -276,4 +287,28 @@ public class RemoteRecorder implements Recorder { public void switchStreamSource(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException { sendRequest("switch", model); } + + @Override + public void suspendRecording(Model model) throws InvalidKeyException, NoSuchAlgorithmException, IllegalStateException, IOException { + sendRequest("suspend", model); + model.setSuspended(true); + // update cached model + int index = models.indexOf(model); + if(index >= 0) { + Model m = models.get(index); + m.setSuspended(true); + } + } + + @Override + public void resumeRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException { + sendRequest("resume", model); + model.setSuspended(false); + // update cached model + int index = models.indexOf(model); + if(index >= 0) { + Model m = models.get(index); + m.setSuspended(false); + } + } } diff --git a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java index 84a3744d..62a8f897 100644 --- a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.nio.file.Path; +import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; @@ -41,6 +42,8 @@ public abstract class AbstractHlsDownload implements Download { volatile boolean running = false; volatile boolean alive = true; Path downloadDir; + Instant startTime; + Model model; public AbstractHlsDownload(HttpClient client) { this.client = client; @@ -117,6 +120,16 @@ public abstract class AbstractHlsDownload implements Download { return downloadDir.toFile(); } + @Override + public Instant getStartTime() { + return startTime; + } + + @Override + public Model getModel() { + return model; + } + public static class SegmentPlaylist { public int seq = 0; public float totalDuration = 0; diff --git a/src/main/java/ctbrec/recorder/download/Download.java b/src/main/java/ctbrec/recorder/download/Download.java index 4148a362..76a71f0e 100644 --- a/src/main/java/ctbrec/recorder/download/Download.java +++ b/src/main/java/ctbrec/recorder/download/Download.java @@ -2,6 +2,7 @@ package ctbrec.recorder.download; import java.io.File; import java.io.IOException; +import java.time.Instant; import ctbrec.Config; import ctbrec.Model; @@ -11,4 +12,6 @@ public interface Download { public void stop(); public boolean isAlive(); public File getDirectory(); + public Model getModel(); + public Instant getStartTime(); } diff --git a/src/main/java/ctbrec/recorder/download/HlsDownload.java b/src/main/java/ctbrec/recorder/download/HlsDownload.java index 6974056a..ab1804b6 100644 --- a/src/main/java/ctbrec/recorder/download/HlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/HlsDownload.java @@ -12,6 +12,7 @@ import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.text.SimpleDateFormat; +import java.time.Instant; import java.util.Date; import java.util.concurrent.Callable; @@ -39,6 +40,8 @@ public class HlsDownload extends AbstractHlsDownload { public void start(Model model, Config config) throws IOException { try { running = true; + startTime = Instant.now(); + super.model = model; 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()); diff --git a/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java b/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java index 51e7b422..59668dbd 100644 --- a/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java @@ -16,6 +16,7 @@ import java.nio.file.Path; import java.text.DecimalFormat; import java.text.SimpleDateFormat; import java.time.Duration; +import java.time.Instant; import java.time.ZonedDateTime; import java.util.Date; import java.util.LinkedList; @@ -58,9 +59,14 @@ public class MergedHlsDownload extends AbstractHlsDownload { super(client); } + public File getTargetFile() { + return targetFile; + } + public void start(String segmentPlaylistUri, File targetFile, ProgressListener progressListener) throws IOException { try { running = true; + super.startTime = Instant.now(); downloadDir = targetFile.getParentFile().toPath(); mergeThread = createMergeThread(targetFile, progressListener, false); mergeThread.start(); @@ -75,7 +81,7 @@ public class MergedHlsDownload extends AbstractHlsDownload { } finally { alive = false; streamer.stop(); - LOG.debug("Download for terminated"); + LOG.debug("Download terminated for {}", segmentPlaylistUri); } } @@ -84,6 +90,8 @@ public class MergedHlsDownload extends AbstractHlsDownload { this.config = config; try { running = true; + super.startTime = Instant.now(); + super.model = model; startTime = ZonedDateTime.now(); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm"); String startTime = sdf.format(new Date()); diff --git a/src/main/java/ctbrec/recorder/server/HttpServer.java b/src/main/java/ctbrec/recorder/server/HttpServer.java index 00537280..5767b72e 100644 --- a/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -20,6 +20,7 @@ import ctbrec.Config; import ctbrec.recorder.LocalRecorder; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; +import ctbrec.sites.bonga.BongaCams; import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; @@ -66,6 +67,7 @@ public class HttpServer { sites.add(new MyFreeCams()); sites.add(new Camsoda()); sites.add(new Cam4()); + sites.add(new BongaCams()); } private void addShutdownHook() { diff --git a/src/main/java/ctbrec/recorder/server/RecorderHttpClient.java b/src/main/java/ctbrec/recorder/server/RecorderHttpClient.java index 22027ba9..7b3170e7 100644 --- a/src/main/java/ctbrec/recorder/server/RecorderHttpClient.java +++ b/src/main/java/ctbrec/recorder/server/RecorderHttpClient.java @@ -6,6 +6,10 @@ import ctbrec.io.HttpClient; public class RecorderHttpClient extends HttpClient { + public RecorderHttpClient() { + super("recorder"); + } + @Override public boolean login() throws IOException { return false; diff --git a/src/main/java/ctbrec/recorder/server/RecorderServlet.java b/src/main/java/ctbrec/recorder/server/RecorderServlet.java index 4d258906..16a4210f 100644 --- a/src/main/java/ctbrec/recorder/server/RecorderServlet.java +++ b/src/main/java/ctbrec/recorder/server/RecorderServlet.java @@ -112,6 +112,18 @@ public class RecorderServlet extends AbstractCtbrecServlet { response = "{\"status\": \"success\", \"msg\": \"Resolution switched\"}"; resp.getWriter().write(response); break; + case "suspend": + LOG.debug("Suspend recording for model {} - {}", request.model.getName(), request.model.getUrl()); + recorder.suspendRecording(request.model); + response = "{\"status\": \"success\", \"msg\": \"Recording suspended\"}"; + resp.getWriter().write(response); + break; + case "resume": + LOG.debug("Resume recording for model {} - {}", request.model.getName(), request.model.getUrl()); + recorder.resumeRecording(request.model); + response = "{\"status\": \"success\", \"msg\": \"Recording resumed\"}"; + resp.getWriter().write(response); + break; default: resp.setStatus(SC_BAD_REQUEST); response = "{\"status\": \"error\", \"msg\": \"Unknown action\"}"; diff --git a/src/main/java/ctbrec/sites/ConfigUI.java b/src/main/java/ctbrec/sites/ConfigUI.java new file mode 100644 index 00000000..33d97ec8 --- /dev/null +++ b/src/main/java/ctbrec/sites/ConfigUI.java @@ -0,0 +1,7 @@ +package ctbrec.sites; + +import javafx.scene.Parent; + +public interface ConfigUI { + public Parent createConfigPanel(); +} diff --git a/src/main/java/ctbrec/sites/Site.java b/src/main/java/ctbrec/sites/Site.java index 411be906..29a0e226 100644 --- a/src/main/java/ctbrec/sites/Site.java +++ b/src/main/java/ctbrec/sites/Site.java @@ -6,7 +6,6 @@ import ctbrec.Model; import ctbrec.io.HttpClient; import ctbrec.recorder.Recorder; import ctbrec.ui.TabProvider; -import javafx.scene.Node; public interface Site { public String getName(); @@ -24,7 +23,7 @@ public interface Site { public boolean supportsTips(); public boolean supportsFollow(); public boolean isSiteForModel(Model m); - public Node getConfigurationGui(); + public ConfigUI getConfigurationGui(); public boolean credentialsAvailable(); public void setEnabled(boolean enabled); public boolean isEnabled(); diff --git a/src/main/java/ctbrec/sites/bonga/BongaCams.java b/src/main/java/ctbrec/sites/bonga/BongaCams.java new file mode 100644 index 00000000..b518c3ff --- /dev/null +++ b/src/main/java/ctbrec/sites/bonga/BongaCams.java @@ -0,0 +1,150 @@ +package ctbrec.sites.bonga; + +import java.io.IOException; + +import org.json.JSONObject; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpClient; +import ctbrec.recorder.Recorder; +import ctbrec.sites.AbstractSite; +import ctbrec.sites.ConfigUI; +import ctbrec.ui.TabProvider; +import okhttp3.FormBody; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class BongaCams extends AbstractSite { + + public static final String BASE_URL = "https://bongacams.com"; + + private BongaCamsHttpClient httpClient; + + private Recorder recorder; + + @Override + public String getName() { + return "BongaCams"; + } + + @Override + public String getBaseUrl() { + return BASE_URL; + } + + @Override + public String getAffiliateLink() { + return "http://bongacams2.com/track?c=610249"; + } + + @Override + public void setRecorder(Recorder recorder) { + this.recorder = recorder; + } + + @Override + public TabProvider getTabProvider() { + return new BongaCamsTabProvider(recorder, this); + } + + @Override + public Model createModel(String name) { + BongaCamsModel model = new BongaCamsModel(); + model.setName(name); + model.setUrl(BASE_URL + '/' + name); + model.setDescription(""); + model.setSite(this); + return model; + } + + @Override + public Integer getTokenBalance() throws IOException { + int userId = ((BongaCamsHttpClient)getHttpClient()).getUserId(); + String url = BongaCams.BASE_URL + "/tools/amf.php"; + RequestBody body = new FormBody.Builder() + .add("method", "ping") + .add("args[]", Integer.toString(userId)) + .build(); + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", BongaCams.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest") + .post(body) + .build(); + try(Response response = getHttpClient().execute(request, true)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if(json.optString("status").equals("online")) { + JSONObject userData = json.getJSONObject("userData"); + return userData.getInt("balance"); + } else { + throw new IOException("Request was not successful: " + json.toString(2)); + } + } else { + throw new IOException(response.code() + " " + response.message()); + } + } + } + + @Override + public String getBuyTokensLink() { + return getAffiliateLink(); + } + + @Override + public void login() throws IOException { + getHttpClient().login(); + } + + @Override + public HttpClient getHttpClient() { + if(httpClient == null) { + httpClient = new BongaCamsHttpClient(); + } + return httpClient; + } + + @Override + public void init() throws IOException { + } + + @Override + public void shutdown() { + if(httpClient != null) { + httpClient.shutdown(); + } + } + + @Override + public boolean supportsTips() { + return true; + } + + @Override + public boolean supportsFollow() { + // TODO Auto-generated method stub + return false; + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof BongaCamsModel; + } + + @Override + public ConfigUI getConfigurationGui() { + return new BongaCamsConfigUI(this); + } + + @Override + public boolean credentialsAvailable() { + String username = Config.getInstance().getSettings().bongaUsername; + return username != null && !username.trim().isEmpty(); + } + +} diff --git a/src/main/java/ctbrec/sites/bonga/BongaCamsConfigUI.java b/src/main/java/ctbrec/sites/bonga/BongaCamsConfigUI.java new file mode 100644 index 00000000..32ad77a3 --- /dev/null +++ b/src/main/java/ctbrec/sites/bonga/BongaCamsConfigUI.java @@ -0,0 +1,54 @@ +package ctbrec.sites.bonga; + +import ctbrec.Config; +import ctbrec.sites.ConfigUI; +import ctbrec.ui.DesktopIntergation; +import ctbrec.ui.SettingsTab; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class BongaCamsConfigUI implements ConfigUI { + + private BongaCams bongaCams; + + public BongaCamsConfigUI(BongaCams bongaCams) { + this.bongaCams = bongaCams; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + layout.add(new Label("BongaCams User"), 0, 0); + TextField username = new TextField(Config.getInstance().getSettings().bongaUsername); + username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().bongaUsername = username.getText()); + GridPane.setFillWidth(username, true); + GridPane.setHgrow(username, Priority.ALWAYS); + GridPane.setColumnSpan(username, 2); + layout.add(username, 1, 0); + + layout.add(new Label("BongaCams Password"), 0, 1); + PasswordField password = new PasswordField(); + password.setText(Config.getInstance().getSettings().bongaPassword); + password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().bongaPassword = password.getText()); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, 1); + + Button createAccount = new Button("Create new Account"); + createAccount.setOnAction((e) -> DesktopIntergation.open(bongaCams.getAffiliateLink())); + layout.add(createAccount, 1, 2); + GridPane.setColumnSpan(createAccount, 2); + GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/src/main/java/ctbrec/sites/bonga/BongaCamsHttpClient.java b/src/main/java/ctbrec/sites/bonga/BongaCamsHttpClient.java new file mode 100644 index 00000000..4606f830 --- /dev/null +++ b/src/main/java/ctbrec/sites/bonga/BongaCamsHttpClient.java @@ -0,0 +1,243 @@ +package ctbrec.sites.bonga; + +import java.io.IOException; +import java.net.HttpCookie; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.io.HttpClient; +import javafx.application.Platform; +import okhttp3.Cookie; +import okhttp3.FormBody; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class BongaCamsHttpClient extends HttpClient { + + private static final transient Logger LOG = LoggerFactory.getLogger(BongaCamsHttpClient.class); + private int userId = 0; + + public BongaCamsHttpClient() { + super("bongacams"); + addSortByPopularCookie(); + } + + /** + * Adds a cookie, which defines the sort order for returned model lists + */ + private void addSortByPopularCookie() { + Cookie sortByCookie = new Cookie.Builder() + .domain("bongacams.com") + .name("bcmlsf9") + .value("%7B%22limit%22%3A20%2C%22c_limit%22%3A10%2C%22th_type%22%3A%22live%22%2C%22sorting%22%3A%22popular%22%2C%22display%22%3A%22auto%22%7D") + .build(); + + Map> cookies = cookieJar.getCookies(); + for (Entry> entry : cookies.entrySet()) { + List cookieList = entry.getValue(); + for (Iterator iterator = cookieList.iterator(); iterator.hasNext();) { + Cookie cookie = iterator.next(); + if(cookie.name().equals("bcmlsf9")) { + iterator.remove(); + } + } + entry.getValue().add(sortByCookie); + } + } + + @Override + public synchronized boolean login() throws IOException { + if(loggedIn) { + return true; + } + + boolean cookiesWorked = checkLoginSuccess(); + if(cookiesWorked) { + loggedIn = true; + LOG.debug("Logged in with cookies"); + return true; + } + + BlockingQueue queue = new LinkedBlockingQueue<>(); + + Runnable showDialog = () -> { + // login with javafx WebView + BongaCamsLoginDialog loginDialog = new BongaCamsLoginDialog(); + + // transfer cookies from WebView to OkHttp cookie jar + transferCookies(loginDialog); + + try { + queue.put(true); + } catch (InterruptedException e) { + LOG.error("Error while signaling termination", e); + } + }; + + if(Platform.isFxApplicationThread()) { + showDialog.run(); + } else { + Platform.runLater(showDialog); + try { + queue.take(); + } catch (InterruptedException e) { + LOG.error("Error while waiting for login dialog to close", e); + throw new IOException(e); + } + } + + loggedIn = checkLoginSuccess(); + if(loggedIn) { + LOG.info("Logged in. User ID is {}", userId); + } else { + LOG.info("Login failed"); + } + return loggedIn; + } + + /** + * Check, if the login worked by requesting roomdata and looking + * @throws IOException + */ + private boolean checkLoginSuccess() throws IOException { + String modelName = getAnyModelName(); + // we request the roomData of a random model, because it contains + // user data, if the user is logged in, which we can use to verify, that the login worked + String url = BongaCams.BASE_URL + "/tools/amf.php"; + RequestBody body = new FormBody.Builder() + .add("method", "getRoomData") + .add("args[]", modelName) + .add("args[]", "false") + //.add("method", "ping") // TODO alternative request, but + //.add("args[]", ) // where to get the userId + .build(); + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", BongaCams.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest") + .post(body) + .build(); + try(Response response = execute(request)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if(json.optString("status").equals("success")) { + JSONObject userData = json.getJSONObject("userData"); + userId = userData.optInt("userId"); + return userId > 0; + } else { + throw new IOException("Request was not successful: " + json.toString(2)); + } + } else { + throw new IOException(response.code() + " " + response.message()); + } + } + } + + /** + * Fetches the list of online models and returns the name of the first model + */ + private String getAnyModelName() throws IOException { + Request request = new Request.Builder() + .url(BongaCams.BASE_URL + "/tools/listing_v3.php?livetab=female&online_only=true&is_mobile=true&offset=0") + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", BongaCams.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = execute(request)) { + if (response.isSuccessful()) { + String content = response.body().string(); + JSONObject json = new JSONObject(content); + if(json.optString("status").equals("success")) { + JSONArray _models = json.getJSONArray("models"); + JSONObject m = _models.getJSONObject(0); + String name = m.getString("username"); + return name; + } else { + throw new IOException("Request was not successful: " + content); + } + } else { + throw new IOException(response.code() + ' ' + response.message()); + } + } + } + + private void transferCookies(BongaCamsLoginDialog loginDialog) { + HttpUrl redirectedUrl = HttpUrl.parse(loginDialog.getUrl()); + List cookies = new ArrayList<>(); + for (HttpCookie webViewCookie : loginDialog.getCookies()) { + Cookie cookie = Cookie.parse(redirectedUrl, webViewCookie.toString()); + cookies.add(cookie); + } + cookieJar.saveFromResponse(redirectedUrl, cookies); + + HttpUrl origUrl = HttpUrl.parse(BongaCamsLoginDialog.URL); + cookies = new ArrayList<>(); + for (HttpCookie webViewCookie : loginDialog.getCookies()) { + Cookie cookie = Cookie.parse(origUrl, webViewCookie.toString()); + cookies.add(cookie); + } + cookieJar.saveFromResponse(origUrl, cookies); + } + + // @Override + // public boolean login() throws IOException { + // String url = BongaCams.BASE_URL + "/login"; + // String dateTime = new SimpleDateFormat("d.MM.yyyy', 'HH:mm:ss").format(new Date()); + // RequestBody body = new FormBody.Builder() + // .add("security_log_additional_info","{\"language\":\"en\",\"cookieEnabled\":true,\"javaEnabled\":false,\"flashVersion\":\"31.0.0\",\"dateTime\":\""+dateTime+"\",\"ips\":[\"192.168.0.1\"]}") + // .add("log_in[username]", Config.getInstance().getSettings().bongaUsername) + // .add("log_in[password]", Config.getInstance().getSettings().bongaPassword) + // .add("log_in[remember]", "1") + // .add("log_in[bfpt]", "") + // .add("header_form", "1") + // .build(); + // Request request = new Request.Builder() + // .url(url) + // .post(body) + // .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + // .addHeader("Accept","application/json") + // .addHeader("Accept-Language", "en") + // .addHeader("Referer", BongaCams.BASE_URL) + // .addHeader("X-Requested-With", "XMLHttpRequest") + // .build(); + // try(Response response = execute(request)) { + // if(response.isSuccessful()) { + // JSONObject json = new JSONObject(response.body().string()); + // if(json.optString("status").equals("success")) { + // return true; + // } else { + // LOG.debug("Login response: {}", json.toString(2)); + // Platform.runLater(() -> new BongaCamsLoginDialog()); + // throw new IOException("Login not successful"); + // } + // } else { + // throw new IOException(response.code() + " " + response.message()); + // } + // } + // } + + public int getUserId() throws IOException { + if(userId == 0) { + login(); + } + return userId; + } +} diff --git a/src/main/java/ctbrec/sites/bonga/BongaCamsLoginDialog.java b/src/main/java/ctbrec/sites/bonga/BongaCamsLoginDialog.java new file mode 100644 index 00000000..626667d3 --- /dev/null +++ b/src/main/java/ctbrec/sites/bonga/BongaCamsLoginDialog.java @@ -0,0 +1,119 @@ +package ctbrec.sites.bonga; + +import java.io.File; +import java.io.InputStream; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.HttpCookie; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.OS; +import javafx.concurrent.Worker.State; +import javafx.scene.Scene; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.image.Image; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.web.WebEngine; +import javafx.scene.web.WebView; +import javafx.stage.Stage; + +public class BongaCamsLoginDialog { + + private static final transient Logger LOG = LoggerFactory.getLogger(BongaCamsLoginDialog.class); + public static final String URL = BongaCams.BASE_URL + "/login"; + private List cookies = null; + private String url; + private Region veil; + private ProgressIndicator p; + + public BongaCamsLoginDialog() { + Stage stage = new Stage(); + stage.setTitle("BongaCams Login"); + InputStream icon = getClass().getResourceAsStream("/icon.png"); + stage.getIcons().add(new Image(icon)); + CookieManager cookieManager = new CookieManager(); + CookieHandler.setDefault(cookieManager); + WebView webView = createWebView(stage); + + veil = new Region(); + veil.setStyle("-fx-background-color: rgba(0, 0, 0, 0.4)"); + p = new ProgressIndicator(); + p.setMaxSize(140, 140); + + StackPane stackPane = new StackPane(); + stackPane.getChildren().addAll(webView, veil, p); + + stage.setScene(new Scene(stackPane, 640, 480)); + stage.showAndWait(); + cookies = cookieManager.getCookieStore().getCookies(); + } + + private WebView createWebView(Stage stage) { + WebView browser = new WebView(); + WebEngine webEngine = browser.getEngine(); + webEngine.setJavaScriptEnabled(true); + webEngine.setUserAgent(Config.getInstance().getSettings().httpUserAgent); + webEngine.locationProperty().addListener((obs, oldV, newV) -> { + try { + URL _url = new URL(newV); + if (Objects.equals(_url.getPath(), "/")) { + stage.close(); + } + } catch (MalformedURLException e) { + LOG.error("Couldn't parse new url {}", newV, e); + } + url = newV.toString(); + }); + webEngine.getLoadWorker().stateProperty().addListener((observable, oldState, newState) -> { + if (newState == State.SUCCEEDED) { + veil.setVisible(false); + p.setVisible(false); + //System.out.println("############# " + webEngine.getLocation()); + //System.out.println(webEngine.getDocument().getDocumentElement().getTextContent()); + try { + String username = Config.getInstance().getSettings().bongaUsername; + if (username != null && !username.trim().isEmpty()) { + webEngine.executeScript("$('input[name=\"log_in[username]\"]').attr('value','" + username + "')"); + } + String password = Config.getInstance().getSettings().bongaPassword; + if (password != null && !password.trim().isEmpty()) { + webEngine.executeScript("$('input[name=\"log_in[password]\"]').attr('value','" + password + "')"); + } + webEngine.executeScript("$('div[class~=\"fancybox-overlay\"]').css('display','none')"); + webEngine.executeScript("$('div#header').css('display','none')"); + webEngine.executeScript("$('div.footer').css('display','none')"); + webEngine.executeScript("$('div.footer_copy').css('display','none')"); + webEngine.executeScript("$('div[class~=\"banner_top_index\"]').css('display','none')"); + webEngine.executeScript("$('td.menu_container').css('display','none')"); + } catch(Exception e) { + LOG.warn("Couldn't auto fill username and password for BongaCams", e); + } + } else if (newState == State.CANCELLED || newState == State.FAILED) { + veil.setVisible(false); + p.setVisible(false); + } + }); + webEngine.setUserDataDirectory(new File(OS.getConfigDir(), "webengine")); + webEngine.load(URL); + return browser; + } + + public List getCookies() { + // for (HttpCookie httpCookie : cookies) { + // LOG.debug("Cookie: {}", httpCookie); + // } + return cookies; + } + + public String getUrl() { + return url; + } +} diff --git a/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java b/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java new file mode 100644 index 00000000..4014ae8c --- /dev/null +++ b/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java @@ -0,0 +1,223 @@ +package ctbrec.sites.bonga; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import org.json.JSONObject; +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.Playlist; +import com.iheartradio.m3u8.data.PlaylistData; +import com.iheartradio.m3u8.data.StreamInfo; + +import ctbrec.AbstractModel; +import ctbrec.Config; +import ctbrec.recorder.download.StreamSource; +import ctbrec.sites.Site; +import okhttp3.FormBody; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class BongaCamsModel extends AbstractModel { + + private static final transient Logger LOG = LoggerFactory.getLogger(BongaCamsModel.class); + + private BongaCams site; + private int userId; + private String onlineState = "n/a"; + private boolean online = false; + private List streamSources = new ArrayList<>(); + private int[] resolution; + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + return online; + } + + public void setOnline(boolean online) { + this.online = online; + } + + @Override + public String getOnlineState(boolean failFast) throws IOException, ExecutionException { + return onlineState; + } + + public void setOnlineState(String onlineState) { + this.onlineState = onlineState; + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { + String streamUrl = getStreamUrl(); + if (streamUrl == null) { + return Collections.emptyList(); + } + Request req = new Request.Builder().url(streamUrl).build(); + Response response = site.getHttpClient().execute(req); + try { + InputStream inputStream = response.body().byteStream(); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + Playlist playlist = parser.parse(); + MasterPlaylist master = playlist.getMasterPlaylist(); + for (PlaylistData playlistData : master.getPlaylists()) { + + StreamSource streamsource = new StreamSource(); + streamsource.mediaPlaylistUrl = streamUrl.replace("playlist.m3u8", playlistData.getUri()); + if (playlistData.hasStreamInfo()) { + StreamInfo info = playlistData.getStreamInfo(); + streamsource.bandwidth = info.getBandwidth(); + streamsource.width = info.hasResolution() ? info.getResolution().width : 0; + streamsource.height = info.hasResolution() ? info.getResolution().height : 0; + } else { + streamsource.bandwidth = 0; + streamsource.width = 0; + streamsource.height = 0; + } + streamSources.add(streamsource); + } + } finally { + response.close(); + } + return streamSources; + } + + private String getStreamUrl() throws IOException { + String url = BongaCams.BASE_URL + "/tools/amf.php"; + RequestBody body = new FormBody.Builder() + .add("method", "getRoomData") + .add("args[]", getName()) + .add("args[]", "false") + .build(); + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", BongaCams.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest") + .post(body) + .build(); + try(Response response = site.getHttpClient().execute(request)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if(json.optString("status").equals("success")) { + JSONObject localData = json.getJSONObject("localData"); + String server = localData.getString("videoServerUrl"); + return "https:" + server + "/hls/stream_" + getName() + "/playlist.m3u8"; + } else { + throw new IOException("Request was not successful: " + json.toString(2)); + } + } else { + throw new IOException(response.code() + " " + response.message()); + } + } + } + + @Override + public void invalidateCacheEntries() { + resolution = null; + } + + @Override + public void receiveTip(int tokens) throws IOException { + String url = BongaCams.BASE_URL + "/chat-ajax-amf-service?" + System.currentTimeMillis(); + int userId = ((BongaCamsHttpClient)site.getHttpClient()).getUserId(); + RequestBody body = new FormBody.Builder() + .add("method", "tipModel") + .add("args[]", getName()) + .add("args[]", Integer.toString(tokens)) + .add("args[]", Integer.toString(userId)) + .add("args[3]", "") + .build(); + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", BongaCams.BASE_URL + '/' + getName()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .post(body) + .build(); + try(Response response = site.getHttpClient().execute(request, true)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if(!json.optString("status").equals("success")) { + LOG.error("Sending tip failed {}", json.toString(2)); + throw new IOException("Sending tip failed"); + } + } else { + throw new IOException(response.code() + ' ' + response.message()); + } + } + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + if(resolution == null) { + if(failFast) { + return new int[2]; + } + try { + if(!isOnline()) { + return new int[2]; + } + List streamSources = getStreamSources(); + Collections.sort(streamSources); + StreamSource best = streamSources.get(streamSources.size()-1); + resolution = new int[] {best.width, best.height}; + } catch (ExecutionException | IOException | ParseException | PlaylistException | InterruptedException e) { + LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); + } + return resolution; + } else { + return resolution; + } + } + + @Override + public boolean follow() throws IOException { + // TODO Auto-generated method stub + return false; + } + + @Override + public boolean unfollow() throws IOException { + // TODO Auto-generated method stub + return false; + } + + @Override + public void setSite(Site site) { + if(site instanceof BongaCams) { + this.site = (BongaCams) site; + } else { + throw new IllegalArgumentException("Site has to be an instance of BongaCams"); + } + } + + @Override + public Site getSite() { + return site; + } + + public int getUserId() { + return userId; + } + + public void setUserId(int userId) { + this.userId = userId; + } +} diff --git a/src/main/java/ctbrec/sites/bonga/BongaCamsTabProvider.java b/src/main/java/ctbrec/sites/bonga/BongaCamsTabProvider.java new file mode 100644 index 00000000..5a5bf55a --- /dev/null +++ b/src/main/java/ctbrec/sites/bonga/BongaCamsTabProvider.java @@ -0,0 +1,66 @@ +package ctbrec.sites.bonga; + +import java.util.ArrayList; +import java.util.List; + +import ctbrec.recorder.Recorder; +import ctbrec.ui.PaginatedScheduledService; +import ctbrec.ui.TabProvider; +import ctbrec.ui.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +public class BongaCamsTabProvider extends TabProvider { + + private BongaCams bongaCams; + private Recorder recorder; + + public BongaCamsTabProvider(Recorder recorder, BongaCams bongaCams) { + this.recorder = recorder; + this.bongaCams = bongaCams; + } + + @Override + public List getTabs(Scene scene) { + List tabs = new ArrayList<>(); + + // female + String url = BongaCams.BASE_URL + "/tools/listing_v3.php?livetab=female&online_only=true&is_mobile=true&offset="; + BongaCamsUpdateService updateService = new BongaCamsUpdateService(bongaCams, url); + tabs.add(createTab("Female", updateService)); + + // male + url = BongaCams.BASE_URL + "/tools/listing_v3.php?livetab=male&online_only=true&is_mobile=true&offset="; + updateService = new BongaCamsUpdateService(bongaCams, url); + tabs.add(createTab("Male", updateService)); + + // couples + url = BongaCams.BASE_URL + "/tools/listing_v3.php?livetab=couples&online_only=true&is_mobile=true&offset="; + updateService = new BongaCamsUpdateService(bongaCams, url); + tabs.add(createTab("Couples", updateService)); + + // trans + url = BongaCams.BASE_URL + "/tools/listing_v3.php?livetab=transsexual&online_only=true&is_mobile=true&offset="; + updateService = new BongaCamsUpdateService(bongaCams, url); + tabs.add(createTab("Transsexual", updateService)); + + // new + url = BongaCams.BASE_URL + "/tools/listing_v3.php?livetab=new-models&online_only=true&is_mobile=true&offset="; + updateService = new BongaCamsUpdateService(bongaCams, url); + tabs.add(createTab("New", updateService)); + + // friends + url = BongaCams.BASE_URL + "/tools/listing_v3.php?livetab=friends&online_only=true&offset="; + updateService = new BongaCamsUpdateService(bongaCams, url); + tabs.add(createTab("Friends", updateService)); + + return tabs; + } + + private Tab createTab(String title, PaginatedScheduledService updateService) { + ThumbOverviewTab tab = new ThumbOverviewTab(title, updateService, bongaCams); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/src/main/java/ctbrec/sites/bonga/BongaCamsUpdateService.java b/src/main/java/ctbrec/sites/bonga/BongaCamsUpdateService.java new file mode 100644 index 00000000..9761be92 --- /dev/null +++ b/src/main/java/ctbrec/sites/bonga/BongaCamsUpdateService.java @@ -0,0 +1,83 @@ +package ctbrec.sites.bonga; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.ui.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; + +public class BongaCamsUpdateService extends PaginatedScheduledService { + + private static final transient Logger LOG = LoggerFactory.getLogger(BongaCamsUpdateService.class); + + private BongaCams bongaCams; + private String url; + + public BongaCamsUpdateService(BongaCams bongaCams, String url) { + this.bongaCams = bongaCams; + this.url = url; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + String _url = url + ((page-1) * 36); + LOG.debug("Fetching page {}", _url); + Request request = new Request.Builder() + .url(_url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", bongaCams.getBaseUrl()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + Response response = bongaCams.getHttpClient().execute(request); + if (response.isSuccessful()) { + String content = response.body().string(); + List models = new ArrayList<>(); + JSONObject json = new JSONObject(content); + if(json.optString("status").equals("success")) { + JSONArray _models = json.getJSONArray("models"); + for (int i = 0; i < _models.length(); i++) { + JSONObject m = _models.getJSONObject(i); + String name = m.getString("username"); + BongaCamsModel model = (BongaCamsModel) bongaCams.createModel(name); + model.setUserId(m.getInt("user_id")); + boolean away = m.optBoolean("is_away"); + boolean online = m.optBoolean("online") && !away; + model.setOnline(online); + if(online) { + if(away) { + model.setOnlineState("away"); + } else { + model.setOnlineState(m.getString("room")); + } + } else { + model.setOnlineState("offline"); + } + model.setPreview("https:" + m.getString("thumb_image")); + models.add(model); + } + } + return models; + } else { + int code = response.code(); + response.close(); + throw new IOException("HTTP status " + code); + } + } + }; + } +} diff --git a/src/main/java/ctbrec/sites/cam4/Cam4.java b/src/main/java/ctbrec/sites/cam4/Cam4.java index 5d791883..7533f96e 100644 --- a/src/main/java/ctbrec/sites/cam4/Cam4.java +++ b/src/main/java/ctbrec/sites/cam4/Cam4.java @@ -9,17 +9,8 @@ import ctbrec.Model; import ctbrec.io.HttpClient; import ctbrec.recorder.Recorder; import ctbrec.sites.AbstractSite; -import ctbrec.ui.DesktopIntergation; -import ctbrec.ui.SettingsTab; +import ctbrec.sites.ConfigUI; import ctbrec.ui.TabProvider; -import javafx.geometry.Insets; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.PasswordField; -import javafx.scene.control.TextField; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.Priority; public class Cam4 extends AbstractSite { @@ -124,32 +115,7 @@ public class Cam4 extends AbstractSite { } @Override - public Node getConfigurationGui() { - GridPane layout = SettingsTab.createGridLayout(); - layout.add(new Label("Cam4 User"), 0, 0); - TextField username = new TextField(Config.getInstance().getSettings().cam4Username); - username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Username = username.getText()); - GridPane.setFillWidth(username, true); - GridPane.setHgrow(username, Priority.ALWAYS); - GridPane.setColumnSpan(username, 2); - layout.add(username, 1, 0); - - layout.add(new Label("Cam4 Password"), 0, 1); - PasswordField password = new PasswordField(); - password.setText(Config.getInstance().getSettings().cam4Password); - password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Password = password.getText()); - GridPane.setFillWidth(password, true); - GridPane.setHgrow(password, Priority.ALWAYS); - GridPane.setColumnSpan(password, 2); - layout.add(password, 1, 1); - - Button createAccount = new Button("Create new Account"); - createAccount.setOnAction((e) -> DesktopIntergation.open(Cam4.AFFILIATE_LINK)); - layout.add(createAccount, 1, 2); - GridPane.setColumnSpan(createAccount, 2); - GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - return layout; + public ConfigUI getConfigurationGui() { + return new Cam4ConfigUI(); } } diff --git a/src/main/java/ctbrec/sites/cam4/Cam4ConfigUI.java b/src/main/java/ctbrec/sites/cam4/Cam4ConfigUI.java new file mode 100644 index 00000000..4c1bef12 --- /dev/null +++ b/src/main/java/ctbrec/sites/cam4/Cam4ConfigUI.java @@ -0,0 +1,48 @@ +package ctbrec.sites.cam4; + +import ctbrec.Config; +import ctbrec.sites.ConfigUI; +import ctbrec.ui.DesktopIntergation; +import ctbrec.ui.SettingsTab; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class Cam4ConfigUI implements ConfigUI { + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + layout.add(new Label("Cam4 User"), 0, 0); + TextField username = new TextField(Config.getInstance().getSettings().cam4Username); + username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Username = username.getText()); + GridPane.setFillWidth(username, true); + GridPane.setHgrow(username, Priority.ALWAYS); + GridPane.setColumnSpan(username, 2); + layout.add(username, 1, 0); + + layout.add(new Label("Cam4 Password"), 0, 1); + PasswordField password = new PasswordField(); + password.setText(Config.getInstance().getSettings().cam4Password); + password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Password = password.getText()); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, 1); + + Button createAccount = new Button("Create new Account"); + createAccount.setOnAction((e) -> DesktopIntergation.open(Cam4.AFFILIATE_LINK)); + layout.add(createAccount, 1, 2); + GridPane.setColumnSpan(createAccount, 2); + GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java b/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java index 3a40c555..968d1de4 100644 --- a/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java +++ b/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java @@ -23,12 +23,23 @@ public class Cam4HttpClient extends HttpClient { private static final transient Logger LOG = LoggerFactory.getLogger(Cam4HttpClient.class); + public Cam4HttpClient() { + super("cam4"); + } + @Override public synchronized boolean login() throws IOException { if(loggedIn) { return true; } + boolean cookiesWorked = checkLoginSuccess(); + if(cookiesWorked) { + loggedIn = true; + LOG.debug("Logged in with cookies"); + return true; + } + BlockingQueue queue = new LinkedBlockingQueue<>(); Runnable showDialog = () -> { diff --git a/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java index c170af83..bb862bed 100644 --- a/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java +++ b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java @@ -60,6 +60,7 @@ public class Cam4LoginDialog { WebView browser = new WebView(); WebEngine webEngine = browser.getEngine(); webEngine.setJavaScriptEnabled(true); + webEngine.setUserAgent(Config.getInstance().getSettings().httpUserAgent); webEngine.locationProperty().addListener((obs, oldV, newV) -> { try { URL _url = new URL(newV); diff --git a/src/main/java/ctbrec/sites/camsoda/Camsoda.java b/src/main/java/ctbrec/sites/camsoda/Camsoda.java index 3cf7f937..c405b336 100644 --- a/src/main/java/ctbrec/sites/camsoda/Camsoda.java +++ b/src/main/java/ctbrec/sites/camsoda/Camsoda.java @@ -9,17 +9,8 @@ import ctbrec.Model; import ctbrec.io.HttpClient; import ctbrec.recorder.Recorder; import ctbrec.sites.AbstractSite; -import ctbrec.ui.DesktopIntergation; -import ctbrec.ui.SettingsTab; +import ctbrec.sites.ConfigUI; import ctbrec.ui.TabProvider; -import javafx.geometry.Insets; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.PasswordField; -import javafx.scene.control.TextField; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.Priority; import okhttp3.Request; import okhttp3.Response; @@ -44,6 +35,11 @@ public class Camsoda extends AbstractSite { return BASE_URI; } + @Override + public String getBuyTokensLink() { + return BASE_URI; + } + @Override public void setRecorder(Recorder recorder) { this.recorder = recorder; @@ -87,11 +83,6 @@ public class Camsoda extends AbstractSite { throw new RuntimeException("Tokens not found in response"); } - @Override - public String getBuyTokensLink() { - return getBaseUrl(); - } - @Override public void login() throws IOException { if(credentialsAvailable()) { @@ -140,32 +131,7 @@ public class Camsoda extends AbstractSite { } @Override - public Node getConfigurationGui() { - GridPane layout = SettingsTab.createGridLayout(); - layout.add(new Label("CamSoda User"), 0, 0); - TextField username = new TextField(Config.getInstance().getSettings().camsodaUsername); - username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaUsername = username.getText()); - GridPane.setFillWidth(username, true); - GridPane.setHgrow(username, Priority.ALWAYS); - GridPane.setColumnSpan(username, 2); - layout.add(username, 1, 0); - - layout.add(new Label("CamSoda Password"), 0, 1); - PasswordField password = new PasswordField(); - password.setText(Config.getInstance().getSettings().camsodaPassword); - password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaPassword = password.getText()); - GridPane.setFillWidth(password, true); - GridPane.setHgrow(password, Priority.ALWAYS); - GridPane.setColumnSpan(password, 2); - layout.add(password, 1, 1); - - Button createAccount = new Button("Create new Account"); - createAccount.setOnAction((e) -> DesktopIntergation.open(getAffiliateLink())); - layout.add(createAccount, 1, 2); - GridPane.setColumnSpan(createAccount, 2); - GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - return layout; + public ConfigUI getConfigurationGui() { + return new CamsodaConfigUI(this); } } diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaConfigUI.java b/src/main/java/ctbrec/sites/camsoda/CamsodaConfigUI.java new file mode 100644 index 00000000..056ecd86 --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaConfigUI.java @@ -0,0 +1,54 @@ +package ctbrec.sites.camsoda; + +import ctbrec.Config; +import ctbrec.sites.ConfigUI; +import ctbrec.ui.DesktopIntergation; +import ctbrec.ui.SettingsTab; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class CamsodaConfigUI implements ConfigUI { + + private Camsoda camsoda; + + public CamsodaConfigUI(Camsoda camsoda) { + this.camsoda = camsoda; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + layout.add(new Label("CamSoda User"), 0, 0); + TextField username = new TextField(Config.getInstance().getSettings().camsodaUsername); + username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaUsername = username.getText()); + GridPane.setFillWidth(username, true); + GridPane.setHgrow(username, Priority.ALWAYS); + GridPane.setColumnSpan(username, 2); + layout.add(username, 1, 0); + + layout.add(new Label("CamSoda Password"), 0, 1); + PasswordField password = new PasswordField(); + password.setText(Config.getInstance().getSettings().camsodaPassword); + password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaPassword = password.getText()); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, 1); + + Button createAccount = new Button("Create new Account"); + createAccount.setOnAction((e) -> DesktopIntergation.open(camsoda.getAffiliateLink())); + layout.add(createAccount, 1, 2); + GridPane.setColumnSpan(createAccount, 2); + GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java b/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java index 9ab57b9e..fbb90d6c 100644 --- a/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java @@ -29,12 +29,23 @@ public class CamsodaHttpClient extends HttpClient { private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaHttpClient.class); private String csrfToken = null; + public CamsodaHttpClient() { + super("camsoda"); + } + @Override public boolean login() throws IOException { if(loggedIn) { return true; } + // persisted cookies might let us log in + if(checkLoginSuccess()) { + loggedIn = true; + LOG.debug("Logged in with cookies"); + return true; + } + String url = Camsoda.BASE_URI + "/api/v1/auth/login"; FormBody body = new FormBody.Builder() .add("username", Config.getInstance().getSettings().camsodaUsername) diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java b/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java index 1fe5beeb..fb2b7aff 100644 --- a/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java @@ -25,6 +25,7 @@ import com.iheartradio.m3u8.data.PlaylistData; import com.iheartradio.m3u8.data.StreamInfo; import ctbrec.AbstractModel; +import ctbrec.Config; import ctbrec.recorder.download.StreamSource; import ctbrec.sites.Site; import okhttp3.FormBody; @@ -181,7 +182,7 @@ public class CamsodaModel extends AbstractModel { .url(url) .post(body) .addHeader("Referer", Camsoda.BASE_URI + '/' + getName()) - .addHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0") + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) .addHeader("Accept", "application/json, text/plain, */*") .addHeader("Accept-Language", "en") .addHeader("X-CSRF-Token", csrfToken) @@ -203,7 +204,7 @@ public class CamsodaModel extends AbstractModel { .url(url) .post(RequestBody.create(null, "")) .addHeader("Referer", Camsoda.BASE_URI + '/' + getName()) - .addHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0") + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) .addHeader("Accept", "application/json, text/plain, */*") .addHeader("Accept-Language", "en") .addHeader("X-CSRF-Token", csrfToken) @@ -227,7 +228,7 @@ public class CamsodaModel extends AbstractModel { .url(url) .post(RequestBody.create(null, "")) .addHeader("Referer", Camsoda.BASE_URI + '/' + getName()) - .addHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0") + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) .addHeader("Accept", "application/json, text/plain, */*") .addHeader("Accept-Language", "en") .addHeader("X-CSRF-Token", csrfToken) diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java b/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java index 44070fcc..3557a55a 100644 --- a/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java @@ -195,8 +195,8 @@ public class CamsodaShowsTab extends Tab implements TabSelectionListener { root.setCenter(grid); loadImage(model, thumb); - record.prefWidthProperty().bind(openInBrowser.widthProperty()); - follow.prefWidthProperty().bind(openInBrowser.widthProperty()); + record.minWidthProperty().bind(openInBrowser.widthProperty()); + follow.minWidthProperty().bind(openInBrowser.widthProperty()); } private void follow(Model model) { diff --git a/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java b/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java index 984e8c69..d06b37b4 100644 --- a/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java +++ b/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java @@ -29,18 +29,9 @@ import ctbrec.Model; import ctbrec.io.HttpClient; import ctbrec.recorder.Recorder; import ctbrec.sites.AbstractSite; -import ctbrec.ui.DesktopIntergation; +import ctbrec.sites.ConfigUI; import ctbrec.ui.HtmlParser; -import ctbrec.ui.SettingsTab; import ctbrec.ui.TabProvider; -import javafx.geometry.Insets; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.PasswordField; -import javafx.scene.control.TextField; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.Priority; import okhttp3.FormBody; import okhttp3.Request; import okhttp3.RequestBody; @@ -316,33 +307,8 @@ public class Chaturbate extends AbstractSite { } @Override - public Node getConfigurationGui() { - GridPane layout = SettingsTab.createGridLayout(); - layout.add(new Label("Chaturbate User"), 0, 0); - TextField 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, 0); - - layout.add(new Label("Chaturbate Password"), 0, 1); - PasswordField password = new PasswordField(); - password.setText(Config.getInstance().getSettings().password); - password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().password = password.getText()); - GridPane.setFillWidth(password, true); - GridPane.setHgrow(password, Priority.ALWAYS); - GridPane.setColumnSpan(password, 2); - layout.add(password, 1, 1); - - Button createAccount = new Button("Create new Account"); - createAccount.setOnAction((e) -> DesktopIntergation.open(Chaturbate.REGISTRATION_LINK)); - layout.add(createAccount, 1, 2); - GridPane.setColumnSpan(createAccount, 2); - GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - return layout; + public ConfigUI getConfigurationGui() { + return new ChaturbateConfigUi(); } @Override diff --git a/src/main/java/ctbrec/sites/chaturbate/ChaturbateConfigUi.java b/src/main/java/ctbrec/sites/chaturbate/ChaturbateConfigUi.java new file mode 100644 index 00000000..b215b879 --- /dev/null +++ b/src/main/java/ctbrec/sites/chaturbate/ChaturbateConfigUi.java @@ -0,0 +1,48 @@ +package ctbrec.sites.chaturbate; + +import ctbrec.Config; +import ctbrec.sites.ConfigUI; +import ctbrec.ui.DesktopIntergation; +import ctbrec.ui.SettingsTab; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class ChaturbateConfigUi implements ConfigUI { + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + + layout.add(new Label("Chaturbate User"), 0, 0); + TextField 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, 0); + + layout.add(new Label("Chaturbate Password"), 0, 1); + PasswordField password = new PasswordField(); + password.setText(Config.getInstance().getSettings().password); + password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().password = password.getText()); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, 1); + + Button createAccount = new Button("Create new Account"); + createAccount.setOnAction((e) -> DesktopIntergation.open(Chaturbate.REGISTRATION_LINK)); + layout.add(createAccount, 1, 2); + GridPane.setColumnSpan(createAccount, 2); + GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + + return layout; + } +} diff --git a/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java b/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java index 33cdbcd8..58e1f059 100644 --- a/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java +++ b/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java @@ -20,6 +20,10 @@ public class ChaturbateHttpClient extends HttpClient { private static final transient Logger LOG = LoggerFactory.getLogger(ChaturbateHttpClient.class); protected String token; + public ChaturbateHttpClient() { + super("chaturbate"); + } + private void extractCsrfToken(Request request) { try { Cookie csrfToken = cookieJar.getCookie(request.url(), "csrftoken"); @@ -38,6 +42,16 @@ public class ChaturbateHttpClient extends HttpClient { @Override public boolean login() throws IOException { + if(loggedIn) { + return true; + } + + if(checkLogin()) { + loggedIn = true; + LOG.debug("Logged in with cookies"); + return true; + } + try { Request login = new Request.Builder() .url(Chaturbate.BASE_URI + "/auth/login/") @@ -82,6 +96,24 @@ public class ChaturbateHttpClient extends HttpClient { return loggedIn; } + private boolean checkLogin() throws IOException { + String url = "https://chaturbate.com/p/" + Config.getInstance().getSettings().username + "/"; + Request req = new Request.Builder().url(url).build(); + Response resp = execute(req); + if (resp.isSuccessful()) { + String profilePage = resp.body().string(); + try { + HtmlParser.getText(profilePage, "span.tokencount"); + return true; + } catch(Exception e) { + LOG.debug("Token tag not found. Login failed"); + return false; + } + } else { + throw new IOException("HTTP response: " + resp.code() + " - " + resp.message()); + } + } + @Override public Response execute(Request req, boolean requiresLogin) throws IOException { Response resp = super.execute(req, requiresLogin); diff --git a/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java b/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java index 9b573fb2..d635994b 100644 --- a/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java +++ b/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java @@ -17,6 +17,7 @@ import com.iheartradio.m3u8.data.MasterPlaylist; import com.iheartradio.m3u8.data.PlaylistData; import ctbrec.AbstractModel; +import ctbrec.Config; import ctbrec.recorder.download.StreamSource; import ctbrec.sites.Site; import okhttp3.Request; @@ -147,7 +148,7 @@ public class ChaturbateModel extends AbstractModel { .header("Accept", "*/*") .header("Accept-Language", "en-US,en;q=0.5") .header("Referer", getUrl()) - .header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0") + .header("User-Agent", Config.getInstance().getSettings().httpUserAgent) .header("X-CSRFToken", ((ChaturbateHttpClient)site.getHttpClient()).getToken()) .header("X-Requested-With", "XMLHttpRequest") .build(); diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCams.java b/src/main/java/ctbrec/sites/mfc/MyFreeCams.java index c06c1218..1d857810 100644 --- a/src/main/java/ctbrec/sites/mfc/MyFreeCams.java +++ b/src/main/java/ctbrec/sites/mfc/MyFreeCams.java @@ -8,18 +8,9 @@ import ctbrec.Config; import ctbrec.Model; import ctbrec.recorder.Recorder; import ctbrec.sites.AbstractSite; -import ctbrec.ui.DesktopIntergation; +import ctbrec.sites.ConfigUI; import ctbrec.ui.HtmlParser; -import ctbrec.ui.SettingsTab; import ctbrec.ui.TabProvider; -import javafx.geometry.Insets; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.PasswordField; -import javafx.scene.control.TextField; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.Priority; import okhttp3.Request; import okhttp3.Response; @@ -129,33 +120,8 @@ public class MyFreeCams extends AbstractSite { } @Override - public Node getConfigurationGui() { - GridPane layout = SettingsTab.createGridLayout(); - layout.add(new Label("MyFreeCams User"), 0, 0); - TextField username = new TextField(Config.getInstance().getSettings().mfcUsername); - username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().mfcUsername = username.getText()); - GridPane.setFillWidth(username, true); - GridPane.setHgrow(username, Priority.ALWAYS); - GridPane.setColumnSpan(username, 2); - layout.add(username, 1, 0); - - layout.add(new Label("MyFreeCams Password"), 0, 1); - PasswordField password = new PasswordField(); - password.setText(Config.getInstance().getSettings().mfcPassword); - password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().mfcPassword = password.getText()); - GridPane.setFillWidth(password, true); - GridPane.setHgrow(password, Priority.ALWAYS); - GridPane.setColumnSpan(password, 2); - layout.add(password, 1, 1); - - Button createAccount = new Button("Create new Account"); - createAccount.setOnAction((e) -> DesktopIntergation.open(getAffiliateLink())); - layout.add(createAccount, 1, 2); - GridPane.setColumnSpan(createAccount, 2); - GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); - return layout; + public ConfigUI getConfigurationGui() { + return new MyFreeCamsConfigUI(this); } @Override diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java b/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java index 17191f65..40f228a2 100644 --- a/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java +++ b/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java @@ -7,9 +7,7 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -23,6 +21,8 @@ import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; import com.google.common.collect.EvictingQueue; import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; @@ -45,8 +45,8 @@ public class MyFreeCamsClient { private Moshi moshi; private volatile boolean running = false; - private Map sessionStates = new HashMap<>(); - private Map models = new HashMap<>(); + private Cache sessionStates = CacheBuilder.newBuilder().maximumSize(4000).build(); + private Cache models = CacheBuilder.newBuilder().maximumSize(4000).build(); private Lock lock = new ReentrantLock(); private ExecutorService executor = Executors.newSingleThreadExecutor(); private ServerConfig serverConfig; @@ -59,7 +59,7 @@ public class MyFreeCamsClient { private int sessionId; private long heartBeat; - private EvictingQueue receivedTextHistory = EvictingQueue.create(10000); + private EvictingQueue receivedTextHistory = EvictingQueue.create(100); private MyFreeCamsClient() { moshi = new Moshi.Builder().build(); @@ -118,7 +118,7 @@ public class MyFreeCamsClient { lock.lock(); try { LOG.trace("Models: {}", models.size()); - return new ArrayList<>(this.models.values()); + return new ArrayList<>(this.models.asMap().values()); } finally { lock.unlock(); } @@ -208,7 +208,7 @@ public class MyFreeCamsClient { JSONObject json = new JSONObject(message.getMessage()); String[] names = JSONObject.getNames(json); Integer uid = Integer.parseInt(names[0]); - SessionState sessionState = sessionStates.get(uid); + SessionState sessionState = sessionStates.getIfPresent(uid); if (sessionState != null) { JSONArray tags = json.getJSONArray(names[0]); for (Object obj : tags) { @@ -358,7 +358,7 @@ public class MyFreeCamsClient { if (newState.getUid() <= 0) { return; } - SessionState storedState = sessionStates.get(newState.getUid()); + SessionState storedState = sessionStates.getIfPresent(newState.getUid()); if (storedState != null) { storedState.merge(newState); updateModel(storedState); @@ -384,7 +384,7 @@ public class MyFreeCamsClient { return; } - MyFreeCamsModel model = models.get(state.getUid()); + MyFreeCamsModel model = models.getIfPresent(state.getUid()); if(model == null) { model = mfc.createModel(state.getNm()); model.setUid(state.getUid()); @@ -494,7 +494,7 @@ public class MyFreeCamsClient { public void update(MyFreeCamsModel model) { lock.lock(); try { - for (SessionState state : sessionStates.values()) { + for (SessionState state : sessionStates.asMap().values()) { if(Objects.equals(state.getNm(), model.getName()) || Objects.equals(model.getUid(), state.getUid())) { model.update(state, getStreamUrl(state)); return; @@ -532,7 +532,7 @@ public class MyFreeCamsClient { } public MyFreeCamsModel getModel(int uid) { - return models.get(uid); + return models.getIfPresent(uid); } public void execute(Runnable r) { @@ -540,7 +540,7 @@ public class MyFreeCamsClient { } public void getSessionState(ctbrec.Model model) { - for (SessionState state : sessionStates.values()) { + for (SessionState state : sessionStates.asMap().values()) { if(Objects.equals(state.getNm(), model.getName())) { JsonAdapter adapter = moshi.adapter(SessionState.class).indent(" "); System.out.println(adapter.toJson(state)); diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCamsConfigUI.java b/src/main/java/ctbrec/sites/mfc/MyFreeCamsConfigUI.java new file mode 100644 index 00000000..1190f061 --- /dev/null +++ b/src/main/java/ctbrec/sites/mfc/MyFreeCamsConfigUI.java @@ -0,0 +1,54 @@ +package ctbrec.sites.mfc; + +import ctbrec.Config; +import ctbrec.sites.ConfigUI; +import ctbrec.ui.DesktopIntergation; +import ctbrec.ui.SettingsTab; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class MyFreeCamsConfigUI implements ConfigUI { + + private MyFreeCams myFreeCams; + + public MyFreeCamsConfigUI(MyFreeCams myFreeCams) { + this.myFreeCams = myFreeCams; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + layout.add(new Label("MyFreeCams User"), 0, 0); + TextField username = new TextField(Config.getInstance().getSettings().mfcUsername); + username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().mfcUsername = username.getText()); + GridPane.setFillWidth(username, true); + GridPane.setHgrow(username, Priority.ALWAYS); + GridPane.setColumnSpan(username, 2); + layout.add(username, 1, 0); + + layout.add(new Label("MyFreeCams Password"), 0, 1); + PasswordField password = new PasswordField(); + password.setText(Config.getInstance().getSettings().mfcPassword); + password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().mfcPassword = password.getText()); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, 1); + + Button createAccount = new Button("Create new Account"); + createAccount.setOnAction((e) -> DesktopIntergation.open(myFreeCams.getAffiliateLink())); + layout.add(createAccount, 1, 2); + GridPane.setColumnSpan(createAccount, 2); + GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCamsHttpClient.java b/src/main/java/ctbrec/sites/mfc/MyFreeCamsHttpClient.java index 762d3dd6..62c6229c 100644 --- a/src/main/java/ctbrec/sites/mfc/MyFreeCamsHttpClient.java +++ b/src/main/java/ctbrec/sites/mfc/MyFreeCamsHttpClient.java @@ -5,11 +5,13 @@ import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; +import org.jsoup.select.Elements; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.io.HttpClient; +import ctbrec.ui.HtmlParser; import okhttp3.Cookie; import okhttp3.CookieJar; import okhttp3.FormBody; @@ -24,12 +26,22 @@ public class MyFreeCamsHttpClient extends HttpClient { private static final transient Logger LOG = LoggerFactory.getLogger(MyFreeCamsHttpClient.class); + public MyFreeCamsHttpClient() { + super("myfreecams"); + } + @Override public boolean login() throws IOException { if(loggedIn) { return true; } + if(checkLogin()) { + loggedIn = true; + LOG.debug("Logged in with cookies"); + return true; + } + String username = Config.getInstance().getSettings().mfcUsername; String password = Config.getInstance().getSettings().mfcPassword; RequestBody body = new FormBody.Builder() @@ -61,6 +73,25 @@ public class MyFreeCamsHttpClient extends HttpClient { } } + private boolean checkLogin() throws IOException { + Request req = new Request.Builder().url(MyFreeCams.BASE_URI + "/php/account.php?request=status").build(); + Response resp = execute(req); + if(resp.isSuccessful()) { + String content = resp.body().string(); + try { + Elements tags = HtmlParser.getTags(content, "div.content > p > b"); + tags.get(2).text(); + return true; + } catch(Exception e) { + LOG.debug("Token tag not found. Login failed"); + return false; + } + } else { + resp.close(); + throw new IOException(resp.code() + " " + resp.message()); + } + } + public WebSocket newWebSocket(Request req, WebSocketListener webSocketListener) { return client.newWebSocket(req, webSocketListener); } diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java b/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java index db794ae2..0c8ac197 100644 --- a/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java +++ b/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java @@ -213,6 +213,7 @@ public class MyFreeCamsModel extends AbstractModel { public void setName(String name) { if(getName() != null && name != null && !getName().equals(name)) { LOG.debug("Model name changed {} -> {}", getName(), name); + setUrl("https://profiles.myfreecams.com/" + name); } super.setName(name); } diff --git a/src/main/java/ctbrec/sites/mfc/X.java b/src/main/java/ctbrec/sites/mfc/X.java index db5175db..8b0b99ee 100644 --- a/src/main/java/ctbrec/sites/mfc/X.java +++ b/src/main/java/ctbrec/sites/mfc/X.java @@ -37,8 +37,12 @@ public class X { if(x == null) { return; } - fcext.merge(x.fcext); - share.merge(x.share); + if (fcext != null) { + fcext.merge(x.fcext); + } + if (share != null) { + share.merge(x.share); + } additionalProperties.putAll(x.additionalProperties); } diff --git a/src/main/java/ctbrec/ui/CamrecApplication.java b/src/main/java/ctbrec/ui/CamrecApplication.java index 90be7e79..5856873e 100644 --- a/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/src/main/java/ctbrec/ui/CamrecApplication.java @@ -27,6 +27,7 @@ import ctbrec.recorder.LocalRecorder; import ctbrec.recorder.Recorder; import ctbrec.recorder.RemoteRecorder; import ctbrec.sites.Site; +import ctbrec.sites.bonga.BongaCams; import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; @@ -60,10 +61,11 @@ public class CamrecApplication extends Application { @Override public void start(Stage primaryStage) throws Exception { + sites.add(new BongaCams()); + sites.add(new Cam4()); + sites.add(new Camsoda()); sites.add(new Chaturbate()); sites.add(new MyFreeCams()); - sites.add(new Camsoda()); - sites.add(new Cam4()); loadConfig(); createHttpClient(); bus = new AsyncEventBus(Executors.newSingleThreadExecutor()); @@ -198,7 +200,7 @@ public class CamrecApplication extends Application { } private void createHttpClient() { - httpClient = new HttpClient() { + httpClient = new HttpClient("camrec") { @Override public boolean login() throws IOException { return false; diff --git a/src/main/java/ctbrec/ui/JavaFxModel.java b/src/main/java/ctbrec/ui/JavaFxModel.java index a1c9d399..9cd8420a 100644 --- a/src/main/java/ctbrec/ui/JavaFxModel.java +++ b/src/main/java/ctbrec/ui/JavaFxModel.java @@ -9,7 +9,6 @@ import com.iheartradio.m3u8.PlaylistException; import com.squareup.moshi.JsonReader; import com.squareup.moshi.JsonWriter; -import ctbrec.AbstractModel; import ctbrec.Model; import ctbrec.recorder.download.StreamSource; import ctbrec.sites.Site; @@ -19,15 +18,13 @@ 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 AbstractModel { +public class JavaFxModel implements Model { private transient BooleanProperty onlineProperty = new SimpleBooleanProperty(); + private transient BooleanProperty pausedProperty = new SimpleBooleanProperty(); private Model delegate; public JavaFxModel(Model delegate) { this.delegate = delegate; - try { - onlineProperty.set(delegate.isOnline()); - } catch (IOException | ExecutionException | InterruptedException e) {} } @Override @@ -89,6 +86,10 @@ public class JavaFxModel extends AbstractModel { return onlineProperty; } + public BooleanProperty getPausedProperty() { + return pausedProperty; + } + Model getDelegate() { return delegate; } @@ -157,4 +158,35 @@ public class JavaFxModel extends AbstractModel { public void writeSiteSpecificData(JsonWriter writer) throws IOException { delegate.writeSiteSpecificData(writer); } + + @Override + public String getDescription() { + return delegate.getDescription(); + } + + @Override + public void setDescription(String description) { + delegate.setDescription(description); + } + + @Override + public int getStreamUrlIndex() { + return delegate.getStreamUrlIndex(); + } + + @Override + public void setStreamUrlIndex(int streamUrlIndex) { + delegate.setStreamUrlIndex(streamUrlIndex); + } + + @Override + public boolean isSuspended() { + return delegate.isSuspended(); + } + + @Override + public void setSuspended(boolean suspended) { + delegate.setSuspended(suspended); + pausedProperty.set(suspended); + } } diff --git a/src/main/java/ctbrec/ui/PauseIndicator.java b/src/main/java/ctbrec/ui/PauseIndicator.java new file mode 100644 index 00000000..f716b871 --- /dev/null +++ b/src/main/java/ctbrec/ui/PauseIndicator.java @@ -0,0 +1,18 @@ +package ctbrec.ui; + +import javafx.scene.layout.HBox; +import javafx.scene.paint.Color; +import javafx.scene.shape.Rectangle; + +public class PauseIndicator extends HBox { + + public PauseIndicator(Color c, int size) { + spacingProperty().setValue(size*1/5); + Rectangle left = new Rectangle(size*2/5, size); + left.setFill(c); + Rectangle right = new Rectangle(size*2/5, size); + right.setFill(c); + getChildren().add(left); + getChildren().add(right); + } +} diff --git a/src/main/java/ctbrec/ui/RecordedModelsTab.java b/src/main/java/ctbrec/ui/RecordedModelsTab.java index 5a37b229..3d5dfd37 100644 --- a/src/main/java/ctbrec/ui/RecordedModelsTab.java +++ b/src/main/java/ctbrec/ui/RecordedModelsTab.java @@ -67,7 +67,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { ScrollPane scrollPane = new ScrollPane(); TableView table = new TableView(); ObservableList observableModels = FXCollections.observableArrayList(); - ContextMenu popup = createContextMenu(); + ContextMenu popup; Label modelLabel = new Label("Model"); TextField model = new TextField(); @@ -104,11 +104,17 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { online.setCellValueFactory((cdf) -> cdf.getValue().getOnlineProperty()); online.setCellFactory(CheckBoxTableCell.forTableColumn(online)); online.setPrefWidth(60); - table.getColumns().addAll(name, url, online); + TableColumn paused = new TableColumn<>("Paused"); + paused.setCellValueFactory((cdf) -> cdf.getValue().getPausedProperty()); + paused.setCellFactory(CheckBoxTableCell.forTableColumn(paused)); + paused.setPrefWidth(60); + table.getColumns().addAll(name, url, online, paused); table.setItems(observableModels); table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { popup = createContextMenu(); - popup.show(table, event.getScreenX(), event.getScreenY()); + if(popup != null) { + popup.show(table, event.getScreenX(), event.getScreenY()); + } event.consume(); }); table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { @@ -186,17 +192,20 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { queue.clear(); for (Model model : models) { int index = observableModels.indexOf(model); + final JavaFxModel javaFxModel; if (index == -1) { - observableModels.add(new JavaFxModel(model)); + javaFxModel = new JavaFxModel(model); + observableModels.add(javaFxModel); } else { // make sure to update the JavaFX online property, so that the table cell is updated - JavaFxModel javaFxModel = observableModels.get(index); - threadPool.submit(() -> { - try { - javaFxModel.getOnlineProperty().set(javaFxModel.isOnline()); - } catch (IOException | ExecutionException | InterruptedException e) {} - }); + javaFxModel = observableModels.get(index); } + threadPool.submit(() -> { + try { + javaFxModel.getOnlineProperty().set(javaFxModel.isOnline()); + javaFxModel.setSuspended(model.isSuspended()); + } catch (IOException | ExecutionException | InterruptedException e) {} + }); } for (Iterator iterator = observableModels.iterator(); iterator.hasNext();) { Model model = iterator.next(); @@ -204,7 +213,6 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { iterator.remove(); } } - }); updateService.setOnFailed((event) -> { LOG.info("Couldn't get list of models from recorder", event.getSource().getException()); @@ -253,26 +261,37 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } private ContextMenu createContextMenu() { - MenuItem stop = new MenuItem("Stop Recording"); + JavaFxModel selectedModel = table.getSelectionModel().getSelectedItem(); + if(selectedModel == null) { + return null; + } + MenuItem stop = new MenuItem("Remove Model"); stop.setOnAction((e) -> stopAction()); MenuItem copyUrl = new MenuItem("Copy URL"); copyUrl.setOnAction((e) -> { - Model selected = table.getSelectionModel().getSelectedItem(); + Model selected = selectedModel; final Clipboard clipboard = Clipboard.getSystemClipboard(); final ClipboardContent content = new ClipboardContent(); content.putString(selected.getUrl()); clipboard.setContent(content); }); + MenuItem pauseRecording = new MenuItem("Pause Recording"); + pauseRecording.setOnAction((e) -> pauseRecording()); + MenuItem resumeRecording = new MenuItem("Resume Recording"); + resumeRecording.setOnAction((e) -> resumeRecording()); MenuItem openInBrowser = new MenuItem("Open in Browser"); - openInBrowser.setOnAction((e) -> DesktopIntergation.open(table.getSelectionModel().getSelectedItem().getUrl())); + openInBrowser.setOnAction((e) -> DesktopIntergation.open(selectedModel.getUrl())); MenuItem openInPlayer = new MenuItem("Open in Player"); - openInPlayer.setOnAction((e) -> Player.play(table.getSelectionModel().getSelectedItem().getUrl())); + openInPlayer.setOnAction((e) -> Player.play(selectedModel.getUrl())); MenuItem switchStreamSource = new MenuItem("Switch resolution"); - switchStreamSource.setOnAction((e) -> switchStreamSource(table.getSelectionModel().getSelectedItem())); + switchStreamSource.setOnAction((e) -> switchStreamSource(selectedModel)); - return new ContextMenu(stop, copyUrl, openInBrowser, switchStreamSource); + ContextMenu menu = new ContextMenu(stop); + menu.getItems().add(selectedModel.isSuspended() ? resumeRecording : pauseRecording); + menu.getItems().addAll(copyUrl, openInBrowser, switchStreamSource); + return menu; } private void switchStreamSource(JavaFxModel fxModel) { @@ -345,4 +364,60 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { }.start(); } }; + + private void pauseRecording() { + JavaFxModel model = table.getSelectionModel().getSelectedItem(); + Model delegate = table.getSelectionModel().getSelectedItem().getDelegate(); + if (delegate != null) { + table.setCursor(Cursor.WAIT); + new Thread() { + @Override + public void run() { + try { + recorder.suspendRecording(delegate); + Platform.runLater(() -> model.setSuspended(true)); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + LOG.error("Couldn't pause recording", e1); + Platform.runLater(() -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Couldn't pause recording"); + alert.setContentText("Error while pausing the recording: " + e1.getLocalizedMessage()); + alert.showAndWait(); + }); + } finally { + table.setCursor(Cursor.DEFAULT); + } + } + }.start(); + } + }; + + private void resumeRecording() { + JavaFxModel model = table.getSelectionModel().getSelectedItem(); + Model delegate = table.getSelectionModel().getSelectedItem().getDelegate(); + if (delegate != null) { + table.setCursor(Cursor.WAIT); + new Thread() { + @Override + public void run() { + try { + recorder.resumeRecording(delegate); + Platform.runLater(() -> model.setSuspended(false)); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + LOG.error("Couldn't resume recording", e1); + Platform.runLater(() -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Couldn't resume recording"); + alert.setContentText("Error while resuming 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 index e64184f0..13a0701b 100644 --- a/src/main/java/ctbrec/ui/RecordingsTab.java +++ b/src/main/java/ctbrec/ui/RecordingsTab.java @@ -139,9 +139,11 @@ public class RecordingsTab extends Tab implements TabSelectionListener { 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()); + if(recording != null) { + popup = createContextMenu(recording); + if(!popup.getItems().isEmpty()) { + popup.show(table, event.getScreenX(), event.getScreenY()); + } } event.consume(); }); diff --git a/src/main/java/ctbrec/ui/SettingsTab.java b/src/main/java/ctbrec/ui/SettingsTab.java index f0066613..9cc910d7 100644 --- a/src/main/java/ctbrec/ui/SettingsTab.java +++ b/src/main/java/ctbrec/ui/SettingsTab.java @@ -14,6 +14,7 @@ import com.sun.javafx.collections.ObservableListWrapper; import ctbrec.Config; import ctbrec.Hmac; import ctbrec.Settings; +import ctbrec.sites.ConfigUI; import ctbrec.sites.Site; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; @@ -27,6 +28,7 @@ import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.RadioButton; +import javafx.scene.control.ScrollPane; import javafx.scene.control.Tab; import javafx.scene.control.TextField; import javafx.scene.control.TextInputDialog; @@ -54,7 +56,9 @@ public class SettingsTab extends Tab implements TabSelectionListener { public static final int CHECKBOX_MARGIN = 6; private TextField recordingsDirectory; private Button recordingsDirectoryButton; + private Button postProcessingDirectoryButton; private TextField mediaPlayer; + private TextField postProcessing; private TextField server; private TextField port; private CheckBox loadResolution; @@ -88,7 +92,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { ColumnConstraints cc = new ColumnConstraints(); cc.setPercentWidth(50); mainLayout.getColumnConstraints().setAll(cc, cc); - setContent(mainLayout); + setContent(new ScrollPane(mainLayout)); VBox leftSide = new VBox(15); VBox rightSide = new VBox(15); GridPane.setHgrow(leftSide, Priority.ALWAYS); @@ -119,9 +123,9 @@ public class SettingsTab extends Tab implements TabSelectionListener { rightSide.getChildren().add(credentialsAccordion); for (int i = 0; i < sites.size(); i++) { Site site = sites.get(i); - Node siteConfig = site.getConfigurationGui(); + ConfigUI siteConfig = site.getConfigurationGui(); if(siteConfig != null) { - TitledPane pane = new TitledPane(site.getName(), siteConfig); + TitledPane pane = new TitledPane(site.getName(), siteConfig.createConfigPanel()); credentialsAccordion.getPanes().add(pane); } } @@ -265,6 +269,17 @@ public class SettingsTab extends Tab implements TabSelectionListener { layout.add(mediaPlayer, 1, 1); layout.add(createMpvBrowseButton(), 3, 1); + layout.add(new Label("Post-Processing"), 0, 2); + postProcessing = new TextField(Config.getInstance().getSettings().postProcessing); + postProcessing.focusedProperty().addListener(createPostProcessingFocusListener()); + GridPane.setFillWidth(postProcessing, true); + GridPane.setHgrow(postProcessing, Priority.ALWAYS); + GridPane.setColumnSpan(postProcessing, 2); + GridPane.setMargin(postProcessing, new Insets(0, 0, 0, CHECKBOX_MARGIN)); + layout.add(postProcessing, 1, 2); + postProcessingDirectoryButton = createPostProcessingBrowseButton(); + layout.add(postProcessingDirectoryButton, 3, 2); + TitledPane locations = new TitledPane("Locations", layout); locations.setCollapsible(false); return locations; @@ -378,6 +393,8 @@ public class SettingsTab extends Tab implements TabSelectionListener { recordingsDirectoryButton.setDisable(!local); splitAfter.setDisable(!local); maxResolution.setDisable(!local); + postProcessing.setDisable(!local); + postProcessingDirectoryButton.setDisable(!local); } private ChangeListener createRecordingsDirectoryFocusListener() { @@ -412,6 +429,22 @@ public class SettingsTab extends Tab implements TabSelectionListener { }; } + private ChangeListener createPostProcessingFocusListener() { + return new ChangeListener() { + @Override + public void changed(ObservableValue arg0, Boolean oldPropertyValue, Boolean newPropertyValue) { + if (newPropertyValue) { + postProcessing.setBorder(Border.EMPTY); + postProcessing.setTooltip(null); + } else { + String input = postProcessing.getText(); + File program = new File(input); + setPostProcessing(program); + } + } + }; + } + private void setMpv(File program) { String msg = validateProgram(program); if (msg != null) { @@ -422,6 +455,16 @@ public class SettingsTab extends Tab implements TabSelectionListener { } } + private void setPostProcessing(File program) { + String msg = validateProgram(program); + if (msg != null) { + postProcessing.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2)))); + postProcessing.setTooltip(new Tooltip(msg)); + } else { + Config.getInstance().getSettings().postProcessing = postProcessing.getText(); + } + } + private String validateProgram(File program) { if (program == null || !program.exists()) { return "File does not exist"; @@ -468,6 +511,27 @@ public class SettingsTab extends Tab implements TabSelectionListener { return button; } + private Button createPostProcessingBrowseButton() { + Button button = new Button("Select"); + button.setOnAction((e) -> { + FileChooser chooser = new FileChooser(); + File program = chooser.showOpenDialog(null); + if(program != null) { + try { + postProcessing.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(); + } + setPostProcessing(program); + } + }); + return button; + } + private void setRecordingsDir(File dir) { if (dir != null && dir.isDirectory()) { try { diff --git a/src/main/java/ctbrec/ui/ThumbCell.java b/src/main/java/ctbrec/ui/ThumbCell.java index f579a948..1865844b 100644 --- a/src/main/java/ctbrec/ui/ThumbCell.java +++ b/src/main/java/ctbrec/ui/ThumbCell.java @@ -64,6 +64,7 @@ public class ThumbCell extends StackPane { private Text resolutionTag; private Recorder recorder; private Circle recordingIndicator; + private PauseIndicator pausedIndicator; private int index = 0; ContextMenu popup; private final Color colorNormal = Color.BLACK; @@ -81,6 +82,7 @@ public class ThumbCell extends StackPane { this.model = model; this.recorder = recorder; recording = recorder.isRecording(model); + model.setSuspended(recorder.isSuspended(model)); this.setStyle("-fx-background-color: lightgray"); iv = new ImageView(); @@ -118,7 +120,10 @@ public class ThumbCell extends StackPane { StackPane.setAlignment(name, Pos.BOTTOM_CENTER); getChildren().add(name); - topic = new Text(model.getDescription()); + topic = new Text(); + String txt = recording ? " " : ""; + txt += model.getDescription(); + topic.setText(txt); topic.setFill(Color.WHITE); topic.setFont(new Font("Sansserif", 13)); @@ -142,6 +147,12 @@ public class ThumbCell extends StackPane { StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT); getChildren().add(recordingIndicator); + pausedIndicator = new PauseIndicator(colorRecording, 16); + pausedIndicator.setVisible(false); + StackPane.setMargin(pausedIndicator, new Insets(3)); + StackPane.setAlignment(pausedIndicator, Pos.TOP_LEFT); + getChildren().add(pausedIndicator); + selectionOverlay = new Rectangle(); selectionOverlay.setOpacity(0); StackPane.setAlignment(selectionOverlay, Pos.TOP_LEFT); @@ -208,13 +219,15 @@ public class ThumbCell extends StackPane { LOG.trace("Removing invalid resolution value for {}", model.getName()); model.invalidateCacheEntries(); } - + Thread.sleep(500); } catch (IOException | InterruptedException e1) { LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e1); } catch(ExecutionException e) { if(e.getCause() instanceof EOFException) { LOG.warn("Couldn't update resolution tag for model {}. Playlist empty", model.getName()); + } else if(e.getCause() instanceof ParseException) { + LOG.warn("Couldn't update resolution tag for model {} - {}", model.getName(), e.getMessage()); } else { LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e); } @@ -321,7 +334,14 @@ public class ThumbCell extends StackPane { Color c = mouseHovering ? colorHighlight : colorNormal; nameBackground.setFill(c); } - recordingIndicator.setVisible(recording); + + if(recording) { + recordingIndicator.setVisible(!model.isSuspended()); + pausedIndicator.setVisible(model.isSuspended()); + } else { + recordingIndicator.setVisible(false); + pausedIndicator.setVisible(false); + } } void startStopAction(boolean start) { @@ -347,6 +367,31 @@ public class ThumbCell extends StackPane { } } + void pauseResumeAction(boolean pause) { + setCursor(Cursor.WAIT); + new Thread(() -> { + try { + if(pause) { + recorder.suspendRecording(model); + } else { + recorder.resumeRecording(model); + } + setRecording(recording); + } catch (Exception e1) { + LOG.error("Couldn't pause/resume recording", e1); + Platform.runLater(() -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Couldn't pause/resume recording"); + alert.setContentText("I/O error while pausing/resuming the recording: " + e1.getLocalizedMessage()); + alert.showAndWait(); + }); + } finally { + setCursor(Cursor.DEFAULT); + } + }).start(); + } + private void _startStopAction(Model model, boolean start) { new Thread(() -> { try { @@ -426,6 +471,7 @@ public class ThumbCell extends StackPane { this.model.setPreview(model.getPreview()); this.model.setTags(model.getTags()); this.model.setUrl(model.getUrl()); + this.model.setSuspended(model.isSuspended()); update(); } @@ -438,6 +484,7 @@ public class ThumbCell extends StackPane { } private void update() { + model.setSuspended(recorder.isSuspended(model)); setRecording(recorder.isRecording(model)); setImage(model.getPreview()); String txt = recording ? " " : ""; diff --git a/src/main/java/ctbrec/ui/ThumbOverviewTab.java b/src/main/java/ctbrec/ui/ThumbOverviewTab.java index 447336c6..b7e8a7f2 100644 --- a/src/main/java/ctbrec/ui/ThumbOverviewTab.java +++ b/src/main/java/ctbrec/ui/ThumbOverviewTab.java @@ -324,6 +324,12 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { stop.setOnAction((e) -> startStopAction(getSelectedThumbCells(cell), false)); MenuItem startStop = recorder.isRecording(cell.getModel()) ? stop : start; + MenuItem pause = new MenuItem("Pause Recording"); + pause.setOnAction((e) -> pauseResumeAction(getSelectedThumbCells(cell), true)); + MenuItem resume = new MenuItem("Resume Recording"); + resume.setOnAction((e) -> pauseResumeAction(getSelectedThumbCells(cell), false)); + MenuItem pauseResume = recorder.isSuspended(cell.getModel()) ? resume : pause; + MenuItem follow = new MenuItem("Follow"); follow.setOnAction((e) -> follow(getSelectedThumbCells(cell), true)); MenuItem unfollow = new MenuItem("Unfollow"); @@ -389,6 +395,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { contextMenu.setHideOnEscape(true); contextMenu.setAutoFix(true); contextMenu.getItems().addAll(openInPlayer, startStop); + if(recorder.isRecording(cell.getModel())) { + contextMenu.getItems().add(pauseResume); + } if(site.supportsFollow()) { MenuItem followOrUnFollow = (this instanceof FollowedTab) ? unfollow : follow; followOrUnFollow.setDisable(!site.credentialsAvailable()); @@ -431,6 +440,12 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { } } + private void pauseResumeAction(List selection, boolean pause) { + for (ThumbCell thumbCell : selection) { + thumbCell.pauseResumeAction(pause); + } + } + private void startPlayer(List selection) { for (ThumbCell thumbCell : selection) { thumbCell.startPlayer(); diff --git a/src/main/resources/pp.bat b/src/main/resources/pp.bat new file mode 100644 index 00000000..d8587126 --- /dev/null +++ b/src/main/resources/pp.bat @@ -0,0 +1,18 @@ +REM This is an post-processing example script +REM This script is just a wrapper to call the actual powershell script. +REM But you can do something completly different here, too. +REM +REM If you want to use powershell, make sure, that your system allows the execution of powershell scripts: +REM 1. Open cmd.exe as administrator (Click on start, type cmd.exe, right-click on it and select "Run as administrator") +REM 2. Execute powershell +REM 3. Execute Set-ExecutionPolicy Unrestricted + +@echo off + +set directory=%1 +set file=%2 +set model=%3 +set site=%4 +set unixtime=%5 + +powershell -F C:\Users\henrik\Desktop\ctbrec\pp.ps1 -dir "%directory%" -file "%file%" -model "%model%" -site "%site%" -time "%unixtime%" \ No newline at end of file diff --git a/src/main/resources/pp.ps1 b/src/main/resources/pp.ps1 new file mode 100644 index 00000000..47550440 --- /dev/null +++ b/src/main/resources/pp.ps1 @@ -0,0 +1,17 @@ +# parse command line parameters +param ( + [Parameter(Mandatory=$true)][string]$dir, + [Parameter(Mandatory=$true)][string]$file, + [Parameter(Mandatory=$true)][string]$model, + [Parameter(Mandatory=$true)][string]$site, + [Parameter(Mandatory=$true)][string]$time +) + +# convert unixtime into a date object +$epoch = get-date "1/1/1970" +$date = $epoch.AddSeconds($time) + +# print out a theoretical new file name, you could use "rename" here, to rename the file +# or move it somewhere or ... +$newname = "$($model)_$($site)_$($date.toString("yyyyMMdd-HHmm")).ts" +ren $file $newname \ No newline at end of file diff --git a/src/main/resources/pp.sh b/src/main/resources/pp.sh new file mode 100755 index 00000000..b4e829e4 --- /dev/null +++ b/src/main/resources/pp.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# $1 directory (absolute path) +# $2 file (absolute path) +# $3 model name +# $4 site name +# $5 unixtime + +# get the filename without path +FILE=`basename $2` + +# format unixtime to human readable +TIME=$(date --date="@$5" +%d.%m.%Y_%H:%M) + +# define filename of end result +MP4=$(echo "$1/$4_$3_$TIME.mp4") + +# remux ts to mp4 +ffmpeg -i $2 -c:v copy -c:a copy -f mp4 $MP4 + +# move mp4 to target directory +mv $MP4 /tmp + +# delete the original .ts file +rm $2 + +# delete the directory of the recording +rm -r $1