diff --git a/CHANGELOG.md b/CHANGELOG.md index 158fa26d..7460318b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,62 @@ +3.10.10 +======================== +* Fixed MVLive recordings once again +* Fix: "Check URLs" button stays inactive after the first run +* Fix: recordings for some Cam4 models still didn't start +* Some smaller tweaks here and there + +3.10.9 +======================== +* Added more category tabs for CamSoda +* Added button to the "Recording" tab to go over all model URLs and check, if + the account still exists +* Fix: some Cam4 models were not detected as online + +3.10.8 +======================== +* Fixed Stripchat recordings. For some models the recording didn't start, + even if they were online and publicly visible in the browser +* Fixed Bongacams "New" tab. It didn't show new models. +* Added setting to switch FFmpeg logging on/off (category Advanced/Devtools) + +3.10.7 +======================== +* Fixed streaming of recordings from the server (the file path was duplicated + if single file was used) +* Fixed credentials related bugs for Streamate and Stripchat. + They used the user name from Chaturbate for some requests. Whoopsie! +* Renamed settings for Chaturbate's user name and password +* Added setting to split recordings by size +* Added setting to monitor the clipboard for model URLs and automatically add + them to the recorder +* Fixed moving of segment recordings on the server (post-processing) +* Fixed minimal browser on macOS +* Minimal browser config is now stored in ctbrec's config directory + +3.10.6 +======================== +* Fixed Cam4 downloads + +3.10.5 +======================== +* Fixed MV Live downloads +* MFC web socket now uses the TLS URL +* Fix: date placeholders with patterns with more than one occurrence are + replaced with the value of the first one +* Some smaller UI tweaks + * adjusted component sizes for small resolutions + * recording indicator can now be used to pause / resume the recording + * adjusted scroll speed in the thumbnail overviews + * added shortcuts for the thumbnail overviews (keys 1-9 and arrow keys) + * added "stop" and "pause" to Recordings tab + * added "follow" to Recordings tab + +3.10.4 +======================== +* Fix: Bongacams login +* Fix: Minimal browser would freeze on windows +* Update minimal browser to Electron 10.1.5 + 3.10.3 ======================== * Fix: Recordings couldn't be found in client server setup, if the client was diff --git a/client/.gitignore b/client/.gitignore index c97c8943..4694bd3e 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -8,3 +8,5 @@ /server-local.sh /browser/ /ffmpeg/ +/client.iml +/.idea/ diff --git a/client/pom.xml b/client/pom.xml index ea93eea8..a41574e1 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -8,7 +8,7 @@ ctbrec master - 3.10.3 + 3.10.10 ../master @@ -81,10 +81,6 @@ org.openjfx javafx-media - - org.eclipse.jetty - jetty-server - org.eclipse.jetty jetty-servlet diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index d1f04896..03c539a9 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -8,12 +8,15 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; -import java.util.Iterator; import java.util.List; import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,7 +70,6 @@ import ctbrec.ui.tabs.logging.LoggingTab; import javafx.application.Application; import javafx.application.HostServices; import javafx.application.Platform; -import javafx.beans.value.ChangeListener; import javafx.geometry.Insets; import javafx.scene.Scene; import javafx.scene.control.Alert; @@ -92,24 +94,48 @@ public class CamrecApplication extends Application { private Recorder recorder; private OnlineMonitor onlineMonitor; static HostServices hostServices; - private BorderPane rootPane = new BorderPane(); - private HBox statusBar = new HBox(); - private Label statusLabel = new Label(); - private TabPane tabPane = new TabPane(); - private List sites = new ArrayList<>(); + private final BorderPane rootPane = new BorderPane(); + private final HBox statusBar = new HBox(); + private final Label statusLabel = new Label(); + private final TabPane tabPane = new TabPane(); + private final List sites = new ArrayList<>(); public static HttpClient httpClient; public static String title; private Stage primaryStage; private RecordedModelsTab modelsTab; private RecordingsTab recordingsTab; - + private ScheduledExecutorService scheduler; private int activeRecordings = 0; private double bytesPerSecond = 0; + @Override public void start(Stage primaryStage) throws Exception { this.primaryStage = primaryStage; + scheduler = Executors.newScheduledThreadPool(1, r -> { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("Scheduler"); + return t; + }); + logEnvironment(); + initSites(); + loadConfig(); + registerAlertSystem(); + registerActiveRecordingsCounter(); + registerBandwidthMeterListener(); + createHttpClient(); + hostServices = getHostServices(); + createRecorder(); + startOnlineMonitor(); + createGui(primaryStage); + checkForUpdates(); + startHelpServer(); + registerClipboardListener(); + } + + private void initSites() { sites.add(new BongaCams()); sites.add(new Cam4()); sites.add(new Camsoda()); @@ -122,17 +148,13 @@ public class CamrecApplication extends Application { sites.add(new Showup()); sites.add(new Streamate()); sites.add(new Stripchat()); - loadConfig(); - registerAlertSystem(); - registerActiveRecordingsCounter(); - registerBandwidthMeterListener(); - createHttpClient(); - hostServices = getHostServices(); - createRecorder(); - startOnlineMonitor(); - createGui(primaryStage); - checkForUpdates(); - startHelpServer(); + } + + private void registerClipboardListener() { + if(config.getSettings().monitorClipboard) { + ClipboardListener clipboardListener = new ClipboardListener(recorder, sites); + scheduler.scheduleAtFixedRate(clipboardListener, 0, 1, TimeUnit.SECONDS); + } } private void startHelpServer() { @@ -178,11 +200,11 @@ public class CamrecApplication extends Application { Scene scene = new Scene(rootPane, windowWidth, windowHeight); primaryStage.setScene(scene); + Dialogs.setScene(scene); rootPane.setCenter(tabPane); rootPane.setBottom(statusBar); - for (Iterator iterator = sites.iterator(); iterator.hasNext();) { - Site site = iterator.next(); - if(site.isEnabled()) { + for (Site site : sites) { + if (site.isEnabled()) { SiteTab siteTab = new SiteTab(site, scene); tabPane.getTabs().add(siteTab); } @@ -190,7 +212,7 @@ public class CamrecApplication extends Application { modelsTab = new RecordedModelsTab("Recording", recorder, sites); tabPane.getTabs().add(modelsTab); - recordingsTab = new RecordingsTab("Recordings", recorder, config, sites); + recordingsTab = new RecordingsTab("Recordings", recorder, config); tabPane.getTabs().add(recordingsTab); tabPane.getTabs().add(new SettingsTab(sites, recorder)); tabPane.getTabs().add(new NewsTab()); @@ -206,16 +228,16 @@ public class CamrecApplication extends Application { } loadStyleSheet(primaryStage, "style.css"); primaryStage.getScene().getStylesheets().add("/ctbrec/ui/settings/ColorSettingsPane.css"); - primaryStage.getScene().getStylesheets().add("/ctbrec/ui/ThumbCell.css"); primaryStage.getScene().getStylesheets().add("/ctbrec/ui/controls/SearchBox.css"); primaryStage.getScene().getStylesheets().add("/ctbrec/ui/controls/Popover.css"); primaryStage.getScene().getStylesheets().add("/ctbrec/ui/settings/api/Preferences.css"); + primaryStage.getScene().getStylesheets().add("/ctbrec/ui/tabs/ThumbCell.css"); primaryStage.getScene().widthProperty().addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowWidth = newVal.intValue()); primaryStage.getScene().heightProperty() .addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowHeight = newVal.intValue()); primaryStage.setMaximized(Config.getInstance().getSettings().windowMaximized); primaryStage.maximizedProperty() - .addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowMaximized = newVal.booleanValue()); + .addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowMaximized = newVal); Player.scene = primaryStage.getScene(); primaryStage.setX(Config.getInstance().getSettings().windowX); primaryStage.setY(Config.getInstance().getSettings().windowY); @@ -225,7 +247,7 @@ public class CamrecApplication extends Application { primaryStage.setOnCloseRequest(createShutdownHandler()); // register changelistener to activate / deactivate tabs, when the user switches between them - tabPane.getSelectionModel().selectedItemProperty().addListener((ChangeListener) (ov, from, to) -> { + tabPane.getSelectionModel().selectedItemProperty().addListener((ov, from, to) -> { if (from instanceof TabSelectionListener) { ((TabSelectionListener) from).deselected(); } @@ -274,44 +296,42 @@ public class CamrecApplication extends Application { shutdownInfo.show(); final boolean immediately = shutdownNow; - new Thread() { - @Override - public void run() { - modelsTab.saveState(); - recordingsTab.saveState(); - onlineMonitor.shutdown(); - recorder.shutdown(immediately); - for (Site site : sites) { - if(site.isEnabled()) { - site.shutdown(); - } - } - try { - Config.getInstance().save(); - LOG.info("Shutdown complete. Goodbye!"); - Platform.runLater(() -> { - primaryStage.close(); - shutdownInfo.close(); - Platform.exit(); - // This is needed, because OkHttp?! seems to block the shutdown with its writer threads. They are not daemon threads :( - System.exit(0); - }); - } catch (IOException e1) { - Platform.runLater(() -> { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, primaryStage.getScene()); - alert.setTitle("Error saving settings"); - alert.setContentText("Couldn't save settings: " + e1.getLocalizedMessage()); - alert.showAndWait(); - System.exit(1); - }); - } - try { - ExternalBrowser.getInstance().close(); - } catch (IOException e) { - // noop + new Thread(() -> { + modelsTab.saveState(); + recordingsTab.saveState(); + onlineMonitor.shutdown(); + recorder.shutdown(immediately); + for (Site site : sites) { + if (site.isEnabled()) { + site.shutdown(); } } - }.start(); + try { + Config.getInstance().save(); + LOG.info("Shutdown complete. Goodbye!"); + Platform.runLater(() -> { + primaryStage.close(); + shutdownInfo.close(); + Platform.exit(); + // This is needed, because OkHttp?! seems to block the shutdown with its writer threads. They are not daemon threads :( + System.exit(0); + }); + } catch (IOException e1) { + Platform.runLater(() -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, primaryStage.getScene()); + alert.setTitle("Error saving settings"); + alert.setContentText("Couldn't save settings: " + e1.getLocalizedMessage()); + alert.showAndWait(); + System.exit(1); + }); + } + try { + ExternalBrowser.getInstance().close(); + } catch (IOException e12) { + // noop + } + scheduler.shutdownNow(); + }).start(); }; } @@ -355,8 +375,8 @@ public class CamrecApplication extends Application { if (activeRecordings == 0) { bytesPerSecond = 0; } - String humanreadable = ByteUnitFormatter.format(bytesPerSecond); - String status = String.format("Recording %s / %s models @ %s/s", activeRecordings, recorder.getModels().size(), humanreadable); + String humanReadable = ByteUnitFormatter.format(bytesPerSecond); + String status = String.format("Recording %s / %s models @ %s/s", activeRecordings, recorder.getModels().size(), humanReadable); Platform.runLater(() -> statusLabel.setText(status)); } @@ -370,7 +390,7 @@ public class CamrecApplication extends Application { " -fx-focus-color: -fx-accent;\n" + " -fx-control-inner-background-alt: derive(-fx-base, 95%);\n" + "}"; - fos.write(content.getBytes("utf-8")); + fos.write(content.getBytes(StandardCharsets.UTF_8)); } catch(Exception e) { LOG.error("Couldn't write stylesheet for user defined color theme"); } @@ -400,7 +420,6 @@ public class CamrecApplication extends Application { private void createRecorder() { if (config.getSettings().localRecording) { - //recorder = new LocalRecorder(config); try { recorder = new NextGenLocalRecorder(config, sites); } catch (IOException e) { @@ -431,7 +450,7 @@ public class CamrecApplication extends Application { private void createHttpClient() { httpClient = new HttpClient("camrec") { @Override - public boolean login() throws IOException { + public boolean login() { return false; } }; @@ -481,8 +500,7 @@ public class CamrecApplication extends Application { try (InputStream is = CamrecApplication.class.getClassLoader().getResourceAsStream("version")) { BufferedReader reader = new BufferedReader(new InputStreamReader(is)); String versionString = reader.readLine(); - Version version = Version.of(versionString); - return version; + return Version.of(versionString); } } } diff --git a/client/src/main/java/ctbrec/ui/ClipboardListener.java b/client/src/main/java/ctbrec/ui/ClipboardListener.java new file mode 100644 index 00000000..dc83cb96 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/ClipboardListener.java @@ -0,0 +1,66 @@ +package ctbrec.ui; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import javafx.application.Platform; +import javafx.scene.input.Clipboard; + +public class ClipboardListener implements Runnable { + + private static final Logger LOG = LoggerFactory.getLogger(ClipboardListener.class); + private Recorder recorder; + private List sites; + private Clipboard systemClipboard; + private String lastUrl = null; + + public ClipboardListener(Recorder recorder, List sites) { + this.recorder = recorder; + this.sites = sites; + systemClipboard = Clipboard.getSystemClipboard(); + } + + @Override + public void run() { + Platform.runLater(() -> { + try { + String url = null; + if (systemClipboard.hasUrl()) { + url = systemClipboard.getUrl(); + } else if (systemClipboard.hasString()) { + url = systemClipboard.getString(); + } + if (!Objects.equals(url, lastUrl)) { + lastUrl = url; + addModelIfUrlMatches(url); + } + } catch (Exception e) { + LOG.error("Error in clipboard polling loop", e); + } + }); + } + + private void addModelIfUrlMatches(String url) { + for (Site site : sites) { + Model m = site.createModelFromUrl(url); + if (m != null) { + try { + recorder.startRecording(m); + DesktopIntegration.notification("Add from clipboard", "Model added", "Model " + m.getDisplayName() + " added"); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + DesktopIntegration.notification("Add from clipboard", "Error", "Couldn't add URL from clipboard: " + e.getLocalizedMessage()); + } + break; + } + } + } +} diff --git a/client/src/main/java/ctbrec/ui/DesktopIntegration.java b/client/src/main/java/ctbrec/ui/DesktopIntegration.java index 98bd0304..965a094d 100644 --- a/client/src/main/java/ctbrec/ui/DesktopIntegration.java +++ b/client/src/main/java/ctbrec/ui/DesktopIntegration.java @@ -15,7 +15,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.OS; -import ctbrec.io.StreamRedirectThread; +import ctbrec.io.StreamRedirector; import javafx.geometry.Insets; import javafx.scene.control.Alert; import javafx.scene.control.Label; @@ -133,8 +133,8 @@ public class DesktopIntegration { msg.replace("-", "\\\\-").replace("\\s", "\\\\ "), "--icon=dialog-information" }); - new Thread(new StreamRedirectThread(p.getInputStream(), System.out)).start(); // NOSONAR - new Thread(new StreamRedirectThread(p.getErrorStream(), System.err)).start(); // NOSONAR + new Thread(new StreamRedirector(p.getInputStream(), System.out)).start(); // NOSONAR + new Thread(new StreamRedirector(p.getErrorStream(), System.err)).start(); // NOSONAR } catch (IOException e1) { LOG.error("Notification failed", e1); } diff --git a/client/src/main/java/ctbrec/ui/ExternalBrowser.java b/client/src/main/java/ctbrec/ui/ExternalBrowser.java index 80d56b1d..ab1580b0 100644 --- a/client/src/main/java/ctbrec/ui/ExternalBrowser.java +++ b/client/src/main/java/ctbrec/ui/ExternalBrowser.java @@ -1,11 +1,15 @@ package ctbrec.ui; +import static java.nio.charset.StandardCharsets.*; + import java.io.BufferedReader; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.Socket; +import java.util.Arrays; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; @@ -17,7 +21,7 @@ import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.OS; import ctbrec.Settings.ProxyType; -import ctbrec.io.StreamRedirectThread; +import ctbrec.io.StreamRedirector; public class ExternalBrowser implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(ExternalBrowser.class); @@ -47,16 +51,17 @@ public class ExternalBrowser implements AutoCloseable { addProxyConfig(jsonConfig.getJSONObject("config")); + File configDir = new File(Config.getInstance().getConfigDir(), "ctbrec-minimal-browser"); + String[] cmdline = OS.getBrowserCommand(configDir.getCanonicalPath()); + p = new ProcessBuilder(cmdline).start(); if (LOG.isTraceEnabled()) { - p = new ProcessBuilder(OS.getBrowserCommand("--enable-logging")).start(); - new StreamRedirectThread(p.getInputStream(), System.err); - new StreamRedirectThread(p.getErrorStream(), System.err); + new Thread(new StreamRedirector(p.getInputStream(), System.out)).start(); + new Thread(new StreamRedirector(p.getErrorStream(), System.err)).start(); } else { - p = new ProcessBuilder(OS.getBrowserCommand()).start(); - new StreamRedirectThread(p.getInputStream(), OutputStream.nullOutputStream()); - new StreamRedirectThread(p.getErrorStream(), OutputStream.nullOutputStream()); + new Thread(new StreamRedirector(p.getInputStream(), OutputStream.nullOutputStream())).start(); + new Thread(new StreamRedirector(p.getErrorStream(), OutputStream.nullOutputStream())).start(); } - LOG.debug("Browser started"); + LOG.debug("Browser started: {}", Arrays.toString(cmdline)); connectToRemoteControlSocket(); while (!browserReady) { @@ -69,7 +74,7 @@ public class ExternalBrowser implements AutoCloseable { } else { LOG.debug("Connected to remote control server. Sending config"); } - out.write(jsonConfig.toString().getBytes("utf-8")); + out.write(jsonConfig.toString().getBytes(UTF_8)); out.write('\n'); out.flush(); @@ -95,6 +100,7 @@ public class ExternalBrowser implements AutoCloseable { out = socket.getOutputStream(); reader = new Thread(this::readBrowserOutput); reader.start(); + LOG.debug("Connected to control socket"); return; } catch (IOException e) { if(i == 19) { @@ -116,7 +122,7 @@ public class ExternalBrowser implements AutoCloseable { //LOG.debug("Executing JS {}", javaScript); JSONObject script = new JSONObject(); script.put("execute", javaScript); - out.write(script.toString().getBytes("utf-8")); + out.write(script.toString().getBytes(UTF_8)); out.write('\n'); out.flush(); if(javaScript.equals("quit")) { diff --git a/client/src/main/java/ctbrec/ui/JavaFxModel.java b/client/src/main/java/ctbrec/ui/JavaFxModel.java index 21885b73..3902f842 100644 --- a/client/src/main/java/ctbrec/ui/JavaFxModel.java +++ b/client/src/main/java/ctbrec/ui/JavaFxModel.java @@ -307,4 +307,9 @@ public class JavaFxModel implements Model { public void setRecordUntilSubsequentAction(SubsequentAction action) { delegate.setRecordUntilSubsequentAction(action); } + + @Override + public boolean exists() throws IOException { + return delegate.exists(); + } } diff --git a/client/src/main/java/ctbrec/ui/PauseIcon.java b/client/src/main/java/ctbrec/ui/PauseIcon.java new file mode 100644 index 00000000..30a1ff24 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/PauseIcon.java @@ -0,0 +1,22 @@ +package ctbrec.ui; + + +import javafx.scene.paint.Color; +import javafx.scene.shape.Polygon; + +public class PauseIcon extends Polygon { + + public PauseIcon(Color color, int size) { + super( + 0, size, + 0, 0, + (size * 2.0 / 5.0), 0, + (size * 2.0 / 5.0), size, + (size * 3.0 / 5.0), size, + (size * 3.0 / 5.0), 0, + size, 0, + size, size + ); + setFill(color); + } +} diff --git a/client/src/main/java/ctbrec/ui/PauseIndicator.java b/client/src/main/java/ctbrec/ui/PauseIndicator.java deleted file mode 100644 index f716b871..00000000 --- a/client/src/main/java/ctbrec/ui/PauseIndicator.java +++ /dev/null @@ -1,18 +0,0 @@ -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/client/src/main/java/ctbrec/ui/Player.java b/client/src/main/java/ctbrec/ui/Player.java index abf5b024..d6653a9b 100644 --- a/client/src/main/java/ctbrec/ui/Player.java +++ b/client/src/main/java/ctbrec/ui/Player.java @@ -25,7 +25,7 @@ import ctbrec.Config; import ctbrec.Model; import ctbrec.OS; import ctbrec.Recording; -import ctbrec.io.StreamRedirectThread; +import ctbrec.io.StreamRedirector; import ctbrec.io.UrlUtil; import ctbrec.recorder.download.StreamSource; import ctbrec.ui.controls.Dialogs; @@ -165,12 +165,12 @@ public class Player { // create threads, which read stdout and stderr of the player process. these are needed, // because otherwise the internal buffer for these streams fill up and block the process - Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), OutputStream.nullOutputStream())); + Thread std = new Thread(new StreamRedirector(playerProcess.getInputStream(), OutputStream.nullOutputStream())); //Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), System.out)); std.setName("Player stdout pipe"); std.setDaemon(true); std.start(); - Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), OutputStream.nullOutputStream())); + Thread err = new Thread(new StreamRedirector(playerProcess.getErrorStream(), OutputStream.nullOutputStream())); //Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), System.err)); err.setName("Player stderr pipe"); err.setDaemon(true); diff --git a/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java b/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java new file mode 100644 index 00000000..e9f3b188 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java @@ -0,0 +1,71 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.scene.control.Button; + +public class CheckModelAccountAction { + private static final Logger LOG = LoggerFactory.getLogger(CheckModelAccountAction.class); + + private Button b; + + private Recorder recorder; + + public CheckModelAccountAction(Button b, Recorder recorder) { + this.b = b; + this.recorder = recorder; + + } + + public void execute() { + String buttonText = b.getText(); + b.setDisable(true); + Runnable checker = (() -> { + List deletedAccounts = new ArrayList<>(); + try { + List models = recorder.getModels(); + int total = models.size(); + for (int i = 0; i < total; i++) { + final int counter = i+1; + Platform.runLater(() -> b.setText(buttonText + ' ' + counter + '/' + total)); + Model modelToCheck = models.get(i); + try { + if (!modelToCheck.exists()) { + deletedAccounts.add(modelToCheck); + } + } catch (IOException e) { + LOG.warn("Couldn't check, if model account still exists", e); + } + } + } finally { + Platform.runLater(() -> { + b.setDisable(false); + b.setText(buttonText); + if (!deletedAccounts.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (Model deletedModel : deletedAccounts) { + String name = deletedModel.getDisplayName() + " ".repeat(30); + name = name.substring(0, 30); + sb.append(name).append(' ').append('(').append(deletedModel.getUrl()).append(')').append('\n'); + } + boolean remove = Dialogs.showConfirmDialog("Deleted Accounts", sb.toString(), + "The following accounts seem to have been deleted. Do you want to remove them?", b.getScene()); + if (remove) { + new StopRecordingAction(b, deletedAccounts, recorder).execute(); + } + } + }); + } + }); + new Thread(checker).start(); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/Dialogs.java b/client/src/main/java/ctbrec/ui/controls/Dialogs.java index 886e33dd..f9e3d78f 100644 --- a/client/src/main/java/ctbrec/ui/controls/Dialogs.java +++ b/client/src/main/java/ctbrec/ui/controls/Dialogs.java @@ -24,9 +24,15 @@ import javafx.stage.Stage; public class Dialogs { private Dialogs() {} - // TODO reduce calls to this method and use Dialogs.showError(Scene parent, String header, String text, Throwable t) instead + + private static Scene scene; + + public static void setScene(Scene scene) { + Dialogs.scene = scene; + } + public static void showError(String header, String text, Throwable t) { - showError(null, header, text, t); + showError(scene, header, text, t); } public static void showError(Scene parent, String header, String text, Throwable t) { diff --git a/client/src/main/java/ctbrec/ui/controls/FasterVerticalScrollPaneSkin.java b/client/src/main/java/ctbrec/ui/controls/FasterVerticalScrollPaneSkin.java new file mode 100644 index 00000000..fee2f219 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/FasterVerticalScrollPaneSkin.java @@ -0,0 +1,31 @@ +package ctbrec.ui.controls; + +import javafx.scene.control.ScrollPane; +import javafx.scene.control.skin.ScrollPaneSkin; +import javafx.scene.input.ScrollEvent; + +public class FasterVerticalScrollPaneSkin extends ScrollPaneSkin { + + public FasterVerticalScrollPaneSkin(final ScrollPane scrollPane) { + super(scrollPane); + + getSkinnable().addEventFilter(ScrollEvent.SCROLL, event -> { + double ratio = scrollPane.getViewportBounds().getHeight() / scrollPane.getContent().getBoundsInLocal().getHeight(); + double baseUnitIncrement = 0.15; + double unitIncrement = baseUnitIncrement * ratio * 1.25; + getVerticalScrollBar().setUnitIncrement(unitIncrement); + + if (event.getDeltaX() < 0) { + getHorizontalScrollBar().increment(); + } else if (event.getDeltaX() > 0) { + getHorizontalScrollBar().decrement(); + } + if (event.getDeltaY() < 0) { + getVerticalScrollBar().increment(); + } else if (event.getDeltaY() > 0) { + getVerticalScrollBar().decrement(); + } + event.consume(); + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/PausedIndicator.java b/client/src/main/java/ctbrec/ui/controls/PausedIndicator.java new file mode 100644 index 00000000..482d8d08 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/PausedIndicator.java @@ -0,0 +1,34 @@ +package ctbrec.ui.controls; + +import ctbrec.ui.PauseIcon; +import javafx.scene.Cursor; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import javafx.scene.shape.Rectangle; + +public class PausedIndicator extends StackPane { + + private PauseIcon pausedIcon; + private Rectangle clickPanel; + + public PausedIndicator(int size, Color color) { + setMaxSize(size, size); + + pausedIcon = new PauseIcon(color, size); + pausedIcon.setVisible(false); + clickPanel = new Rectangle(size, size); + clickPanel.setCursor(Cursor.HAND); + clickPanel.setFill(Paint.valueOf("#00000000")); + getChildren().add(pausedIcon); + getChildren().add(clickPanel); + + pausedIcon.visibleProperty().bindBidirectional(visibleProperty()); + clickPanel.onMouseClickedProperty().bindBidirectional(onMouseClickedProperty()); + + Tooltip tooltip = new Tooltip("Resume Recording"); + Tooltip.install(clickPanel, tooltip); + } +} + diff --git a/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java index 1e3fb025..3b469739 100644 --- a/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java +++ b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java @@ -221,6 +221,7 @@ public class SearchPopoverTreeList extends PopoverTreeList implements Pop this.model = null; } else { follow.setVisible(model.getSite().supportsFollow()); + follow.setDisable(!model.getSite().credentialsAvailable()); title.setVisible(true); title.setText(model.getDisplayName()); this.model = model; diff --git a/client/src/main/java/ctbrec/ui/news/NewsTab.java b/client/src/main/java/ctbrec/ui/news/NewsTab.java index fc0a2395..a5f11d8e 100644 --- a/client/src/main/java/ctbrec/ui/news/NewsTab.java +++ b/client/src/main/java/ctbrec/ui/news/NewsTab.java @@ -1,15 +1,7 @@ package ctbrec.ui.news; -import static ctbrec.io.HttpConstants.*; - -import java.io.IOException; -import java.util.Objects; - -import org.json.JSONObject; - import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; - import ctbrec.io.HttpException; import ctbrec.ui.CamrecApplication; import ctbrec.ui.controls.Dialogs; @@ -22,10 +14,15 @@ import javafx.scene.control.Tab; import javafx.scene.layout.VBox; import okhttp3.Request; import okhttp3.Response; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.Objects; + +import static ctbrec.io.HttpConstants.USER_AGENT; public class NewsTab extends Tab implements TabSelectionListener { private static final String ACCESS_TOKEN = "a2804d73a89951a22e0f8483a6fcec8943afd88b7ba17c459c095aa9e6f94fd0"; - //private static final String URL = "https://mastodon.cloud/api/v1/timelines/home?limit=50"; private static final String URL = "https://mastodon.cloud/api/v1/accounts/480960/statuses?limit=20&exclude_replies=true"; private VBox layout = new VBox(); @@ -64,7 +61,7 @@ public class NewsTab extends Tab implements TabSelectionListener { } } } catch (IOException e) { - Dialogs.showError("News", "Couldn't load news from mastodon", e); + Dialogs.showError(getTabPane().getScene(), "News", "Couldn't load news from mastodon", e); } } diff --git a/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java b/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java index b3bb8180..3c84aa54 100644 --- a/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java +++ b/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java @@ -28,6 +28,7 @@ import javafx.beans.property.ListProperty; import javafx.beans.property.LongProperty; import javafx.beans.property.Property; import javafx.beans.property.StringProperty; +import javafx.beans.value.ChangeListener; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.control.CheckBox; @@ -47,12 +48,17 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { private Config config; private Settings settings; + private Preferences prefs; public CtbrecPreferencesStorage(Config config) { this.config = config; this.settings = config.getSettings(); } + public void setPreferences(Preferences prefs) { + this.prefs = prefs; + } + @Override public void save(Preferences preferences) throws IOException { throw new RuntimeException("not implemented"); @@ -102,6 +108,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { prop.addListener((obs, oldV, newV) -> saveValue(() -> { Field field = Settings.class.getField(setting.getKey()); field.set(settings, newV); + if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) { + prefs.getRestartRequiredCallback().run(); + } config.save(); })); HBox row = new HBox(); @@ -142,7 +151,7 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { private int getRangeSliderValue(List values, List labels, int value) { for (int i = 0; i < labels.size(); i++) { int label = labels.get(i).intValue(); - if(label == value) { + if (label == value) { return values.get(i); } } @@ -157,6 +166,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { String oldValue = (String) field.get(settings); if (!Objects.equals(path, oldValue)) { field.set(settings, path); + if (setting.doesNeedRestart()) { + prefs.getRestartRequiredCallback().run(); + } config.save(); } })); @@ -174,6 +186,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { String oldValue = (String) field.get(settings); if (!Objects.equals(path, oldValue)) { field.set(settings, path); + if (setting.doesNeedRestart()) { + prefs.getRestartRequiredCallback().run(); + } config.save(); } })); @@ -187,6 +202,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { ctrl.textProperty().addListener((obs, oldV, newV) -> saveValue(() -> { Field field = Settings.class.getField(setting.getKey()); field.set(settings, newV); + if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) { + prefs.getRestartRequiredCallback().run(); + } config.save(); })); StringProperty prop = (StringProperty) setting.getProperty(); @@ -204,6 +222,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { if (!ctrl.getText().isEmpty()) { Field field = Settings.class.getField(setting.getKey()); field.set(settings, Integer.parseInt(ctrl.getText())); + if (setting.doesNeedRestart() && !Objects.equals(oldV, newV) && prefs != null) { + prefs.getRestartRequiredCallback().run(); + } config.save(); } })); @@ -226,6 +247,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { } Field field = Settings.class.getField(setting.getKey()); field.set(settings, value); + if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) { + prefs.getRestartRequiredCallback().run(); + } config.save(); } })); @@ -239,6 +263,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { ctrl.selectedProperty().addListener((obs, oldV, newV) -> saveValue(() -> { Field field = Settings.class.getField(setting.getKey()); field.set(settings, newV); + if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) { + prefs.getRestartRequiredCallback().run(); + } config.save(); })); BooleanProperty prop = (BooleanProperty) setting.getProperty(); @@ -265,8 +292,14 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { } else { field.set(settings, newV); } + if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) { + prefs.getRestartRequiredCallback().run(); + } config.save(); })); + if (setting.getChangeListener() != null) { + comboBox.valueProperty().addListener((ChangeListener) setting.getChangeListener()); + } return comboBox; } diff --git a/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java b/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java index a0649f3f..dd6e8bb9 100644 --- a/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java +++ b/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java @@ -1,25 +1,18 @@ package ctbrec.ui.settings; +import ctbrec.recorder.postprocessing.*; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.settings.api.Preferences; +import javafx.collections.ObservableList; +import javafx.scene.Scene; +import javafx.scene.layout.Region; + import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.util.HashMap; import java.util.Map; import java.util.Optional; -import ctbrec.Config; -import ctbrec.recorder.postprocessing.CreateContactSheet; -import ctbrec.recorder.postprocessing.DeleteTooShort; -import ctbrec.recorder.postprocessing.Move; -import ctbrec.recorder.postprocessing.PostProcessor; -import ctbrec.recorder.postprocessing.Remux; -import ctbrec.recorder.postprocessing.Rename; -import ctbrec.recorder.postprocessing.Script; -import ctbrec.ui.controls.Dialogs; -import ctbrec.ui.settings.api.Preferences; -import javafx.collections.ObservableList; -import javafx.scene.Scene; -import javafx.scene.layout.Region; - public class PostProcessingDialogFactory { static Map, Class> ppToDialogMap = new HashMap<>(); @@ -35,19 +28,19 @@ public class PostProcessingDialogFactory { private PostProcessingDialogFactory() { } - public static void openNewDialog(PostProcessor pp, Config config, Scene scene, ObservableList stepList) { - openDialog(pp, config, scene, stepList, true); + public static void openNewDialog(PostProcessor pp, Scene scene, ObservableList stepList) { + openDialog(pp, scene, stepList, true); } - public static void openEditDialog(PostProcessor pp, Config config, Scene scene, ObservableList stepList) { - openDialog(pp, config, scene, stepList, false); + public static void openEditDialog(PostProcessor pp, Scene scene, ObservableList stepList) { + openDialog(pp, scene, stepList, false); } - private static void openDialog(PostProcessor pp, Config config, Scene scene, ObservableList stepList, boolean newEntry) { + private static void openDialog(PostProcessor pp, Scene scene, ObservableList stepList, boolean newEntry) { boolean ok; try { Optional preferences = createPreferences(pp); - if(preferences.isPresent()) { + if (preferences.isPresent()) { Region view = preferences.get().getView(false); view.setMinWidth(600); ok = Dialogs.showCustomInput(scene, "Configure " + pp.getName(), view); @@ -62,12 +55,12 @@ public class PostProcessingDialogFactory { } } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | InstantiationException | IOException e) { - Dialogs.showError("New post-processing step", "Couldn't create dialog for " + pp.getName(), e); + Dialogs.showError(scene, "New post-processing step", "Couldn't create dialog for " + pp.getName(), e); } } - private static Optional createPreferences(PostProcessor pp) throws InstantiationException, IllegalAccessException, IllegalArgumentException, - InvocationTargetException, NoSuchMethodException, SecurityException { + private static Optional createPreferences(PostProcessor pp) throws InstantiationException, IllegalAccessException, + InvocationTargetException, NoSuchMethodException { Class paneFactoryClass = ppToDialogMap.get(pp.getClass()); if (paneFactoryClass != null) { AbstractPostProcessingPaneFactory factory = (AbstractPostProcessingPaneFactory) paneFactoryClass.getDeclaredConstructor().newInstance(); diff --git a/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java b/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java index 6e75ccd3..e5cae35f 100644 --- a/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java +++ b/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java @@ -1,21 +1,7 @@ package ctbrec.ui.settings; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.InvocationTargetException; -import java.util.Optional; - import ctbrec.Config; -import ctbrec.recorder.postprocessing.Copy; -import ctbrec.recorder.postprocessing.CreateContactSheet; -import ctbrec.recorder.postprocessing.DeleteOriginal; -import ctbrec.recorder.postprocessing.DeleteTooShort; -import ctbrec.recorder.postprocessing.Move; -import ctbrec.recorder.postprocessing.PostProcessor; -import ctbrec.recorder.postprocessing.RemoveKeepFile; -import ctbrec.recorder.postprocessing.Remux; -import ctbrec.recorder.postprocessing.Rename; -import ctbrec.recorder.postprocessing.Script; +import ctbrec.recorder.postprocessing.*; import ctbrec.ui.controls.Dialogs; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; @@ -30,21 +16,25 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import javafx.stage.Stage; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; + public class PostProcessingStepPanel extends GridPane { - private Config config; + private final Config config; - - private static final Class[] POST_PROCESSOR_CLASSES = new Class[] { // @formatter: off - Copy.class, - Rename.class, - Move.class, - Remux.class, - Script.class, - DeleteOriginal.class, - DeleteTooShort.class, - RemoveKeepFile.class, - CreateContactSheet.class + private static final Class[] POST_PROCESSOR_CLASSES = new Class[]{ // @formatter: off + Copy.class, + Rename.class, + Move.class, + Remux.class, + Script.class, + DeleteOriginal.class, + DeleteTooShort.class, + RemoveKeepFile.class, + CreateContactSheet.class }; // @formatter: on ListView stepListView; @@ -98,33 +88,33 @@ public class PostProcessingStepPanel extends GridPane { } private Button createUpButton() { - Button up = createButton("\u25B4", "Move step up"); - up.setOnAction(evt -> { + Button button = createButton("\u25B4", "Move step up"); + button.setOnAction(evt -> { int idx = stepListView.getSelectionModel().getSelectedIndex(); PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); stepList.remove(idx); stepList.add(idx - 1, selectedItem); stepListView.getSelectionModel().select(idx - 1); }); - return up; + return button; } private Button createDownButton() { - Button down = createButton("\u25BE", "Move step down"); - down.setOnAction(evt -> { + Button button = createButton("\u25BE", "Move step down"); + button.setOnAction(evt -> { int idx = stepListView.getSelectionModel().getSelectedIndex(); PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); stepList.remove(idx); stepList.add(idx + 1, selectedItem); stepListView.getSelectionModel().select(idx + 1); }); - return down; + return button; } private Button createAddButton() { - Button add = createButton("+", "Add a new step"); - add.setDisable(false); - add.setOnAction(evt -> { + Button button = createButton("+", "Add a new step"); + button.setDisable(false); + button.setOnAction(evt -> { PostProcessor[] options = createOptions(); ChoiceDialog choice = new ChoiceDialog<>(options[0], options); choice.setTitle("New Post-Processing Step"); @@ -138,17 +128,17 @@ public class PostProcessingStepPanel extends GridPane { stage.getIcons().add(new Image(icon)); Optional result = choice.showAndWait(); - result.ifPresent(pp -> PostProcessingDialogFactory.openNewDialog(pp, config, getScene(), stepList)); + result.ifPresent(pp -> PostProcessingDialogFactory.openNewDialog(pp, getScene(), stepList)); saveConfig(); }); - return add; + return button; } private void saveConfig() { try { config.save(); } catch (IOException e) { - Dialogs.showError("Post-Processing", "Couldn't save post-processing step", e); + Dialogs.showError(getScene(), "Post-Processing", "Couldn't save post-processing step", e); } } @@ -170,25 +160,25 @@ public class PostProcessingStepPanel extends GridPane { } private Button createRemoveButton() { - Button remove = createButton("-", "Remove selected step"); - remove.setOnAction(evt -> { + Button button = createButton("-", "Remove selected step"); + button.setOnAction(evt -> { PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); if (selectedItem != null) { stepList.remove(selectedItem); } }); - return remove; + return button; } private Button createEditButton() { - Button edit = createButton("\u270E", "Edit selected step"); - edit.setOnAction(evt -> { + Button button = createButton("\u270E", "Edit selected step"); + button.setOnAction(evt -> { PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); - PostProcessingDialogFactory.openEditDialog(selectedItem, config, getScene(), stepList); + PostProcessingDialogFactory.openEditDialog(selectedItem, getScene(), stepList); stepListView.refresh(); saveConfig(); }); - return edit; + return button; } private Button createButton(String text, String tooltip) { diff --git a/client/src/main/java/ctbrec/ui/settings/ProxySettingsPane.java b/client/src/main/java/ctbrec/ui/settings/ProxySettingsPane.java deleted file mode 100644 index c7de4c8e..00000000 --- a/client/src/main/java/ctbrec/ui/settings/ProxySettingsPane.java +++ /dev/null @@ -1,110 +0,0 @@ -package ctbrec.ui.settings; -import static ctbrec.Settings.ProxyType.*; - -import java.util.ArrayList; -import java.util.List; - -import ctbrec.Config; -import ctbrec.Settings.ProxyType; -import javafx.collections.FXCollections; -import javafx.event.ActionEvent; -import javafx.event.EventHandler; -import javafx.scene.control.ComboBox; -import javafx.scene.control.Label; -import javafx.scene.control.PasswordField; -import javafx.scene.control.TextField; -import javafx.scene.control.TitledPane; -import javafx.scene.layout.GridPane; - -public class ProxySettingsPane extends TitledPane implements EventHandler { - - private ComboBox proxyType; - private TextField proxyHost = new TextField(); - private TextField proxyPort = new TextField(); - private TextField proxyUser = new TextField(); - private PasswordField proxyPassword = new PasswordField(); - private SettingsTab settingsTab; - - public ProxySettingsPane(SettingsTab settingsTab) { - this.settingsTab = settingsTab; - createGui(); - loadConfig(); - } - - private void createGui() { - setText("Proxy"); - setCollapsible(false); - GridPane layout = SettingsTab.createGridLayout(); - setContent(layout); - - Label l = new Label("Type"); - layout.add(l, 0, 0); - List proxyTypes = new ArrayList<>(); - proxyTypes.add(DIRECT); - proxyTypes.add(HTTP); - proxyTypes.add(SOCKS4); - proxyTypes.add(SOCKS5); - proxyType = new ComboBox<>(FXCollections.observableList(proxyTypes)); - proxyType.setOnAction(this); - layout.add(proxyType, 1, 0); - - l = new Label("Host"); - layout.add(l, 0, 1); - layout.add(proxyHost, 1, 1); - proxyHost.textProperty().addListener((ob, o, n) -> settingsTab.saveConfig()); - - l = new Label("Port"); - layout.add(l, 0, 2); - layout.add(proxyPort, 1, 2); - proxyPort.textProperty().addListener((ob, o, n) -> settingsTab.saveConfig()); - - l = new Label("Username"); - layout.add(l, 0, 3); - layout.add(proxyUser, 1, 3); - proxyUser.textProperty().addListener((ob, o, n) -> settingsTab.saveConfig()); - - - l = new Label("Password"); - layout.add(l, 0, 4); - layout.add(proxyPassword, 1, 4); - proxyPassword.textProperty().addListener((ob, o, n) -> settingsTab.saveConfig()); - } - - private void loadConfig() { - proxyType.valueProperty().set(Config.getInstance().getSettings().proxyType); - proxyHost.setText(Config.getInstance().getSettings().proxyHost); - proxyPort.setText(Config.getInstance().getSettings().proxyPort); - proxyUser.setText(Config.getInstance().getSettings().proxyUser); - proxyPassword.setText(Config.getInstance().getSettings().proxyPassword); - setComponentDisableState(); - } - - void saveConfig() { - Config.getInstance().getSettings().proxyType = proxyType.getValue(); - Config.getInstance().getSettings().proxyHost = proxyHost.getText(); - Config.getInstance().getSettings().proxyPort = proxyPort.getText(); - Config.getInstance().getSettings().proxyUser = proxyUser.getText(); - Config.getInstance().getSettings().proxyPassword = proxyPassword.getText(); - } - - @Override - public void handle(ActionEvent event) { - setComponentDisableState(); - settingsTab.showRestartRequired(); - settingsTab.saveConfig(); - } - - private void setComponentDisableState() { - if(proxyType.getValue() == DIRECT) { - proxyHost.setDisable(true); - proxyPort.setDisable(true); - proxyUser.setDisable(true); - proxyPassword.setDisable(true); - } else { - proxyHost.setDisable(false); - proxyPort.setDisable(false); - proxyUser.setDisable(proxyType.getValue() == SOCKS4); - proxyPassword.setDisable(proxyType.getValue() == SOCKS4); - } - } -} diff --git a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java index 4d2c2e51..a7566dfd 100644 --- a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java +++ b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java @@ -2,6 +2,7 @@ package ctbrec.ui.settings; import static ctbrec.Settings.DirectoryStructure.*; import static ctbrec.Settings.ProxyType.*; +import static ctbrec.Settings.SplitStrategy.*; import static java.util.Optional.*; import java.io.IOException; @@ -36,6 +37,9 @@ import ctbrec.ui.settings.api.SimpleRangeProperty; import ctbrec.ui.settings.api.ValueConverter; import ctbrec.ui.sites.ConfigUI; import ctbrec.ui.tabs.TabSelectionListener; +import javafx.animation.FadeTransition; +import javafx.animation.PauseTransition; +import javafx.animation.Transition; import javafx.beans.binding.BooleanExpression; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleIntegerProperty; @@ -45,18 +49,33 @@ import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; import javafx.scene.control.Button; +import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.control.Tab; import javafx.scene.control.TextInputDialog; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.Border; +import javafx.scene.layout.BorderStroke; +import javafx.scene.layout.BorderStrokeStyle; +import javafx.scene.layout.BorderWidths; +import javafx.scene.layout.CornerRadii; import javafx.scene.layout.GridPane; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.util.Duration; public class SettingsTab extends Tab implements TabSelectionListener { private static final Logger LOG = LoggerFactory.getLogger(SettingsTab.class); public static final int CHECKBOX_MARGIN = 6; + private static final long MiB = 1024 * 1024L; + private static final long GiB = 1024 * MiB; private List sites; private Recorder recorder; @@ -71,6 +90,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { private SimpleBooleanProperty determineResolution; private SimpleBooleanProperty chooseStreamQuality; private SimpleBooleanProperty livePreviews; + private SimpleBooleanProperty monitorClipboard; private SimpleListProperty startTab; private SimpleFileProperty mediaPlayer; private SimpleStringProperty mediaPlayerParams; @@ -85,6 +105,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { private SimpleDirectoryProperty recordingsDir; private SimpleListProperty directoryStructure; private SimpleListProperty splitAfter; + private SimpleListProperty splitBiggerThan; private SimpleRangeProperty resolutionRange; private List labels = Arrays.asList(0, 240, 360, 480, 600, 720, 960, 1080, 1440, 2160, 4320, 8640); private List values = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11); @@ -94,6 +115,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { private SimpleBooleanProperty onlineCheckSkipsPausedModels; private SimpleLongProperty leaveSpaceOnDevice; private SimpleStringProperty ffmpegParameters; + private SimpleBooleanProperty logFFmpegOutput; private SimpleStringProperty fileExtension; private SimpleStringProperty server; private SimpleIntegerProperty port; @@ -104,8 +126,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { private ExclusiveSelectionProperty recordLocal; private SimpleIntegerProperty postProcessingThreads; private IgnoreList ignoreList; - private PostProcessingStepPanel postProcessingStepPanel; - private Button variablesHelpButton; + private Label restartNotification; public SettingsTab(List sites, Recorder recorder) { this.sites = sites; @@ -124,6 +145,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { determineResolution = new SimpleBooleanProperty(null, "determineResolution", settings.determineResolution); chooseStreamQuality = new SimpleBooleanProperty(null, "chooseStreamQuality", settings.chooseStreamQuality); livePreviews = new SimpleBooleanProperty(null, "livePreviews", settings.livePreviews); + monitorClipboard = new SimpleBooleanProperty(null, "monitorClipboard", settings.monitorClipboard); startTab = new SimpleListProperty<>(null, "startTab", FXCollections.observableList(getTabNames())); mediaPlayer = new SimpleFileProperty(null, "mediaPlayer", settings.mediaPlayer); mediaPlayerParams = new SimpleStringProperty(null, "mediaPlayerParams", settings.mediaPlayerParams); @@ -137,12 +159,14 @@ public class SettingsTab extends Tab implements TabSelectionListener { proxyPassword = new SimpleStringProperty(null, "proxyPassword", settings.proxyPassword); recordingsDir = new SimpleDirectoryProperty(null, "recordingsDir", settings.recordingsDir); directoryStructure = new SimpleListProperty<>(null, "recordingsDirStructure", FXCollections.observableList(List.of(FLAT, ONE_PER_MODEL, ONE_PER_RECORDING))); - splitAfter = new SimpleListProperty<>(null, "splitRecordings", FXCollections.observableList(getSplitOptions())); + splitAfter = new SimpleListProperty<>(null, "splitRecordingsAfterSecs", FXCollections.observableList(getSplitAfterSecsOptions())); + splitBiggerThan = new SimpleListProperty<>(null, "splitRecordingsBiggerThanBytes", FXCollections.observableList(getSplitBiggerThanOptions())); resolutionRange = new SimpleRangeProperty<>(rangeValues, "minimumResolution", "maximumResolution", settings.minimumResolution, settings.maximumResolution); concurrentRecordings = new SimpleIntegerProperty(null, "concurrentRecordings", settings.concurrentRecordings); onlineCheckIntervalInSecs = new SimpleIntegerProperty(null, "onlineCheckIntervalInSecs", settings.onlineCheckIntervalInSecs); leaveSpaceOnDevice = new SimpleLongProperty(null, "minimumSpaceLeftInBytes", (long) new GigabytesConverter().convertTo(settings.minimumSpaceLeftInBytes)); ffmpegParameters = new SimpleStringProperty(null, "ffmpegMergedDownloadArgs", settings.ffmpegMergedDownloadArgs); + logFFmpegOutput = new SimpleBooleanProperty(null, "logFFmpegOutput", settings.logFFmpegOutput); fileExtension = new SimpleStringProperty(null, "ffmpegFileSuffix", settings.ffmpegFileSuffix); server = new SimpleStringProperty(null, "httpServer", settings.httpServer); port = new SimpleIntegerProperty(null, "httpPort", settings.httpPort); @@ -157,8 +181,8 @@ public class SettingsTab extends Tab implements TabSelectionListener { } private void createGui() { - postProcessingStepPanel = new PostProcessingStepPanel(config); - variablesHelpButton = createHelpButton("Variables", "http://localhost:5689/docs/PostProcessing.md#variables"); + PostProcessingStepPanel postProcessingStepPanel = new PostProcessingStepPanel(config); + Button variablesHelpButton = createHelpButton("Variables", "http://localhost:5689/docs/PostProcessing.md#variables"); ignoreList = new IgnoreList(sites); List siteCategories = new ArrayList<>(); for (Site site : sites) { @@ -166,18 +190,20 @@ public class SettingsTab extends Tab implements TabSelectionListener { .ifPresent(configPanel -> siteCategories.add(Category.of(site.getName(), configPanel))); } - Preferences prefs = Preferences.of(new CtbrecPreferencesStorage(config), + CtbrecPreferencesStorage storage = new CtbrecPreferencesStorage(config); + Preferences prefs = Preferences.of(storage, Category.of("General", Group.of("General", Setting.of("User-Agent", httpUserAgent), Setting.of("User-Agent mobile", httpUserAgentMobile), - Setting.of("Update overview interval (seconds)", overviewUpdateIntervalInSecs, "Update the thumbnail overviews every x seconds"), + Setting.of("Update overview interval (seconds)", overviewUpdateIntervalInSecs, "Update the thumbnail overviews every x seconds").needsRestart(), Setting.of("Update thumbnails", updateThumbnails, "The overviews will still be updated, but the thumbnails won't be changed. This is useful for less powerful systems."), Setting.of("Display stream resolution in overview", determineResolution), Setting.of("Manually select stream quality", chooseStreamQuality, "Opens a dialog to select the video resolution before recording"), Setting.of("Enable live previews (experimental)", livePreviews), + Setting.of("Add models from clipboard", monitorClipboard, "Monitor clipboard for model URLs and automatically add them to the recorder").needsRestart(), Setting.of("Start Tab", startTab), - Setting.of("Colors (Base / Accent)", new ColorSettingsPane(Config.getInstance())) + Setting.of("Colors (Base / Accent)", new ColorSettingsPane(Config.getInstance())).needsRestart() ), Group.of("Player", Setting.of("Player", mediaPlayer), @@ -191,7 +217,8 @@ public class SettingsTab extends Tab implements TabSelectionListener { Group.of("Settings", Setting.of("Recordings Directory", recordingsDir), Setting.of("Directory Structure", directoryStructure), - Setting.of("Split recordings after (minutes)", splitAfter).converter(SplitAfterOption.converter()), + Setting.of("Split recordings after", splitAfter).converter(SplitAfterOption.converter()).onChange(this::splitValuesChanged), + Setting.of("Split recordings bigger than", splitBiggerThan).converter(SplitBiggerThanOption.converter()).onChange(this::splitValuesChanged), Setting.of("Restrict Resolution", resolutionRange, "Only record streams with resolution within the given range"), Setting.of("Concurrent Recordings (0 = unlimited)", concurrentRecordings), Setting.of("Leave space on device (GiB)", leaveSpaceOnDevice, "Stop recording, if the free space on the device gets below this threshold").converter(new GigabytesConverter()), @@ -201,7 +228,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { Setting.of("Skip online check for paused models", onlineCheckSkipsPausedModels, "Skip online check for paused models") ), Group.of("Location", - Setting.of("Record Location", recordLocal), + Setting.of("Record Location", recordLocal).needsRestart(), Setting.of("Server", server), Setting.of("Port", port), Setting.of("Path", path, "Leave empty, if you didn't change the servletContext in the server config"), @@ -223,15 +250,22 @@ public class SettingsTab extends Tab implements TabSelectionListener { Category.of("Sites", siteCategories.toArray(new Category[0])), Category.of("Proxy", Group.of("Proxy", - Setting.of("Type", proxyType), - Setting.of("Host", proxyHost), - Setting.of("Port", proxyPort), - Setting.of("Username", proxyUser), - Setting.of("Password", proxyPassword) + Setting.of("Type", proxyType).needsRestart(), + Setting.of("Host", proxyHost).needsRestart(), + Setting.of("Port", proxyPort).needsRestart(), + Setting.of("Username", proxyUser).needsRestart(), + Setting.of("Password", proxyPassword).needsRestart() + ) + ), + Category.of("Advanced / Devtools", + Group.of("Logging", + Setting.of("Log FFmpeg output", logFFmpegOutput, "Log FFmpeg output to files in the system's temp directory") ) ) ); Region preferencesView = prefs.getView(); + prefs.onRestartRequired(this::showRestartRequired); + storage.setPreferences(prefs); preferencesView.setMinSize(800, 400); preferencesView.setPrefSize(1280, 960); ScrollPane scrollPane = new ScrollPane(preferencesView); @@ -241,7 +275,20 @@ public class SettingsTab extends Tab implements TabSelectionListener { GridPane.setFillHeight(scrollPane, true); GridPane.setHgrow(scrollPane, Priority.ALWAYS); GridPane.setVgrow(scrollPane, Priority.ALWAYS); - setContent(container); + + StackPane stackPane = new StackPane(); + stackPane.getChildren().add(container); + restartNotification = new Label("Restart Required"); + restartNotification.setVisible(false); + restartNotification.setOpacity(0); + restartNotification.setStyle("-fx-font-size: 28; -fx-padding: .3em"); + restartNotification.setBorder(new Border(new BorderStroke(Color.web(settings.colorAccent), BorderStrokeStyle.SOLID, new CornerRadii(5), new BorderWidths(2)))); + restartNotification.setBackground(new Background(new BackgroundFill(Color.web(settings.colorBase), new CornerRadii(5), Insets.EMPTY))); + stackPane.getChildren().add(restartNotification); + StackPane.setAlignment(restartNotification, Pos.TOP_RIGHT); + StackPane.setMargin(restartNotification, new Insets(10, 40, 0, 0)); + + setContent(stackPane); prefs.expandTree(); @@ -251,7 +298,8 @@ public class SettingsTab extends Tab implements TabSelectionListener { prefs.getSetting("requireAuthentication").ifPresent(s -> bindEnabledProperty(s, recordLocal)); prefs.getSetting("transportLayerSecurity").ifPresent(s -> bindEnabledProperty(s, recordLocal)); prefs.getSetting("recordingsDir").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); - prefs.getSetting("splitRecordings").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("splitRecordingsAfterSecs").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("splitRecordingsBiggerThanBytes").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); prefs.getSetting("minimumResolution").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); prefs.getSetting("recordingsDirStructure").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); prefs.getSetting("onlineCheckIntervalInSecs").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); @@ -266,6 +314,23 @@ public class SettingsTab extends Tab implements TabSelectionListener { prefs.getSetting("downloadFilename").ifPresent(s -> bindEnabledProperty(s, recordLocal)); postProcessingStepPanel.disableProperty().bind(recordLocal.not()); variablesHelpButton.disableProperty().bind(recordLocal); + + + } + + private void splitValuesChanged(ObservableValue value, Object oldV, Object newV) { + boolean splitAfterSet = settings.splitRecordingsAfterSecs > 0; + boolean splitBiggerThanSet = settings.splitRecordingsBiggerThanBytes > 0; + if (splitAfterSet && splitBiggerThanSet) { + settings.splitStrategy = TIME_OR_SIZE; + } else if (splitAfterSet) { + settings.splitStrategy = TIME; + } else if (splitBiggerThanSet) { + settings.splitStrategy = SIZE; + } else { + settings.splitStrategy = DONT; + } + saveConfig(); } private Button createHelpButton(String text, String url) { @@ -288,7 +353,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { .collect(Collectors.toList()); } - private List getSplitOptions() { + private List getSplitAfterSecsOptions() { List splitOptions = new ArrayList<>(); splitOptions.add(new SplitAfterOption("disabled", 0)); if (Config.isDevMode()) { @@ -304,6 +369,29 @@ public class SettingsTab extends Tab implements TabSelectionListener { return splitOptions; } + private List getSplitBiggerThanOptions() { + List splitOptions = new ArrayList<>(); + splitOptions.add(new SplitBiggerThanOption("disabled", 0)); + if (Config.isDevMode()) { + splitOptions.add(new SplitBiggerThanOption("10 MiB", 10 * MiB)); + splitOptions.add(new SplitBiggerThanOption("20 MiB", 20 * MiB)); + } + splitOptions.add(new SplitBiggerThanOption("100 MiB", 100 * MiB)); + splitOptions.add(new SplitBiggerThanOption("250 MiB", 250 * MiB)); + splitOptions.add(new SplitBiggerThanOption("500 MiB", 500 * MiB)); + splitOptions.add(new SplitBiggerThanOption("1 GiB", 1 * GiB)); + splitOptions.add(new SplitBiggerThanOption("2 GiB", 2 * GiB)); + splitOptions.add(new SplitBiggerThanOption("3 GiB", 3 * GiB)); + splitOptions.add(new SplitBiggerThanOption("4 GiB", 4 * GiB)); + splitOptions.add(new SplitBiggerThanOption("5 GiB", 5 * GiB)); + splitOptions.add(new SplitBiggerThanOption("6 GiB", 6 * GiB)); + splitOptions.add(new SplitBiggerThanOption("7 GiB", 7 * GiB)); + splitOptions.add(new SplitBiggerThanOption("8 GiB", 8 * GiB)); + splitOptions.add(new SplitBiggerThanOption("9 GiB", 9 * GiB)); + splitOptions.add(new SplitBiggerThanOption("10 GiB", 10 * GiB)); + return splitOptions; + } + private void requireAuthenticationChanged(ObservableValue obs, Boolean oldV, Boolean newV) { // NOSONAR boolean requiresAuthentication = newV; Config.getInstance().getSettings().requireAuthentication = requiresAuthentication; @@ -359,7 +447,26 @@ public class SettingsTab extends Tab implements TabSelectionListener { } void showRestartRequired() { - // TODO restartLabel.setVisible(true); + if (!restartNotification.isVisible()) { + restartNotification.setVisible(true); + Transition fadeIn = changeOpacity(restartNotification, 1); + fadeIn.play(); + fadeIn.setOnFinished(e -> { + Transition fadeOut = changeOpacity(restartNotification, 0); + fadeOut.setOnFinished(e2 -> restartNotification.setVisible(false)); + PauseTransition pauseTransition = new PauseTransition(Duration.seconds(5)); + pauseTransition.setOnFinished(evt -> fadeOut.play()); + pauseTransition.play(); + }); + } + } + + private static final Duration ANIMATION_DURATION = new Duration(500); + private Transition changeOpacity(Node node, double opacity) { + FadeTransition transition = new FadeTransition(ANIMATION_DURATION, node); + transition.setFromValue(node.getOpacity()); + transition.setToValue(opacity); + return transition; } public static class SplitAfterOption { @@ -415,4 +522,60 @@ public class SettingsTab extends Tab implements TabSelectionListener { }; } } + + public static class SplitBiggerThanOption { + private String label; + private long value; + + public SplitBiggerThanOption(String label, long value) { + super(); + this.label = label; + this.value = value; + } + + public long getValue() { + return value; + } + + @Override + public String toString() { + return label; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + (int) (value ^ (value >>> 32)); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + SplitBiggerThanOption other = (SplitBiggerThanOption) obj; + if (value != other.value) + return false; + return true; + } + + public static ValueConverter converter() { + return new ValueConverter() { + @Override + public Long convertFrom(Object splitBiggerThanOption) { + return ((SplitBiggerThanOption) splitBiggerThanOption).getValue(); + } + + @Override + public SplitBiggerThanOption convertTo(Object value) { + return new SplitBiggerThanOption(value.toString(), (Long) value); + } + }; + } + } } diff --git a/client/src/main/java/ctbrec/ui/settings/api/GigabytesConverter.java b/client/src/main/java/ctbrec/ui/settings/api/GigabytesConverter.java index f007e20d..52b87310 100644 --- a/client/src/main/java/ctbrec/ui/settings/api/GigabytesConverter.java +++ b/client/src/main/java/ctbrec/ui/settings/api/GigabytesConverter.java @@ -12,8 +12,8 @@ public class GigabytesConverter implements ValueConverter { @Override public Object convertFrom(Object b) { - long spaceLeftInGiB = (long) b; - return spaceLeftInGiB * ONE_GIB_IN_BYTES; + long gibiBytes = (long) b; + return gibiBytes * ONE_GIB_IN_BYTES; } } diff --git a/client/src/main/java/ctbrec/ui/settings/api/Preferences.java b/client/src/main/java/ctbrec/ui/settings/api/Preferences.java index d8c9bb7d..f5a72a66 100644 --- a/client/src/main/java/ctbrec/ui/settings/api/Preferences.java +++ b/client/src/main/java/ctbrec/ui/settings/api/Preferences.java @@ -35,6 +35,8 @@ public class Preferences { private PreferencesStorage preferencesStorage; + private Runnable restartRequiredCallback = () -> {}; + private Preferences(PreferencesStorage preferencesStorage, Category...categories) { this.preferencesStorage = preferencesStorage; this.categories = categories; @@ -248,4 +250,12 @@ public class Preferences { return result; } } + + public void onRestartRequired(Runnable callback) { + this.restartRequiredCallback = callback; + } + + public Runnable getRestartRequiredCallback() { + return restartRequiredCallback; + } } diff --git a/client/src/main/java/ctbrec/ui/settings/api/Setting.java b/client/src/main/java/ctbrec/ui/settings/api/Setting.java index 98acb0a6..d89e9de3 100644 --- a/client/src/main/java/ctbrec/ui/settings/api/Setting.java +++ b/client/src/main/java/ctbrec/ui/settings/api/Setting.java @@ -4,6 +4,7 @@ import static java.util.Optional.*; import ctbrec.StringUtil; import javafx.beans.property.Property; +import javafx.beans.value.ChangeListener; import javafx.scene.Node; import javafx.scene.control.Control; import javafx.scene.control.Tooltip; @@ -17,6 +18,7 @@ public class Setting { private PreferencesStorage preferencesStorage; private boolean needsRestart = false; private ValueConverter converter; + private ChangeListener changeListener; protected Setting(String name, Property property) { this.name = name; @@ -107,4 +109,13 @@ public class Setting { public ValueConverter getConverter() { return converter; } + + public Setting onChange(ChangeListener changeListener) { + this.changeListener = changeListener; + return this; + } + + public ChangeListener getChangeListener() { + return changeListener; + } } diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsElectronLoginDialog.java index aa496ec9..40bff63c 100644 --- a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsElectronLoginDialog.java +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsElectronLoginDialog.java @@ -67,11 +67,10 @@ public class BongaCamsElectronLoginDialog { browser.executeJavaScript("document.getElementById('log_in_password').value = '" + password + "';"); } String[] simplify = new String[] { - "$('div#header').css('display','none');", - "$('div.footer').css('display','none');", - "$('div.footer_copy').css('display','none')", - "$('div[class~=\"banner_top_index\"]').css('display','none');", - "$('td.menu_container').css('display','none');", + "$('div[class~=\"page_header\"]').css('display','none');", + "$('div[class~=\"header_bar\"]').css('display','none')", + "$('footer').css('display','none');", + "$('div[class~=\"footer_copy\"]').css('display','none')", "$('div[class~=\"fancybox-overlay\"]').css('display','none');" }; for (String js : simplify) { diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java index b2406e07..a75ab316 100644 --- a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java @@ -1,25 +1,24 @@ package ctbrec.ui.sites.bonga; -import java.io.IOException; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.sites.bonga.BongaCams; import ctbrec.sites.bonga.BongaCamsHttpClient; import ctbrec.ui.controls.Dialogs; import ctbrec.ui.sites.AbstractSiteUi; import ctbrec.ui.sites.ConfigUI; import ctbrec.ui.tabs.TabProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; public class BongaCamsSiteUi extends AbstractSiteUi { - private static final transient Logger LOG = LoggerFactory.getLogger(BongaCamsSiteUi.class); - private BongaCamsTabProvider tabProvider; - private BongaCamsConfigUI configUi; - private BongaCams bongaCams; + private static final Logger LOG = LoggerFactory.getLogger(BongaCamsSiteUi.class); + private final BongaCamsTabProvider tabProvider; + private final BongaCamsConfigUI configUi; + private final BongaCams bongaCams; public BongaCamsSiteUi(BongaCams bongaCams) { this.bongaCams = bongaCams; @@ -57,11 +56,13 @@ public class BongaCamsSiteUi extends AbstractSiteUi { try { queue.put(true); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); LOG.error("Error while signaling termination", e); } }).start(); queue.take(); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); LOG.error("Error while waiting for login dialog to close", e); throw new IOException(e); } diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsTabProvider.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsTabProvider.java index 399a1efe..8feacf7c 100644 --- a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsTabProvider.java +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsTabProvider.java @@ -47,7 +47,7 @@ public class BongaCamsTabProvider extends TabProvider { tabs.add(createTab("Transsexual", updateService)); // new - url = BongaCams.baseUrl + "/tools/listing_v3.php?livetab=new-models&online_only=true&is_mobile=true&offset="; + url = BongaCams.baseUrl + "/tools/listing_v3.php?livetab=new&online_only=true&is_mobile=true&offset="; updateService = new BongaCamsUpdateService(bongaCams, url); tabs.add(createTab("New", updateService)); diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaTabProvider.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaTabProvider.java index d90ba6e7..8a460a08 100644 --- a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaTabProvider.java +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaTabProvider.java @@ -4,6 +4,7 @@ import static ctbrec.sites.camsoda.Camsoda.*; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.function.Predicate; import ctbrec.recorder.Recorder; @@ -16,6 +17,7 @@ import javafx.scene.control.Tab; public class CamsodaTabProvider extends TabProvider { + private static final String API_URL = BASE_URI + "/api/v1/browse/online"; private Camsoda camsoda; private Recorder recorder; CamsodaFollowedTab followedTab; @@ -29,8 +31,13 @@ public class CamsodaTabProvider extends TabProvider { @Override public List getTabs(Scene scene) { List tabs = new ArrayList<>(); - tabs.add(createTab("Online", BASE_URI + "/api/v1/browse/online", m -> true)); - //tabs.add(createTab("New", BASE_URI + "/api/v1/browse/online", CamsodaModel::isNew)); + tabs.add(createTab("All", API_URL, m -> true)); + tabs.add(createTab("New", API_URL, CamsodaModel::isNew)); + tabs.add(createTab("Female", API_URL, m -> Objects.equals("f", m.getGender()))); + tabs.add(createTab("Male", API_URL, m -> Objects.equals("m", m.getGender()))); + tabs.add(createTab("Couples", API_URL, m -> Objects.equals("c", m.getGender()))); + tabs.add(createTab("Trans", API_URL, m -> Objects.equals("t", m.getGender()))); + //tabs.add(createTab("#petite", BASE_URI + "/api/v1/browse/tag-petite", m -> true)); followedTab.setRecorder(recorder); followedTab.setScene(scene); tabs.add(followedTab); diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java index 1b8510d4..27e90dd6 100644 --- a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java @@ -74,60 +74,58 @@ public class CamsodaUpdateService extends PaginatedScheduledService { if (response.isSuccessful()) { String body = response.body().string(); JSONObject json = new JSONObject(body); - if (json.optBoolean("status")) { - JSONArray template = json.getJSONArray("template"); - JSONArray results = json.getJSONArray("results"); - for (int i = 0; i < results.length(); i++) { - Object result = results.getJSONObject(i).get("tpl"); - CamsodaModel model; - try { - if (result instanceof JSONObject) { - JSONObject tpl = (JSONObject) result; - String name = tpl.getString(Integer.toString(getTemplateIndex(template, "username"))); - model = (CamsodaModel) camsoda.createModel(name); - model.setDescription(tpl.getString(Integer.toString(getTemplateIndex(template, "subject_html")))); - model.setSortOrder(tpl.getFloat(Integer.toString(getTemplateIndex(template, "sort_value")))); - String preview = "https:" + tpl.getString(Integer.toString(getTemplateIndex(template, "thumb"))); - model.setPreview(preview); - String displayName = tpl.getString(Integer.toString(getTemplateIndex(template, "display_name"))); - model.setDisplayName(displayName.replaceAll("[^a-zA-Z0-9]", "")); - if (model.getDisplayName().isBlank()) { - model.setDisplayName(name); - } - model.setOnlineState(tpl.getString(Integer.toString(getTemplateIndex(template, "stream_name"))).isEmpty() ? OFFLINE : ONLINE); - try { - String statusIndex = Integer.toString(getTemplateIndex(template, "status")); - if (tpl.has(statusIndex)) { - model.setOnlineStateByStatus(tpl.getString(statusIndex)); - } - } catch (NoSuchElementException e) { - } - models.add(model); - } else if (result instanceof JSONArray) { - JSONArray tpl = (JSONArray) result; - String name = tpl.getString(getTemplateIndex(template, "username")); - model = (CamsodaModel) camsoda.createModel(name); - model.setSortOrder(tpl.getFloat(getTemplateIndex(template, "sort_value"))); - model.setDescription(tpl.getString(getTemplateIndex(template, "subject_html"))); - String preview = "https:" + tpl.getString(getTemplateIndex(template, "thumb")); - model.setPreview(preview); - model.setOnlineStateByStatus(tpl.getString(getTemplateIndex(template, "status"))); - String displayName = tpl.getString(getTemplateIndex(template, "display_name")); - model.setDisplayName(displayName.replaceAll("[^a-zA-Z0-9]", "")); - if (model.getDisplayName().isBlank()) { - model.setDisplayName(name); - } - models.add(model); + JSONArray template = json.getJSONArray("template"); + JSONArray results = json.getJSONArray("results"); + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + Object templateObject = result.get("tpl"); + CamsodaModel model; + try { + if (templateObject instanceof JSONObject) { + JSONObject tpl = (JSONObject) templateObject; + String name = tpl.getString(Integer.toString(getTemplateIndex(template, "username"))); + model = (CamsodaModel) camsoda.createModel(name); + model.setDescription(tpl.getString(Integer.toString(getTemplateIndex(template, "subject_html")))); + model.setSortOrder(tpl.getFloat(Integer.toString(getTemplateIndex(template, "sort_value")))); + model.setNew(result.optBoolean("new")); + model.setGender(tpl.getString(Integer.toString(getTemplateIndex(template, "gender")))); + String preview = "https:" + tpl.getString(Integer.toString(getTemplateIndex(template, "thumb"))); + model.setPreview(preview); + String displayName = tpl.getString(Integer.toString(getTemplateIndex(template, "display_name"))); + model.setDisplayName(displayName.replaceAll("[^a-zA-Z0-9]", "")); + if (model.getDisplayName().isBlank()) { + model.setDisplayName(name); } - } catch (Exception e) { - LOG.warn("Couldn't parse one of the models: {}", result, e); + model.setOnlineState(tpl.getString(Integer.toString(getTemplateIndex(template, "stream_name"))).isEmpty() ? OFFLINE : ONLINE); + try { + String statusIndex = Integer.toString(getTemplateIndex(template, "status")); + if (tpl.has(statusIndex)) { + model.setOnlineStateByStatus(tpl.getString(statusIndex)); + } + } catch (NoSuchElementException e) { + } + models.add(model); + } else if (templateObject instanceof JSONArray) { + JSONArray tpl = (JSONArray) templateObject; + String name = tpl.getString(getTemplateIndex(template, "username")); + model = (CamsodaModel) camsoda.createModel(name); + model.setSortOrder(tpl.getFloat(getTemplateIndex(template, "sort_value"))); + model.setDescription(tpl.getString(getTemplateIndex(template, "subject_html"))); + String preview = "https:" + tpl.getString(getTemplateIndex(template, "thumb")); + model.setPreview(preview); + model.setOnlineStateByStatus(tpl.getString(getTemplateIndex(template, "status"))); + String displayName = tpl.getString(getTemplateIndex(template, "display_name")); + model.setDisplayName(displayName.replaceAll("[^a-zA-Z0-9]", "")); + if (model.getDisplayName().isBlank()) { + model.setDisplayName(name); + } + models.add(model); } + } catch (Exception e) { + LOG.warn("Couldn't parse one of the models: {}", result, e); } - return models; - } else { - LOG.debug("Response was not successful: {}", json); - return Collections.emptyList(); } + return models; } else { throw new HttpException(response.code(), response.message()); } diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java index 791b1fd1..445d3a77 100644 --- a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java @@ -45,10 +45,10 @@ public class ChaturbateConfigUi extends AbstractConfigUI { layout.add(enabled, 1, row++); layout.add(new Label("Chaturbate User"), 0, row); - TextField username = new TextField(Config.getInstance().getSettings().username); + TextField username = new TextField(Config.getInstance().getSettings().chaturbateUsername); username.textProperty().addListener((ob, o, n) -> { - if(!n.equals(Config.getInstance().getSettings().username)) { - Config.getInstance().getSettings().username = n; + if(!n.equals(Config.getInstance().getSettings().chaturbateUsername)) { + Config.getInstance().getSettings().chaturbateUsername = n; chaturbate.getHttpClient().logout(); save(); } @@ -60,10 +60,10 @@ public class ChaturbateConfigUi extends AbstractConfigUI { layout.add(new Label("Chaturbate Password"), 0, row); PasswordField password = new PasswordField(); - password.setText(Config.getInstance().getSettings().password); + password.setText(Config.getInstance().getSettings().chaturbatePassword); password.textProperty().addListener((ob, o, n) -> { - if(!n.equals(Config.getInstance().getSettings().password)) { - Config.getInstance().getSettings().password = n; + if(!n.equals(Config.getInstance().getSettings().chaturbatePassword)) { + Config.getInstance().getSettings().chaturbatePassword = n; chaturbate.getHttpClient().logout(); save(); } diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateUpdateService.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateUpdateService.java index cddd8d17..5fbcd63f 100644 --- a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateUpdateService.java @@ -11,7 +11,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.Model; -import ctbrec.StringUtil; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.chaturbate.ChaturbateModelParser; import ctbrec.ui.SiteUiFactory; @@ -49,7 +48,7 @@ public class ChaturbateUpdateService extends PaginatedScheduledService { return new Task>() { @Override public List call() throws IOException { - if(loginRequired && StringUtil.isBlank(ctbrec.Config.getInstance().getSettings().username)) { + if(loginRequired && !chaturbate.credentialsAvailable()) { return Collections.emptyList(); } else { String url = ChaturbateUpdateService.this.url + "?page="+page+"&keywords=&_=" + System.currentTimeMillis(); diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java index 377f3d9e..a38512ef 100644 --- a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java @@ -62,6 +62,7 @@ public class StreamateUpdateService extends PaginatedScheduledService { model.setId(p.getLong("id")); //model.setPreview(p.getString("thumbnail")); model.setPreview("https://cdn.nsimg.net/snap/320x240/" + model.getId() + ".jpg"); + model.setDescription(p.optString("headlineMessage")); boolean online = p.optBoolean("online"); model.setOnline(online); model.setOnlineState(online ? ONLINE : OFFLINE); diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatUpdateService.java b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatUpdateService.java index f44f2b4b..ff441d76 100644 --- a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatUpdateService.java @@ -15,7 +15,6 @@ import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.Model; -import ctbrec.StringUtil; import ctbrec.io.HttpException; import ctbrec.sites.stripchat.Stripchat; import ctbrec.sites.stripchat.StripchatModel; @@ -46,7 +45,7 @@ public class StripchatUpdateService extends PaginatedScheduledService { @Override public List call() throws IOException { List models = new ArrayList<>(); - if(loginRequired && StringUtil.isBlank(ctbrec.Config.getInstance().getSettings().username)) { + if(loginRequired && !stripchat.credentialsAvailable()) { return Collections.emptyList(); } else { int offset = (getPage() - 1) * modelsPerPage; diff --git a/client/src/main/java/ctbrec/ui/tabs/FollowTabBlinkTransition.java b/client/src/main/java/ctbrec/ui/tabs/FollowTabBlinkTransition.java new file mode 100644 index 00000000..a33f332a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/FollowTabBlinkTransition.java @@ -0,0 +1,44 @@ +package ctbrec.ui.tabs; + +import javafx.animation.Transition; +import javafx.scene.control.Tab; +import javafx.scene.paint.Color; +import javafx.util.Duration; + +public class FollowTabBlinkTransition extends Transition { + + private final String normalStyle; + private final Tab followedTab; + private final Color normal; + private final Color highlight; + + FollowTabBlinkTransition(Tab followedTab) { + this.followedTab = followedTab; + normalStyle = followedTab.getStyle(); + normal = Color.web("#f4f4f4"); + highlight = Color.web("#2b8513"); + + setCycleDuration(Duration.millis(500)); + setCycleCount(6); + setAutoReverse(true); + setOnFinished(evt -> followedTab.setStyle(normalStyle)); + } + + @Override + protected void interpolate(double fraction) { + double rh = highlight.getRed(); + double rn = normal.getRed(); + double diff = rh - rn; + double r = (rn + diff * fraction) * 255; + double gh = highlight.getGreen(); + double gn = normal.getGreen(); + diff = gh - gn; + double g = (gn + diff * fraction) * 255; + double bh = highlight.getBlue(); + double bn = normal.getBlue(); + diff = bh - bn; + double b = (bn + diff * fraction) * 255; + String style = "-fx-background-color: rgb(" + r + "," + g + "," + b + ")"; + followedTab.setStyle(style); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java index 005fb456..90eba303 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java @@ -38,6 +38,7 @@ import ctbrec.ui.DesktopIntegration; import ctbrec.ui.JavaFxModel; import ctbrec.ui.PreviewPopupHandler; import ctbrec.ui.StreamSourceSelectionDialog; +import ctbrec.ui.action.CheckModelAccountAction; import ctbrec.ui.action.EditNotesAction; import ctbrec.ui.action.FollowAction; import ctbrec.ui.action.OpenRecordingsDir; @@ -125,6 +126,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { Button pauseAll = new Button("Pause All"); Button resumeAll = new Button("Resume All"); ToggleButton toggleRecording = new ToggleButton("Pause Recording"); + Button checkModelAccountExistance = new Button("Check URLs"); TextField filter; public RecordedModelsTab(String title, Recorder recorder, List sites) { @@ -269,7 +271,9 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { ObservableList suggestions = FXCollections.observableArrayList(); sites.forEach(site -> suggestions.add(site.getClass().getSimpleName())); model = new AutoFillTextField(new ObservableListSuggester(suggestions)); - model.setPrefWidth(600); + model.minWidth(150); + model.prefWidth(600); + HBox.setHgrow(model, Priority.ALWAYS); model.setPromptText("e.g. MyFreeCams:ModelName or an URL like https://chaturbate.com/modelname/"); model.onActionHandler(this::addModel); model.setTooltip(new Tooltip("To add a model enter SiteName:ModelName\n" + @@ -277,7 +281,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { BorderPane.setMargin(addModelBox, new Insets(5)); addModelButton.setOnAction(this::addModel); addModelButton.setPadding(new Insets(5)); - addModelBox.getChildren().addAll(modelLabel, model, addModelButton, pauseAll, resumeAll, toggleRecording); + addModelBox.getChildren().addAll(modelLabel, model, addModelButton, pauseAll, resumeAll, toggleRecording, checkModelAccountExistance); HBox.setMargin(pauseAll, new Insets(0, 0, 0, 20)); pauseAll.setOnAction(this::pauseAll); resumeAll.setOnAction(this::resumeAll); @@ -286,13 +290,21 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { toggleRecording.setPadding(new Insets(5)); toggleRecording.setOnAction(this::toggleRecording); HBox.setMargin(toggleRecording, new Insets(0, 0, 0, 20)); + checkModelAccountExistance.setPadding(new Insets(5)); + checkModelAccountExistance.setTooltip(new Tooltip("Go over all model URLs and check, if the account still exists")); + HBox.setMargin(checkModelAccountExistance, new Insets(0, 0, 0, 20)); + checkModelAccountExistance.setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder).execute()); HBox filterContainer = new HBox(); filterContainer.setSpacing(0); filterContainer.setPadding(new Insets(0)); filterContainer.setAlignment(Pos.CENTER_RIGHT); + filterContainer.minWidth(100); + filterContainer.prefWidth(150); HBox.setHgrow(filterContainer, Priority.ALWAYS); filter = new SearchBox(false); + filter.minWidth(100); + filter.prefWidth(150); filter.setPromptText("Filter"); filter.textProperty().addListener( (observableValue, oldValue, newValue) -> { String q = filter.getText(); @@ -645,6 +657,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { switchStreamSource.setOnAction(e -> switchStreamSource(selectedModels.get(0))); MenuItem follow = new MenuItem("Follow"); follow.setOnAction(e -> follow(selectedModels)); + follow.setDisable(!selectedModels.stream().allMatch(m -> m.getSite().supportsFollow() && m.getSite().credentialsAvailable())); MenuItem ignore = new MenuItem("Ignore"); ignore.setOnAction(e -> ignore(selectedModels)); MenuItem notes = new MenuItem("Notes"); diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java index 1a1e9f06..43728def 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java @@ -14,7 +14,6 @@ import java.text.DecimalFormat; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; -import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -23,11 +22,13 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.Config; +import ctbrec.Model; import ctbrec.Recording; import ctbrec.Recording.State; import ctbrec.StringUtil; @@ -38,13 +39,16 @@ import ctbrec.recorder.ProgressListener; import ctbrec.recorder.Recorder; import ctbrec.recorder.RecordingPinnedException; import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; -import ctbrec.sites.Site; import ctbrec.ui.AutosizeAlert; import ctbrec.ui.CamrecApplication; import ctbrec.ui.DesktopIntegration; import ctbrec.ui.FileDownload; import ctbrec.ui.JavaFxRecording; import ctbrec.ui.Player; +import ctbrec.ui.action.FollowAction; +import ctbrec.ui.action.PauseAction; +import ctbrec.ui.action.PlayAction; +import ctbrec.ui.action.StopRecordingAction; import ctbrec.ui.controls.DateTimeCellFactory; import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.Toast; @@ -91,10 +95,8 @@ public class RecordingsTab extends Tab implements TabSelectionListener { private static final Logger LOG = LoggerFactory.getLogger(RecordingsTab.class); private ScheduledService> updateService; - private Config config; - private Recorder recorder; - @SuppressWarnings("unused") - private List sites; + private final Config config; + private final Recorder recorder; private long spaceTotal = -1; private long spaceFree = -1; @@ -107,11 +109,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener { Label spaceLabel; Lock recordingsLock = new ReentrantLock(); - public RecordingsTab(String title, Recorder recorder, Config config, List sites) { + public RecordingsTab(String title, Recorder recorder, Config config) { super(title); this.recorder = recorder; this.config = config; - this.sites = sites; createGui(); setClosable(false); initializeUpdateService(); @@ -138,9 +139,9 @@ public class RecordingsTab extends Tab implements TabSelectionListener { date.setId("date"); date.setCellValueFactory(cdf -> { Instant instant = cdf.getValue().getStartDate(); - return new SimpleObjectProperty(instant); + return new SimpleObjectProperty<>(instant); }); - date.setCellFactory(new DateTimeCellFactory()); + date.setCellFactory(new DateTimeCellFactory<>()); date.setPrefWidth(200); TableColumn status = new TableColumn<>("Status"); status.setId("status"); @@ -191,32 +192,29 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } public boolean isDownloadRunning() { - return observableRecordings.stream() - .map(Recording::getStatus) - .anyMatch(s -> s == DOWNLOADING); + return observableRecordings.stream().map(Recording::getStatus).anyMatch(s -> s == DOWNLOADING); } private TableCell createSizeCell() { - TableCell cell = new TableCell() { + return new TableCell<>() { @Override protected void updateItem(Number sizeInByte, boolean empty) { - if(empty || sizeInByte == null) { + if (empty || sizeInByte == null) { setText(null); setStyle(null); } else { setText(StringUtil.formatSize(sizeInByte)); setStyle("-fx-alignment: CENTER-RIGHT;"); - if(Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + if (Objects.equals(System.getenv("CTBREC_DEV"), "1")) { int row = this.getTableRow().getIndex(); JavaFxRecording rec = tableViewProperty().get().getItems().get(row); - if(!rec.valueChanged() && rec.getStatus() == RECORDING) { + if (!rec.valueChanged() && rec.getStatus() == RECORDING) { setStyle("-fx-alignment: CENTER-RIGHT; -fx-background-color: red"); } } } } }; - return cell; } private void onContextMenuRequested(ContextMenuEvent event) { @@ -231,26 +229,29 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private void onMousePressed(MouseEvent event) { - if(popup != null) { + if (popup != null) { popup.hide(); } } private void onMouseClicked(MouseEvent event) { - if(event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { Recording recording = table.getSelectionModel().getSelectedItem(); - if(recording != null) { - play(recording); + if (recording != null) { + State state = recording.getStatus(); + if(state == FINISHED || state == RECORDING && config.getSettings().localRecording) { + play(recording); + } } } } - private void onKeyPressed( KeyEvent event ) { + private void onKeyPressed(KeyEvent event) { List recordings = table.getSelectionModel().getSelectedItems(); if (recordings != null && !recordings.isEmpty()) { State status = recordings.get(0).getStatus(); if (event.getCode() == KeyCode.DELETE) { - if(recordings.size() > 1 || status == FINISHED || status == FAILED || status == WAITING) { + if (recordings.size() > 1 || status == FINISHED || status == FAILED || status == WAITING) { delete(recordings); } } else if (event.getCode() == KeyCode.ENTER && status == FINISHED) { @@ -273,14 +274,14 @@ public class RecordingsTab extends Tab implements TabSelectionListener { AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR, getTabPane().getScene()); autosizeAlert.setTitle("Whoopsie!"); autosizeAlert.setHeaderText("Recordings not available"); - autosizeAlert.setContentText("An error occured while retrieving the list of recordings"); + autosizeAlert.setContentText("An error occurred while retrieving the list of recordings"); autosizeAlert.showAndWait(); }); } private void updateFreeSpaceDisplay() { - if(spaceTotal != -1 && spaceFree != -1) { - double free = ((double)spaceFree) / spaceTotal; + if (spaceTotal != -1 && spaceFree != -1) { + double free = ((double) spaceFree) / spaceTotal; spaceLeft.setProgress(free); double totalGiB = ((double) spaceTotal) / 1024 / 1024 / 1024; double freeGiB = ((double) spaceFree) / 1024 / 1024 / 1024; @@ -299,13 +300,9 @@ public class RecordingsTab extends Tab implements TabSelectionListener { recordingsLock.lock(); try { - for (Iterator iterator = observableRecordings.iterator(); iterator.hasNext();) { - JavaFxRecording old = iterator.next(); - if (!recordings.contains(old)) { - // remove deleted recordings - iterator.remove(); - } - } + // remove deleted recordings + observableRecordings.removeIf(old -> !recordings.contains(old)); + for (JavaFxRecording recording : recordings) { if (!observableRecordings.contains(recording)) { // add new recordings @@ -324,10 +321,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private ScheduledService> createUpdateService() { - ScheduledService> service = new ScheduledService>() { + ScheduledService> service = new ScheduledService<>() { @Override protected Task> createTask() { - return new Task>() { + return new Task<>() { @Override public List call() throws IOException, InvalidKeyException, NoSuchAlgorithmException { updateSpace(); @@ -388,7 +385,11 @@ public class RecordingsTab extends Tab implements TabSelectionListener { JavaFxRecording first = recordings.get(0); MenuItem openInPlayer = new MenuItem("Open in Player"); openInPlayer.setOnAction(e -> play(first)); - if(first.getStatus() == FINISHED || Config.getInstance().getSettings().localRecording) { + if (first.getStatus() == FINISHED || Config.getInstance().getSettings().localRecording) { + contextMenu.getItems().add(openInPlayer); + } else if (first.getStatus() == RECORDING && !Config.getInstance().getSettings().localRecording) { + openInPlayer.setText("Open live stream"); + openInPlayer.setOnAction(e -> play(first.getModel())); contextMenu.getItems().add(openInPlayer); } @@ -397,30 +398,33 @@ public class RecordingsTab extends Tab implements TabSelectionListener { openContactSheet.setDisable(first.getContactSheet().isEmpty()); contextMenu.getItems().add(openContactSheet); - // TODO find a way to reenable this - // MenuItem stopRecording = new MenuItem("Stop recording"); - // stopRecording.setOnAction((e) -> { - // Model m = site.createModel(recording.getModelName()); - // try { - // recorder.stopRecording(m); - // } catch (Exception e1) { - // showErrorDialog("Stop recording", "Couldn't stop recording of model " + m.getName(), e1); - // } - // }); - // if(recording.getStatus() == STATUS.RECORDING) { - // contextMenu.getItems().add(stopRecording); - // } + MenuItem stopRecording = new MenuItem("Stop Recording"); + stopRecording.setOnAction(e -> stopRecording(recordings.stream().map(JavaFxRecording::getModel).collect(Collectors.toList()))); + if (recordings.stream().anyMatch(r -> r.getStatus() == RECORDING)) { + contextMenu.getItems().add(stopRecording); + } + + MenuItem pauseRecording = new MenuItem("Pause Recording"); + pauseRecording.setOnAction(e -> pauseRecording(recordings.stream().map(JavaFxRecording::getModel).collect(Collectors.toList()))); + if (recordings.stream().anyMatch(r -> r.getStatus() == RECORDING)) { + contextMenu.getItems().add(pauseRecording); + } MenuItem deleteRecording = new MenuItem("Delete"); deleteRecording.setOnAction(e -> delete(recordings)); - if(first.getStatus() == FINISHED || first.getStatus() == WAITING || first.getStatus() == FAILED || recordings.size() > 1) { + if (first.getStatus() == FINISHED || first.getStatus() == WAITING || first.getStatus() == FAILED || recordings.size() > 1) { contextMenu.getItems().add(deleteRecording); deleteRecording.setDisable(recordings.stream().allMatch(Recording::isPinned)); } + MenuItem followModels = new MenuItem("Follow Model"); + followModels.setOnAction(e -> follow(recordings.stream().map(JavaFxRecording::getModel).collect(Collectors.toList()))); + followModels.setDisable(!recordings.stream().map(JavaFxRecording::getModel).allMatch(m -> m.getSite().supportsFollow() && m.getSite().credentialsAvailable())); + contextMenu.getItems().add(followModels); + MenuItem openDir = new MenuItem("Open directory"); openDir.setOnAction(e -> onOpenDirectory(first)); - if(Config.getInstance().getSettings().localRecording) { + if (Config.getInstance().getSettings().localRecording) { contextMenu.getItems().add(openDir); } @@ -449,7 +453,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { contextMenu.getItems().add(rerunPostProcessing); rerunPostProcessing.setDisable(!recordings.stream().allMatch(Recording::canBePostProcessed)); - if(recordings.size() > 1) { + if (recordings.size() > 1) { openInPlayer.setDisable(true); openDir.setDisable(true); } @@ -457,6 +461,18 @@ public class RecordingsTab extends Tab implements TabSelectionListener { return contextMenu; } + private void follow(List selectedModels) { + new FollowAction(getTabPane(), selectedModels).execute(); + } + + private void stopRecording(List selectedModels) { + new StopRecordingAction(getTabPane(), selectedModels, recorder).execute(); + } + + private void pauseRecording(List selectedModels) { + new PauseAction(getTabPane(), selectedModels, recorder).execute(); + } + private void openContactSheet(JavaFxRecording recording) { if (config.getSettings().localRecording) { recording.getContactSheet().ifPresent(f -> new Thread(() -> DesktopIntegration.open(f)).start()); @@ -466,7 +482,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { try { target = File.createTempFile("cs_", ".jpg"); target.deleteOnExit(); - FileDownload download = new FileDownload(CamrecApplication.httpClient, (p) -> { + FileDownload download = new FileDownload(CamrecApplication.httpClient, p -> { if (p == 100) { DesktopIntegration.open(target); } @@ -487,7 +503,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { Node source = getTabPane(); String notes = recording.getNote(); Optional newNote = Dialogs.showTextInput(source.getScene(), "Recording Notes", "", notes); - if(newNote.isPresent()) { + if (newNote.isPresent()) { table.setCursor(Cursor.WAIT); Thread backgroundThread = new Thread(() -> { List exceptions = new ArrayList<>(); @@ -562,7 +578,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private void jumpToNextModel(KeyCode code) { - if (!table.getItems().isEmpty() && (code.isLetterKey() || code.isDigitKey())) { + try { + ensureTableIsNotEmpty(); + ensureKeyCodeIsLetterOrDigit(code); + // determine where to start looking for the next model int startAt = 0; if (table.getSelectionModel().getSelectedIndex() >= 0) { @@ -587,6 +606,20 @@ public class RecordingsTab extends Tab implements TabSelectionListener { i = 0; } } while (i != startAt); + } catch (IllegalStateException | IllegalArgumentException e) { + // GUI was not in the state to process the user input + } + } + + private void ensureKeyCodeIsLetterOrDigit(KeyCode code) { + if (!(code.isLetterKey() || code.isDigitKey())) { + throw new IllegalArgumentException("keycode not allowed"); + } + } + + private void ensureTableIsNotEmpty() { + if (table.getItems().isEmpty()) { + throw new IllegalStateException("table is empty"); } } @@ -630,13 +663,12 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private String proposeTargetFilename(Recording recording) { - if(recording.isSingleFile()) { + if (recording.isSingleFile()) { return recording.getPostProcessedFile().getName(); } else { String downloadFilename = config.getSettings().downloadFilename; String fileSuffix = config.getSettings().ffmpegFileSuffix; - String filename = new DownloadPostprocessor().fillInPlaceHolders(downloadFilename, recording, config) + '.' + fileSuffix; - return filename; + return new DownloadPostprocessor().fillInPlaceHolders(downloadFilename, recording, config) + '.' + fileSuffix; } } @@ -665,7 +697,8 @@ public class RecordingsTab extends Tab implements TabSelectionListener { Platform.runLater(() -> { recording.setStatus(FINISHED); recording.setProgress(-1); - RecordingStateChangedEvent evt = new RecordingStateChangedEvent(target, recording.getStatus(), recording.getModel(), recording.getStartDate()); + RecordingStateChangedEvent evt = new RecordingStateChangedEvent(target, recording.getStatus(), recording.getModel(), + recording.getStartDate()); EventBusHolder.BUS.post(evt); }); } @@ -697,7 +730,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR, getTabPane().getScene()); autosizeAlert.setTitle(title); autosizeAlert.setHeaderText(msg); - StringBuilder contentText = new StringBuilder("On or more error(s) occured:"); + StringBuilder contentText = new StringBuilder("On or more error(s) occurred:"); for (Exception exception : exceptions) { contentText.append("\n• ").append(exception.getLocalizedMessage()); } @@ -707,21 +740,22 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private void play(Recording recording) { - new Thread() { - @Override - public void run() { - boolean started = Player.play(recording); - if(started && Config.getInstance().getSettings().showPlayerStarting) { - Platform.runLater(() -> Toast.makeText(getTabPane().getScene(), "Starting Player", 2000, 500, 500)); - } + new Thread(() -> { + boolean started = Player.play(recording); + if (started && Config.getInstance().getSettings().showPlayerStarting) { + Platform.runLater(() -> Toast.makeText(getTabPane().getScene(), "Starting Player", 2000, 500, 500)); } - }.start(); + }).start(); + } + + private void play(Model model) { + new PlayAction(table, model).execute(); } private void delete(List recordings) { table.setCursor(Cursor.WAIT); String msg; - if(recordings.size() > 1) { + if (recordings.size() > 1) { msg = "Delete " + recordings.size() + " recordings for good?"; } else { Recording r = recordings.get(0); @@ -745,8 +779,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { try { List deleted = new ArrayList<>(); List exceptions = new ArrayList<>(); - for (Iterator iterator = recordings.iterator(); iterator.hasNext();) { - JavaFxRecording r = iterator.next(); + for (JavaFxRecording r : recordings) { if (r.getStatus() != FINISHED && r.getStatus() != FAILED && r.getStatus() != WAITING) { continue; } @@ -771,7 +804,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } public void saveState() { - if(!table.getSortOrder().isEmpty()) { + if (!table.getSortOrder().isEmpty()) { TableColumn col = table.getSortOrder().get(0); Config.getInstance().getSettings().recordingsSortColumn = col.getText(); Config.getInstance().getSettings().recordingsSortType = col.getSortType().toString(); @@ -795,9 +828,9 @@ public class RecordingsTab extends Tab implements TabSelectionListener { private void restoreSorting() { String sortCol = Config.getInstance().getSettings().recordingsSortColumn; - if(StringUtil.isNotBlank(sortCol)) { + if (StringUtil.isNotBlank(sortCol)) { for (TableColumn col : table.getColumns()) { - if(Objects.equals(sortCol, col.getText())) { + if (Objects.equals(sortCol, col.getText())) { col.setSortType(SortType.valueOf(Config.getInstance().getSettings().recordingsSortType)); table.getSortOrder().clear(); table.getSortOrder().add(col); @@ -809,10 +842,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener { private void restoreColumnOrder() { String[] columnIds = Config.getInstance().getSettings().recordingsColumnIds; - ObservableList> columns = table.getColumns(); + ObservableList> columns = table.getColumns(); for (int i = 0; i < columnIds.length; i++) { for (int j = 0; j < table.getColumns().size(); j++) { - if(Objects.equals(columnIds[i], columns.get(j).getId())) { + if (Objects.equals(columnIds[i], columns.get(j).getId())) { TableColumn col = columns.get(j); columns.remove(j); // NOSONAR columns.add(i, col); @@ -823,7 +856,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { private void restoreColumnWidths() { double[] columnWidths = Config.getInstance().getSettings().recordingsColumnWidths; - if(columnWidths != null && columnWidths.length == table.getColumns().size()) { + if (columnWidths != null && columnWidths.length == table.getColumns().size()) { for (int i = 0; i < columnWidths.length; i++) { table.getColumns().get(i).setPrefWidth(columnWidths[i]); } diff --git a/client/src/main/java/ctbrec/ui/ThumbCell.css b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.css similarity index 100% rename from client/src/main/java/ctbrec/ui/ThumbCell.css rename to client/src/main/java/ctbrec/ui/tabs/ThumbCell.css diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java index 653678e0..1930253c 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java @@ -1,25 +1,8 @@ package ctbrec.ui.tabs; -import static ctbrec.Model.State.*; -import static ctbrec.io.HttpConstants.*; - -import java.io.IOException; -import java.util.Locale; -import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.function.Function; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; - import ctbrec.Config; import ctbrec.Model; import ctbrec.Model.State; @@ -27,10 +10,11 @@ import ctbrec.io.HttpException; import ctbrec.recorder.Recorder; import ctbrec.ui.AutosizeAlert; import ctbrec.ui.CamrecApplication; -import ctbrec.ui.PauseIndicator; import ctbrec.ui.SiteUiFactory; import ctbrec.ui.StreamSourceSelectionDialog; import ctbrec.ui.action.PlayAction; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.PausedIndicator; import ctbrec.ui.controls.StreamPreview; import javafx.animation.FadeTransition; import javafx.animation.FillTransition; @@ -39,7 +23,6 @@ import javafx.animation.Transition; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.geometry.Insets; @@ -48,6 +31,7 @@ import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.control.Alert; import javafx.scene.control.ContextMenu; +import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.StackPane; @@ -63,6 +47,18 @@ import javafx.scene.text.TextAlignment; import javafx.util.Duration; import okhttp3.Request; import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.*; +import java.util.function.Function; + +import static ctbrec.Model.State.OFFLINE; +import static ctbrec.Model.State.ONLINE; +import static ctbrec.io.HttpConstants.*; public class ThumbCell extends StackPane { @@ -85,7 +81,7 @@ public class ThumbCell extends StackPane { private Text resolutionTag; private Recorder recorder; private Circle recordingIndicator; - private PauseIndicator pausedIndicator; + private PausedIndicator pausedIndicator; private int index = 0; ContextMenu popup; private static final Color colorNormal = Color.BLACK; @@ -177,12 +173,16 @@ public class ThumbCell extends StackPane { recordingIndicator = new Circle(8); recordingIndicator.setFill(colorRecording); + recordingIndicator.setCursor(Cursor.HAND); + recordingIndicator.setOnMouseClicked(e -> pauseResumeAction(true)); + Tooltip tooltip = new Tooltip("Pause Recording"); + Tooltip.install(recordingIndicator, tooltip); StackPane.setMargin(recordingIndicator, new Insets(3)); StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT); getChildren().add(recordingIndicator); - pausedIndicator = new PauseIndicator(colorRecording, 16); - pausedIndicator.setVisible(false); + pausedIndicator = new PausedIndicator(16, colorRecording); + pausedIndicator.setOnMouseClicked(e -> pauseResumeAction(false)); StackPane.setMargin(pausedIndicator, new Insets(3)); StackPane.setAlignment(pausedIndicator, Pos.TOP_LEFT); getChildren().add(pausedIndicator); @@ -236,7 +236,7 @@ public class ThumbCell extends StackPane { play.setStyle("-fx-background-color: black;"); previewTrigger.getChildren().add(play); - Circle clip = new Circle(s / 2); + Circle clip = new Circle(s / 2.0); clip.setTranslateX(clip.getRadius()); clip.setTranslateY(clip.getRadius()); previewTrigger.setClip(clip); @@ -287,8 +287,7 @@ public class ThumbCell extends StackPane { resolution = resolutionCache.get(model); resolutionBackgroundColor = resolutionOnlineColor; final int w = resolution[1]; - String width = w != Integer.MAX_VALUE ? Integer.toString(w) : "HD"; - tagText = width; + tagText = w != Integer.MAX_VALUE ? Integer.toString(w) : "HD"; if (w == 0) { State state = model.getOnlineState(false); tagText = state.name(); @@ -364,7 +363,7 @@ public class ThumbCell extends StackPane { setThumbWidth(Config.getInstance().getSettings().thumbWidth); }); } else { - img.progressProperty().addListener((ChangeListener) (observable, oldValue, newValue) -> { + img.progressProperty().addListener((observable, oldValue, newValue) -> { if (newValue.doubleValue() == 1.0) { iv.setImage(img); setThumbWidth(Config.getInstance().getSettings().thumbWidth); @@ -403,13 +402,13 @@ public class ThumbCell extends StackPane { private void setRecording(boolean recording) { this.recording = recording; + Color c; if (recording) { - Color c = mouseHovering ? colorHighlight : colorRecording; - nameBackground.setFill(c); + c = mouseHovering ? colorHighlight : colorRecording; } else { - Color c = mouseHovering ? colorHighlight : colorNormal; - nameBackground.setFill(c); + c = mouseHovering ? colorHighlight : colorNormal; } + nameBackground.setFill(c); updateRecordingIndicator(); } @@ -430,7 +429,7 @@ public class ThumbCell extends StackPane { boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality; if (selectSource && start) { Function onSuccess = modl -> { - startStopActionAsync(modl, start); + startStopActionAsync(modl, true); return null; }; Function onFail = throwable -> { @@ -484,13 +483,7 @@ public class ThumbCell extends StackPane { } } catch (Exception e1) { LOG.error(COULDNT_START_STOP_RECORDING, e1); - Platform.runLater(() -> { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); - alert.setTitle(ERROR); - alert.setHeaderText(COULDNT_START_STOP_RECORDING); - alert.setContentText("I/O error while starting/stopping the recording: " + e1.getLocalizedMessage()); - alert.showAndWait(); - }); + Dialogs.showError(getScene(), COULDNT_START_STOP_RECORDING, "I/O error while starting/stopping the recording: ", e1); } finally { setCursor(Cursor.DEFAULT); } @@ -507,13 +500,7 @@ public class ThumbCell extends StackPane { if (followed) { return true; } else { - Platform.runLater(() -> { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); - alert.setTitle(ERROR); - alert.setHeaderText("Couldn't follow model"); - alert.setContentText(""); - alert.showAndWait(); - }); + Dialogs.showError(getScene(), "Couldn't follow model", "", null); return false; } } else { @@ -523,25 +510,14 @@ public class ThumbCell extends StackPane { Platform.runLater(() -> thumbCellList.remove(ThumbCell.this)); return true; } else { - Platform.runLater(() -> { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); - alert.setTitle(ERROR); - alert.setHeaderText("Couldn't unfollow model"); - alert.setContentText(""); - alert.showAndWait(); - }); + Dialogs.showError(getScene(), "Couldn't unfollow model", "", null); return false; } } } catch (Exception e1) { LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1); - Platform.runLater(() -> { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); - alert.setTitle(ERROR); - alert.setHeaderText("Couldn't follow/unfollow model"); - alert.setContentText("I/O error while following/unfollowing model " + model.getName() + ": " + e1.getLocalizedMessage()); - alert.showAndWait(); - }); + String msg = "I/O error while following/unfollowing model " + model.getName() + ": "; + Dialogs.showError(getScene(), "Couldn't follow/unfollow model", msg, e1); return false; } finally { setCursor(Cursor.DEFAULT); @@ -616,8 +592,8 @@ public class ThumbCell extends StackPane { public void setThumbWidth(int width) { int height = (int) (width * imgAspectRatio); setSize(width, height); - iv.prefHeight(height); - iv.prefWidth(width); + iv.prefHeight(width); + iv.prefWidth(height); } private void setSize(int w, int h) { @@ -638,8 +614,8 @@ public class ThumbCell extends StackPane { topic.prefHeight(getHeight() - 25); topic.maxHeight(getHeight() - 25); int margin = 4; - topic.maxWidth(w - margin * 2); - topic.setWrappingWidth(w - margin * 2); + topic.maxWidth(w - margin * 2.0); + topic.setWrappingWidth(w - margin * 2.0); streamPreview.resizeTo(w, h); diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java index bdf13d8b..90d2e163 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java @@ -1,31 +1,5 @@ package ctbrec.ui.tabs; -import static ctbrec.ui.controls.Dialogs.*; - -import java.io.IOException; -import java.net.SocketTimeoutException; -import java.text.DecimalFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.Config; import ctbrec.Model; import ctbrec.event.EventBusHolder; @@ -33,21 +7,13 @@ import ctbrec.recorder.Recorder; import ctbrec.sites.Site; import ctbrec.sites.mfc.MyFreeCamsClient; import ctbrec.sites.mfc.MyFreeCamsModel; -import ctbrec.ui.AutosizeAlert; -import ctbrec.ui.DesktopIntegration; -import ctbrec.ui.SiteUiFactory; -import ctbrec.ui.TipDialog; -import ctbrec.ui.TokenLabel; +import ctbrec.ui.*; import ctbrec.ui.action.OpenRecordingsDir; +import ctbrec.ui.controls.FasterVerticalScrollPaneSkin; import ctbrec.ui.controls.SearchBox; import ctbrec.ui.controls.SearchPopover; import ctbrec.ui.controls.SearchPopoverTreeList; -import javafx.animation.FadeTransition; -import javafx.animation.Interpolator; -import javafx.animation.ParallelTransition; -import javafx.animation.ScaleTransition; -import javafx.animation.Transition; -import javafx.animation.TranslateTransition; +import javafx.animation.*; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; @@ -57,38 +23,30 @@ import javafx.collections.ObservableList; import javafx.concurrent.Task; import javafx.concurrent.Worker.State; import javafx.concurrent.WorkerStateEvent; +import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.Parent; -import javafx.scene.control.Alert; -import javafx.scene.control.Button; -import javafx.scene.control.ComboBox; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.Label; -import javafx.scene.control.MenuItem; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.Tab; -import javafx.scene.control.TabPane; -import javafx.scene.control.TextField; -import javafx.scene.control.Tooltip; +import javafx.scene.control.*; import javafx.scene.image.ImageView; -import javafx.scene.input.Clipboard; -import javafx.scene.input.ClipboardContent; -import javafx.scene.input.ContextMenuEvent; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyEvent; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.FlowPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.StackPane; -import javafx.scene.paint.Color; +import javafx.scene.input.*; +import javafx.scene.layout.*; import javafx.scene.transform.Transform; import javafx.util.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.text.DecimalFormat; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import static ctbrec.ui.controls.Dialogs.showError; public class ThumbOverviewTab extends Tab implements TabSelectionListener { private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTab.class); @@ -106,7 +64,6 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { private String filter; ReentrantLock gridLock = new ReentrantLock(); ScrollPane scrollPane = new ScrollPane(); - boolean loginRequired; TextField pageInput = new TextField(Integer.toString(1)); Button pageFirst = new Button("1"); Button pagePrev = new Button("◀"); @@ -117,9 +74,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { StackPane root = new StackPane(); Task> searchTask; SearchPopover popover; - SearchPopoverTreeList popoverTreelist = new SearchPopoverTreeList(); + SearchPopoverTreeList popoverTreeList = new SearchPopoverTreeList(); double imageAspectRatio = 3.0 / 4.0; - private SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true); + private final SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true); private ComboBox thumbWidth; @@ -172,7 +129,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { popover.maxHeightProperty().bind(popover.minHeightProperty()); popover.prefHeightProperty().bind(popover.minHeightProperty()); popover.setMinHeight(450); - popover.pushPage(popoverTreelist); + popover.pushPage(popoverTreeList); StackPane.setAlignment(popover, Pos.TOP_RIGHT); StackPane.setMargin(popover, new Insets(35, 50, 0, 0)); @@ -196,6 +153,8 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { scrollPane.setContent(grid); scrollPane.setFitToHeight(true); scrollPane.setFitToWidth(true); + FasterVerticalScrollPaneSkin scrollPaneSkin = new FasterVerticalScrollPaneSkin(scrollPane); + scrollPane.setSkin(scrollPaneSkin); BorderPane.setMargin(scrollPane, new Insets(5)); pagination = new HBox(5); @@ -205,29 +164,13 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { pagination.getChildren().add(pageInput); BorderPane.setMargin(pagination, new Insets(5)); pageInput.setPrefWidth(50); - pageInput.setOnAction(e -> handlePageNumberInput()); + pageInput.setOnAction(this::handlePageNumberInput); pageFirst.setTooltip(new Tooltip("First Page")); - pageFirst.setOnAction(e -> { - pageInput.setText(Integer.toString(1)); - updateService.setPage(1); - restartUpdateService(); - }); + pageFirst.setOnAction(e -> changePageTo(1)); pagePrev.setTooltip(new Tooltip("Previous Page")); - pagePrev.setOnAction(e -> { - int page = updateService.getPage(); - page = Math.max(1, --page); - pageInput.setText(Integer.toString(page)); - updateService.setPage(page); - restartUpdateService(); - }); + pagePrev.setOnAction(e -> previousPage()); pageNext.setTooltip(new Tooltip("Next Page")); - pageNext.setOnAction(e -> { - int page = updateService.getPage(); - page++; - pageInput.setText(Integer.toString(page)); - updateService.setPage(page); - restartUpdateService(); - }); + pageNext.setOnAction(e -> nextPage()); HBox thumbSizeSelector = new HBox(5); Label l = new Label("Thumb Size"); @@ -242,8 +185,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { thumbWidth = new ComboBox<>(FXCollections.observableList(thumbWidths)); thumbWidth.getSelectionModel().select(Integer.valueOf(Config.getInstance().getSettings().thumbWidth)); thumbWidth.setOnAction(e -> { - int width = thumbWidth.getSelectionModel().getSelectedItem(); - Config.getInstance().getSettings().thumbWidth = width; + Config.getInstance().getSettings().thumbWidth = thumbWidth.getSelectionModel().getSelectedItem(); updateThumbSize(); }); thumbSizeSelector.getChildren().add(thumbWidth); @@ -263,6 +205,35 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { root.getChildren().add(borderPane); root.getChildren().add(popover); setContent(root); + + scrollPane.setOnKeyReleased(event -> { + if (event.getCode() == KeyCode.RIGHT) { + nextPage(); + } else if (event.getCode() == KeyCode.LEFT) { + previousPage(); + } else if (event.getCode().getCode() >= KeyCode.DIGIT1.getCode() && event.getCode().getCode() <= KeyCode.DIGIT9.getCode()) { + changePageTo(event.getCode().getCode() - 48); + } + }); + } + + private void nextPage() { + int page = updateService.getPage(); + page++; + changePageTo(page); + } + + + private void previousPage() { + int page = updateService.getPage(); + page = Math.max(1, --page); + changePageTo(page); + } + + private void changePageTo(int page) { + pageInput.setText(Integer.toString(page)); + updateService.setPage(page); + restartUpdateService(); } private ChangeListener search() { @@ -273,7 +244,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { if(newValue.length() < 2) { return; } - searchTask = new ThumbOverviewTabSearchTask(site, popover, popoverTreelist, newValue); + searchTask = new ThumbOverviewTabSearchTask(site, popover, popoverTreeList, newValue); new Thread(searchTask).start(); }; } @@ -292,12 +263,11 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { } } - private void handlePageNumberInput() { + private void handlePageNumberInput(ActionEvent event) { try { int page = Integer.parseInt(pageInput.getText()); page = Math.max(1, page); - updateService.setPage(page); - restartUpdateService(); + changePageTo(page); } catch(NumberFormatException e) { // noop } finally { @@ -426,11 +396,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { if(popup != null) { popup.hide(); popup = null; - return; } }); newCell.selectionProperty().addListener((obs, oldValue, newValue) -> { - if(newValue.booleanValue()) { + if (Boolean.TRUE.equals(newValue)) { selectedThumbCells.add(newCell); } else { selectedThumbCells.remove(newCell); @@ -555,10 +524,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { event.put("amount", tokens.doubleValue()); EventBusHolder.BUS.post(event); } catch (IOException ex) { - LOG.error("An error occured while sending tip", ex); - showError("Couldn't send tip", "An error occured while sending tip:", ex); + LOG.error("An error occurred while sending tip", ex); + showError(getTabPane().getScene(), "Couldn't send tip", "An error occurred while sending tip:", ex); } catch (Exception ex) { - showError("Couldn't send tip", "You entered an invalid amount of tokens", ex); + showError(getTabPane().getScene(), "Couldn't send tip", "You entered an invalid amount of tokens", ex); } } }); @@ -577,7 +546,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { protected void follow(List selection, boolean follow) { for (ThumbCell thumbCell : selection) { thumbCell.follow(follow).thenAccept(success -> { - if(follow && success.booleanValue()) { + if (follow && Boolean.TRUE.equals(success)) { showAddToFollowedAnimation(thumbCell); } }); @@ -625,35 +594,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { ParallelTransition pt = new ParallelTransition(translate, scale); pt.play(); pt.setOnFinished(evt -> root.getChildren().remove(iv)); - - String normalStyle = followedTab.getStyle(); - Color normal = Color.web("#f4f4f4"); - Color highlight = Color.web("#2b8513"); - Transition blink = new Transition() { - { - setCycleDuration(Duration.millis(500)); - } - @Override - protected void interpolate(double frac) { - double rh = highlight.getRed(); - double rn = normal.getRed(); - double diff = rh - rn; - double r = (rn + diff * frac) * 255; - double gh = highlight.getGreen(); - double gn = normal.getGreen(); - diff = gh - gn; - double g = (gn + diff * frac) * 255; - double bh = highlight.getBlue(); - double bn = normal.getBlue(); - diff = bh - bn; - double b = (bn + diff * frac) * 255; - String style = "-fx-background-color: rgb(" + r + "," + g + "," + b + ")"; - followedTab.setStyle(style); - } - }; - blink.setCycleCount(6); - blink.setAutoReverse(true); - blink.setOnFinished(evt -> followedTab.setStyle(normalStyle)); + FollowTabBlinkTransition blink = new FollowTabBlinkTransition(followedTab); blink.play(); }); } @@ -692,13 +633,13 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { } } - private EventHandler mouseClickListener = e -> { + private final EventHandler mouseClickListener = e -> { ThumbCell cell = (ThumbCell) e.getSource(); if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) { cell.setSelected(false); cell.startPlayer(); } else if (e.getButton() == MouseButton.PRIMARY && e.isControlDown()) { - if(popup == null) { + if (popup == null) { cell.setSelected(!cell.isSelected()); } } else if (e.getButton() == MouseButton.PRIMARY) { @@ -728,12 +669,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { } void filter() { - Collections.sort(filteredThumbCells, (o1, o2) -> { - ThumbCell c1 = o1; - ThumbCell c2 = o2; - - if(c1.getIndex() < c2.getIndex()) return -1; - if(c1.getIndex() > c2.getIndex()) return 1; + filteredThumbCells.sort((c1, c2) -> { + if (c1.getIndex() < c2.getIndex()) return -1; + if (c1.getIndex() > c2.getIndex()) return 1; return c1.getModel().getName().compareTo(c2.getModel().getName()); }); @@ -839,7 +777,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { return !tokensMissing; } - private String createSearchText(Model m) throws ExecutionException { + private String createSearchText(Model m) { StringBuilder searchTextBuilder = new StringBuilder(m.getName()); searchTextBuilder.append(' '); searchTextBuilder.append(m.getDisplayName()); @@ -856,7 +794,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { public void setRecorder(Recorder recorder) { this.recorder = recorder; - popoverTreelist.setRecorder(recorder); + popoverTreeList.setRecorder(recorder); } @Override diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java index 4a0d159f..25892349 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java @@ -1,13 +1,5 @@ package ctbrec.ui.tabs; -import static ctbrec.ui.controls.Dialogs.*; - -import java.io.IOException; -import java.util.List; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.Model; import ctbrec.sites.Site; import ctbrec.ui.SiteUiFactory; @@ -15,20 +7,27 @@ import ctbrec.ui.controls.SearchPopover; import ctbrec.ui.controls.SearchPopoverTreeList; import javafx.application.Platform; import javafx.concurrent.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.List; + +import static ctbrec.ui.controls.Dialogs.showError; public class ThumbOverviewTabSearchTask extends Task> { private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTabSearchTask.class); - private Site site; - private SearchPopover popover; - private SearchPopoverTreeList popoverTreelist; - private String query; + private final Site site; + private final SearchPopover popover; + private final SearchPopoverTreeList popoverTreeList; + private final String query; - public ThumbOverviewTabSearchTask(Site site, SearchPopover popover, SearchPopoverTreeList popoverTreelist, String query) { + public ThumbOverviewTabSearchTask(Site site, SearchPopover popover, SearchPopoverTreeList popoverTreeList, String query) { this.site = site; this.popover = popover; - this.popoverTreelist = popoverTreelist; + this.popoverTreeList = popoverTreeList; this.query = query; } @@ -39,10 +38,10 @@ public class ThumbOverviewTabSearchTask extends Task> { try { loggedin = SiteUiFactory.getUi(site).login(); } catch (IOException e) { - loggedin = false; + // nothing to do } if(!loggedin) { - showError("Login failed", "Search won't work correctly without login", null); + showError(popover.getScene(), "Login failed", "Search won't work correctly without login", null); } } return site.search(query); @@ -61,9 +60,9 @@ public class ThumbOverviewTabSearchTask extends Task> { if(models.isEmpty()) { popover.hide(); } else { - popoverTreelist.getItems().clear(); + popoverTreeList.getItems().clear(); for (Model model : getValue()) { - popoverTreelist.getItems().add(model); + popoverTreeList.getItems().add(model); } popover.show(); } diff --git a/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java b/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java index 494dbf6d..51af2896 100644 --- a/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java @@ -1,8 +1,5 @@ package ctbrec.ui.tabs; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.io.HttpException; import ctbrec.ui.CamrecApplication; import ctbrec.ui.CamrecApplication.Release; @@ -18,12 +15,14 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import okhttp3.Request; import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class UpdateTab extends Tab { private static final Logger LOG = LoggerFactory.getLogger(UpdateTab.class); - private TextArea changelog; + private final TextArea changelog; public UpdateTab(Release latest) { setText("Update Available"); @@ -52,7 +51,7 @@ public class UpdateTab extends Tab { } } catch (Exception e1) { LOG.error("Couldn't download the changelog", e1); - Dialogs.showError("Communication error", "Couldn't download the changelog", e1); + Dialogs.showError(getTabPane().getScene(), "Communication error", "Couldn't download the changelog", e1); } }).start(); } diff --git a/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java b/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java index 932929b7..c52b9aa8 100644 --- a/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java @@ -90,9 +90,13 @@ public class LoggingTab extends Tab { TableColumn location = createTableColumn("Location", 250, idx++); location.setCellValueFactory(cdf -> { - StackTraceElement loc = cdf.getValue().getCallerData()[0]; - String l = loc.getFileName() + ":" + loc.getLineNumber(); - return new SimpleStringProperty(l); + if(cdf.getValue().getCallerData().length > 0) { + StackTraceElement loc = cdf.getValue().getCallerData()[0]; + String l = loc.getFileName() + ":" + loc.getLineNumber(); + return new SimpleStringProperty(l); + } else { + return new SimpleStringProperty(""); + } }); table.getColumns().add(location); diff --git a/client/src/main/resources/html/docs/ConfigurationFile.md b/client/src/main/resources/html/docs/ConfigurationFile.md index e3ea4bf1..0222f242 100644 --- a/client/src/main/resources/html/docs/ConfigurationFile.md +++ b/client/src/main/resources/html/docs/ConfigurationFile.md @@ -53,16 +53,21 @@ the port ctbrec tries to connect to, if it is run in remote mode. - **onlineCheckSkipsPausedModels** - [`true`,`false`] Skip the online check for paused models. If you have many models in the recording list, this can reduce the delay when a recording starts after a model came online. -- **postProcessing** - Absolute path to a script, which is executed once a recording is finished. See [Post-Processing](/docs/PostProcessing.md). +- **postProcessing** - **Deprecated. See [Post-Processing](/docs/PostProcessing.md)** Absolute path to a script, which is executed once a recording is finished. - **recordingsDir** - Where ctbrec saves the recordings. - **recordingsDirStructure** (server only) - [`FLAT`, `ONE_PER_MODEL`, `ONE_PER_RECORDING`] How recordings are stored in the file system. `FLAT` - all recordings in one directory; `ONE_PER_MODEL` - one directory per model; `ONE_PER_RECORDING` - each recordings ends up in its own directory. Change this only, if you have `recordSingleFile` set to `true` -- **recordSingleFile** (server only) - [`true`,`false`] - How recordings are stored in the file system. `true` means, each recording is saved in one large file. `false` means, ctbrec just downloads the stream segments. +- **recordSingleFile** (server only) - [`true`,`false`] - How recordings are stored in the file system. `true` means, each recording is saved in one large file. `false` means, ctbrec just downloads the stream segments. -- **splitRecordings** - [0 - 2147483647] in seconds. Split recordings after this amount of seconds. The recordings are split up into several individual recordings, -which have the defined length (roughly). 0 means no splitting. The server does not support splitRecordings. +- **splitStrategy** - [`DONT`, `TIME`, `SIZE`, `TIME_OR_SIZE`] Defines if and how to split recordings. Also see `splitRecordingsAfterSecs` and `splitRecordingsBiggerThanBytes` + +- **splitRecordingsAfterSecs** - [0 - 2147483647] in seconds. Split recordings after this amount of seconds. The recordings are split up into several individual recordings, +which have the defined length (roughly). Has to be activated with `splitStrategy`. + +- **splitRecordingsBiggerThanBytes** - [0 - 9223372036854775807] in bytes. Split recordings, if the size on disk exceeds this value. The recordings are split up into several individual recordings, +which have the defined size (roughly). Has to be activated with `splitStrategy`. - **webinterface** (server only) - [`true`,`false`] Enables the webinterface for the server. You can access it with http://host:port/static/index.html Don't activate this on a machine, which can be accessed from the internet, because this is totally unprotected at the moment. diff --git a/client/src/main/resources/logback.xml b/client/src/main/resources/logback.xml index fa146bf1..e83c831b 100644 --- a/client/src/main/resources/logback.xml +++ b/client/src/main/resources/logback.xml @@ -1,4 +1,4 @@ - + {}", oldLocation, newLocation); + FileUtils.moveDirectory(oldLocation, newLocation); + } else { + LOG.debug("minimal browser settings have been migrated before"); + } + } catch (IOException e) { + LOG.error("Couldn't migrate minimal browser config location", e); + } + } } private void makeBackup(File source) { diff --git a/common/src/main/java/ctbrec/Model.java b/common/src/main/java/ctbrec/Model.java index 42ccd10e..15b94c7f 100644 --- a/common/src/main/java/ctbrec/Model.java +++ b/common/src/main/java/ctbrec/Model.java @@ -136,4 +136,11 @@ public interface Model extends Comparable, Serializable { public SubsequentAction getRecordUntilSubsequentAction(); public void setRecordUntilSubsequentAction(SubsequentAction action); + /** + * Check, if this model account exists + * @return true, if it exists, false otherwise + * @throws IOException + */ + public boolean exists() throws IOException; + } \ No newline at end of file diff --git a/common/src/main/java/ctbrec/OS.java b/common/src/main/java/ctbrec/OS.java index 76f759cc..b97ebb61 100644 --- a/common/src/main/java/ctbrec/OS.java +++ b/common/src/main/java/ctbrec/OS.java @@ -86,10 +86,16 @@ public class OS { System.arraycopy(args, 0, cmd, 1, args.length); break; case MAC: - cmd = new String[args.length + 2]; - cmd[0] = "open"; - cmd[1] = new File(browserDir, "ctbrec-minimal-browser.app").getAbsolutePath(); - System.arraycopy(args, 0, cmd, 2, args.length); + cmd = new String[args.length + 5]; + int index = 0; + cmd[index++] = "open"; + cmd[index++] = "-W"; + cmd[index++] = "-a"; + cmd[index++] = new File(browserDir, "ctbrec-minimal-browser.app").getAbsolutePath(); + if (args.length > 0) { + cmd[index] = "--args"; + System.arraycopy(args, 0, cmd, 5, args.length); + } break; default: throw new UnsupportedOperatingSystemException(System.getProperty(OS_NAME)); diff --git a/common/src/main/java/ctbrec/Recording.java b/common/src/main/java/ctbrec/Recording.java index d422c4fd..f2ccc264 100644 --- a/common/src/main/java/ctbrec/Recording.java +++ b/common/src/main/java/ctbrec/Recording.java @@ -3,35 +3,23 @@ package ctbrec; import static ctbrec.Recording.State.*; import java.io.File; -import java.io.IOException; import java.io.Serializable; -import java.nio.file.FileVisitOption; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; -import java.util.EnumSet; import java.util.HashSet; import java.util.Optional; import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.event.EventBusHolder; import ctbrec.event.RecordingStateChangedEvent; +import ctbrec.io.IoUtils; import ctbrec.recorder.download.Download; import ctbrec.recorder.download.VideoLengthDetector; public class Recording implements Serializable { - private static final transient Logger LOG = LoggerFactory.getLogger(Recording.class); - private String id; private Model model; private transient Download download; @@ -211,7 +199,7 @@ public class Recording implements Serializable { public Duration getLength() { File ppFile = getPostProcessedFile(); if (ppFile.isDirectory()) { - File playlist = new File(ppFile, "playlist.m3u8a"); + File playlist = new File(ppFile, "playlist.m3u8"); return VideoLengthDetector.getLength(playlist); } else { return VideoLengthDetector.getLength(ppFile); @@ -253,11 +241,11 @@ public class Recording implements Serializable { private long getSize() { File rec = getAbsoluteFile(); if (rec.isDirectory()) { - return getDirectorySize(rec); + return IoUtils.getDirectorySize(rec); } else { if (!rec.exists()) { if (rec.getName().endsWith(".m3u8")) { - return getDirectorySize(rec.getParentFile()); + return IoUtils.getDirectorySize(rec.getParentFile()); } else { return -1; } @@ -267,29 +255,6 @@ public class Recording implements Serializable { } } - private long getDirectorySize(File dir) { - final long[] size = { 0 }; - int maxDepth = 1; // Don't expect subdirs, so don't even try - try { - Files.walkFileTree(dir.toPath(), EnumSet.noneOf(FileVisitOption.class), maxDepth, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - size[0] += attrs.size(); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFileFailed(Path file, IOException exc) { - // Ignore file access issues - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException e) { - LOG.error("Couldn't determine size of recording {}", this, e); - } - return size[0]; - } - public void refresh() { sizeInByte = getSize(); } diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 09b5acc2..804d759b 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -34,6 +34,13 @@ public class Settings { SOCKS5 } + public enum SplitStrategy { + DONT, + TIME, + SIZE, + TIME_OR_SIZE + } + public String bongacamsBaseUrl = "https://bongacams.com"; public String bongaPassword = ""; public String bongaUsername = ""; @@ -41,6 +48,8 @@ public class Settings { public String cam4Username = ""; public String camsodaPassword = ""; public String camsodaUsername = ""; + public String chaturbatePassword = ""; + public String chaturbateUsername = ""; public String chaturbateBaseUrl = "https://chaturbate.com"; public boolean chooseStreamQuality = false; public String colorAccent = "#FFFFFF"; @@ -61,8 +70,8 @@ public class Settings { public int httpSecurePort = 8443; public String httpServer = "localhost"; public int httpTimeout = 10000; - public String httpUserAgent = "Mozilla/5.0 Gecko/20100101 Firefox/73.0"; - public String httpUserAgentMobile = "Mozilla/5.0 (Android 9.0; Mobile; rv:73.0) Gecko/63.0 Firefox/73.0"; + public String httpUserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:82.0) Gecko/20100101 Firefox/82.0"; + public String httpUserAgentMobile = "Mozilla/5.0 (Android 9.0; Mobile; rv:82.0) Gecko/82.0 Firefox/82.0"; public byte[] key = null; public String lastDownloadDir = ""; public String livejasminBaseUrl = "https://www.livejasmin.com"; @@ -71,6 +80,7 @@ public class Settings { public String livejasminUsername = ""; public boolean livePreviews = false; public boolean localRecording = true; + public boolean logFFmpegOutput = false; public int minimumResolution = 0; public int maximumResolution = 8640; public int maximumResolutionPlayer = 0; @@ -90,10 +100,13 @@ public class Settings { public Map modelNotes = new HashMap<>(); public List models = new ArrayList<>(); public List modelsIgnored = new ArrayList<>(); + public boolean monitorClipboard = false; public int onlineCheckIntervalInSecs = 60; public boolean onlineCheckSkipsPausedModels = false; public int overviewUpdateIntervalInSecs = 10; - public String password = ""; // chaturbate password TODO maybe rename this onetime + @Deprecated + public String password = ""; + @Deprecated public String postProcessing = ""; public int postProcessingThreads = 2; public List postProcessors = new ArrayList<>(); @@ -120,7 +133,11 @@ public class Settings { public String showupUsername = ""; public String showupPassword = ""; public boolean singlePlayer = true; + @Deprecated public int splitRecordings = 0; + public SplitStrategy splitStrategy = SplitStrategy.DONT; + public int splitRecordingsAfterSecs = 0; + public long splitRecordingsBiggerThanBytes = 0; public String startTab = "Settings"; public String streamatePassword = ""; public String streamateUsername = ""; @@ -130,7 +147,8 @@ public class Settings { public boolean transportLayerSecurity = true; public int thumbWidth = 180; public boolean updateThumbnails = true; - public String username = ""; // chaturbate username TODO maybe rename this onetime + @Deprecated + public String username = ""; public int windowHeight = 800; public boolean windowMaximized = false; public int windowWidth = 1340; diff --git a/common/src/main/java/ctbrec/event/ExecuteProgram.java b/common/src/main/java/ctbrec/event/ExecuteProgram.java index 5bb2b321..4c9caebc 100644 --- a/common/src/main/java/ctbrec/event/ExecuteProgram.java +++ b/common/src/main/java/ctbrec/event/ExecuteProgram.java @@ -7,7 +7,7 @@ import org.slf4j.LoggerFactory; import ctbrec.OS; import ctbrec.event.EventHandlerConfiguration.ActionConfiguration; -import ctbrec.io.StreamRedirectThread; +import ctbrec.io.StreamRedirector; public class ExecuteProgram extends Action { @@ -38,11 +38,11 @@ public class ExecuteProgram extends Action { // create threads, which read stdout and stderr of the player process. these are needed, // because otherwise the internal buffer for these streams fill up and block the process - Thread std = new Thread(new StreamRedirectThread(process.getInputStream(), System.out)); + Thread std = new Thread(new StreamRedirector(process.getInputStream(), System.out)); std.setName("Player stdout pipe"); std.setDaemon(true); std.start(); - Thread err = new Thread(new StreamRedirectThread(process.getErrorStream(), System.err)); + Thread err = new Thread(new StreamRedirector(process.getErrorStream(), System.err)); err.setName("Player stderr pipe"); err.setDaemon(true); err.start(); diff --git a/common/src/main/java/ctbrec/io/IoUtils.java b/common/src/main/java/ctbrec/io/IoUtils.java index 644e540b..a76d6879 100644 --- a/common/src/main/java/ctbrec/io/IoUtils.java +++ b/common/src/main/java/ctbrec/io/IoUtils.java @@ -2,7 +2,13 @@ package ctbrec.io; import java.io.File; import java.io.IOException; +import java.nio.file.FileVisitOption; +import java.nio.file.FileVisitResult; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.EnumSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,4 +56,27 @@ public class IoUtils { throw new IOException("Couldn't delete all files in " + directory); } } + + public static long getDirectorySize(File dir) { + final long[] size = { 0 }; + int maxDepth = 1; // Don't expect subdirs, so don't even try + try { + Files.walkFileTree(dir.toPath(), EnumSet.noneOf(FileVisitOption.class), maxDepth, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + size[0] += attrs.size(); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + // Ignore file access issues + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + LOG.error("Couldn't determine size of directory {}", dir, e); + } + return size[0]; + } } diff --git a/common/src/main/java/ctbrec/io/StreamRedirectThread.java b/common/src/main/java/ctbrec/io/StreamRedirector.java similarity index 83% rename from common/src/main/java/ctbrec/io/StreamRedirectThread.java rename to common/src/main/java/ctbrec/io/StreamRedirector.java index 64983e1b..7e4cdcf4 100644 --- a/common/src/main/java/ctbrec/io/StreamRedirectThread.java +++ b/common/src/main/java/ctbrec/io/StreamRedirector.java @@ -6,13 +6,13 @@ import java.io.OutputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class StreamRedirectThread implements Runnable { - private static final Logger LOG = LoggerFactory.getLogger(StreamRedirectThread.class); +public class StreamRedirector implements Runnable { + private static final Logger LOG = LoggerFactory.getLogger(StreamRedirector.class); private InputStream in; private OutputStream out; - public StreamRedirectThread(InputStream in, OutputStream out) { + public StreamRedirector(InputStream in, OutputStream out) { super(); this.in = in; this.out = out; diff --git a/common/src/main/java/ctbrec/recorder/FFmpeg.java b/common/src/main/java/ctbrec/recorder/FFmpeg.java new file mode 100644 index 00000000..fdfa3bb1 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/FFmpeg.java @@ -0,0 +1,135 @@ +package ctbrec.recorder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.function.Consumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.io.DevNull; +import ctbrec.io.StreamRedirector; +import ctbrec.recorder.download.ProcessExitedUncleanException; + +public class FFmpeg { + + private static final Logger LOG = LoggerFactory.getLogger(FFmpeg.class); + + private Process process; + private boolean logOutput = false; + private Consumer startCallback; + private Consumer exitCallback; + private File ffmpegLog = null; + private OutputStream ffmpegLogStream; + private Thread stdout; + private Thread stderr; + + private FFmpeg() {} + + public void exec(String[] cmdline, String[] env, File executionDir) throws IOException, InterruptedException { + LOG.debug("FFmpeg command line: {}", Arrays.toString(cmdline)); + process = Runtime.getRuntime().exec(cmdline, env, executionDir); + afterStart(); + int exitCode = process.waitFor(); + afterExit(exitCode); + } + + private void afterStart() throws IOException { + notifyStartCallback(process); + setupLogging(); + } + + private void afterExit(int exitCode) throws InterruptedException, IOException { + LOG.debug("FFmpeg exit code was {}", exitCode); + notifyExitCallback(exitCode); + stdout.join(); + stderr.join(); + ffmpegLogStream.flush(); + ffmpegLogStream.close(); + if (exitCode != 1) { + if (ffmpegLog != null && ffmpegLog.exists()) { + Files.delete(ffmpegLog.toPath()); + } + } else { + throw new ProcessExitedUncleanException("FFmpeg exit code was " + exitCode); + } + } + + private void setupLogging() throws IOException { + if (logOutput) { + if (ffmpegLog == null) { + ffmpegLog = File.createTempFile("ffmpeg_", ".log"); + } + LOG.debug("Logging FFmpeg output to {}", ffmpegLog); + ffmpegLog.deleteOnExit(); + ffmpegLogStream = new FileOutputStream(ffmpegLog); + } else { + ffmpegLogStream = new DevNull(); + } + stdout = new Thread(new StreamRedirector(process.getInputStream(), ffmpegLogStream)); + stderr = new Thread(new StreamRedirector(process.getErrorStream(), ffmpegLogStream)); + stdout.start(); + stderr.start(); + } + + private void notifyStartCallback(Process process) { + try { + startCallback.accept(process); + } catch(Exception e) { + LOG.error("Exception in onStart callback", e); + } + } + + private void notifyExitCallback(int exitCode) { + try { + exitCallback.accept(exitCode); + } catch(Exception e) { + LOG.error("Exception in onExit callback", e); + } + } + + public int waitFor() throws InterruptedException { + return process.waitFor(); + } + + public static class Builder { + private boolean logOutput = false; + private File logFile; + private Consumer startCallback; + private Consumer exitCallback; + + public Builder logOutput(boolean logOutput) { + this.logOutput = logOutput; + return this; + } + + public Builder logFile(File logFile) { + this.logFile = logFile; + return this; + } + + public Builder onStarted(Consumer callback) { + this.startCallback = callback; + return this; + } + + public Builder onExit(Consumer callback) { + this.exitCallback = callback; + return this; + } + + public FFmpeg build() { + FFmpeg instance = new FFmpeg(); + instance.logOutput = logOutput; + instance.startCallback = startCallback != null ? startCallback : p -> {}; + instance.exitCallback = exitCallback != null ? exitCallback : exitCode -> {}; + instance.ffmpegLog = logFile; + return instance; + } + } + +} diff --git a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java index 90a38395..7e1a3c96 100644 --- a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java @@ -159,6 +159,7 @@ public class NextGenLocalRecorder implements Recorder { ppPool.submit(() -> { try { setRecordingStatus(recording, State.POST_PROCESSING); + recording.refresh(); recordingManager.saveRecording(recording); recording.postprocess(); List postProcessors = config.getSettings().postProcessors; diff --git a/common/src/main/java/ctbrec/recorder/download/Download.java b/common/src/main/java/ctbrec/recorder/download/Download.java index 171a8d98..b5497a1d 100644 --- a/common/src/main/java/ctbrec/recorder/download/Download.java +++ b/common/src/main/java/ctbrec/recorder/download/Download.java @@ -38,4 +38,6 @@ public interface Download extends Serializable { * @return true, if the recording is only a single file */ public boolean isSingleFile(); + + public long getSizeInByte(); } diff --git a/common/src/main/java/ctbrec/recorder/download/SplittingStrategy.java b/common/src/main/java/ctbrec/recorder/download/SplittingStrategy.java new file mode 100644 index 00000000..aba7eaab --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/SplittingStrategy.java @@ -0,0 +1,9 @@ +package ctbrec.recorder.download; + +import ctbrec.Settings; + +public interface SplittingStrategy { + + void init(Settings settings); + boolean splitNecessary(Download download); +} diff --git a/common/src/main/java/ctbrec/recorder/download/VideoLengthDetector.java b/common/src/main/java/ctbrec/recorder/download/VideoLengthDetector.java index c06f0adc..450c3574 100644 --- a/common/src/main/java/ctbrec/recorder/download/VideoLengthDetector.java +++ b/common/src/main/java/ctbrec/recorder/download/VideoLengthDetector.java @@ -13,7 +13,7 @@ import org.slf4j.LoggerFactory; import ctbrec.OS; import ctbrec.io.DevNull; -import ctbrec.io.StreamRedirectThread; +import ctbrec.io.StreamRedirector; public class VideoLengthDetector { private static final Logger LOG = LoggerFactory.getLogger(VideoLengthDetector.class); @@ -39,8 +39,8 @@ public class VideoLengthDetector { Process ffmpeg = Runtime.getRuntime().exec(cmdline, new String[0], videoFile.getParentFile()); int exitCode = 1; ByteArrayOutputStream stdErrBuffer = new ByteArrayOutputStream(); - Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), new DevNull())); - Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), stdErrBuffer)); + Thread stdout = new Thread(new StreamRedirector(ffmpeg.getInputStream(), new DevNull())); + Thread stderr = new Thread(new StreamRedirector(ffmpeg.getErrorStream(), stdErrBuffer)); stdout.start(); stderr.start(); exitCode = ffmpeg.waitFor(); diff --git a/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java b/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java index 9f57d93a..e28a6ba6 100644 --- a/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java @@ -37,6 +37,7 @@ import ctbrec.Recording; import ctbrec.io.BandwidthMeter; import ctbrec.io.HttpClient; import ctbrec.io.HttpException; +import ctbrec.io.IoUtils; import ctbrec.recorder.download.AbstractDownload; import ctbrec.recorder.download.dash.SegmentTimelineType.S; import ctbrec.recorder.download.hls.PostProcessingException; @@ -416,4 +417,9 @@ public class DashDownload extends AbstractDownload { return false; } + @Override + public long getSizeInByte() { + return IoUtils.getDirectorySize(downloadDir.toFile()); + } + } diff --git a/common/src/main/java/ctbrec/recorder/download/dash/FfmpegMuxer.java b/common/src/main/java/ctbrec/recorder/download/dash/FfmpegMuxer.java index 47723943..7b23b6ac 100644 --- a/common/src/main/java/ctbrec/recorder/download/dash/FfmpegMuxer.java +++ b/common/src/main/java/ctbrec/recorder/download/dash/FfmpegMuxer.java @@ -12,7 +12,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.OS; -import ctbrec.io.StreamRedirectThread; +import ctbrec.io.StreamRedirector; import ctbrec.recorder.download.ProcessExitedUncleanException; public class FfmpegMuxer { @@ -99,8 +99,8 @@ public class FfmpegMuxer { // @formatter:on LOG.debug("Command line: {}", Arrays.toString(cmdline)); Process ffmpeg = Runtime.getRuntime().exec(cmdline); - Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), muxLogStream)); - Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), muxLogStream)); + Thread stdout = new Thread(new StreamRedirector(ffmpeg.getInputStream(), muxLogStream)); + Thread stderr = new Thread(new StreamRedirector(ffmpeg.getErrorStream(), muxLogStream)); stdout.start(); stderr.start(); int exitCode = ffmpeg.waitFor(); diff --git a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java index 4af8d12d..9cd2101e 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java @@ -44,6 +44,7 @@ import com.iheartradio.m3u8.data.TrackData; import ctbrec.Config; import ctbrec.Model; import ctbrec.Recording.State; +import ctbrec.Settings; import ctbrec.UnknownModel; import ctbrec.io.BandwidthMeter; import ctbrec.io.HttpClient; @@ -51,6 +52,7 @@ import ctbrec.io.HttpException; import ctbrec.recorder.PlaylistGenerator.InvalidPlaylistException; import ctbrec.recorder.download.AbstractDownload; import ctbrec.recorder.download.HttpHeaderFactory; +import ctbrec.recorder.download.SplittingStrategy; import ctbrec.recorder.download.StreamSource; import ctbrec.sites.Site; import okhttp3.Request; @@ -67,6 +69,7 @@ public abstract class AbstractHlsDownload extends AbstractDownload { protected Model model = new UnknownModel(); protected transient LinkedBlockingQueue downloadQueue = new LinkedBlockingQueue<>(50); protected transient ExecutorService downloadThreadPool = new ThreadPoolExecutor(0, 5, 20, TimeUnit.SECONDS, downloadQueue, createThreadFactory()); + protected transient SplittingStrategy splittingStrategy; protected State state = State.UNKNOWN; private int playlistEmptyCount = 0; @@ -235,4 +238,27 @@ public abstract class AbstractHlsDownload extends AbstractDownload { this.url = url; } } + + protected SplittingStrategy initSplittingStrategy(Settings settings) { + SplittingStrategy strategy; + switch (settings.splitStrategy) { + case TIME: + strategy = new TimeSplittingStrategy(); + break; + case SIZE: + strategy = new SizeSplittingStrategy(); + break; + case TIME_OR_SIZE: + SplittingStrategy timeSplittingStrategy = new TimeSplittingStrategy(); + SplittingStrategy sizeSplittingStrategy = new SizeSplittingStrategy(); + strategy = new CombinedSplittingStrategy(timeSplittingStrategy, sizeSplittingStrategy); + break; + case DONT: + default: + strategy = new NoopSplittingStrategy(); + break; + } + strategy.init(settings); + return strategy; + } } diff --git a/common/src/main/java/ctbrec/recorder/download/hls/CombinedSplittingStrategy.java b/common/src/main/java/ctbrec/recorder/download/hls/CombinedSplittingStrategy.java new file mode 100644 index 00000000..a4f9284c --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/hls/CombinedSplittingStrategy.java @@ -0,0 +1,31 @@ +package ctbrec.recorder.download.hls; + +import ctbrec.Settings; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.SplittingStrategy; + +public class CombinedSplittingStrategy implements SplittingStrategy { + + private SplittingStrategy[] splittingStrategies; + + public CombinedSplittingStrategy(SplittingStrategy... splittingStrategies) { + this.splittingStrategies = splittingStrategies; + } + + @Override + public void init(Settings settings) { + for (SplittingStrategy splittingStrategy : splittingStrategies) { + splittingStrategy.init(settings); + } + } + + @Override + public boolean splitNecessary(Download download) { + for (SplittingStrategy splittingStrategy : splittingStrategies) { + if (splittingStrategy.splitNecessary(download)) { + return true; + } + } + return false; + } +} diff --git a/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java index 1febf103..c58239d6 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java @@ -24,7 +24,7 @@ import ctbrec.Model; import ctbrec.OS; import ctbrec.Recording; import ctbrec.io.HttpClient; -import ctbrec.io.StreamRedirectThread; +import ctbrec.io.StreamRedirector; import ctbrec.recorder.download.ProcessExitedUncleanException; /** @@ -73,8 +73,8 @@ public class FFmpegDownload extends AbstractHlsDownload { File ffmpegLog = File.createTempFile(targetFile.getName(), ".log"); ffmpegLog.deleteOnExit(); try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) { - Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream)); - Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream)); + Thread stdout = new Thread(new StreamRedirector(ffmpeg.getInputStream(), mergeLogStream)); + Thread stderr = new Thread(new StreamRedirector(ffmpeg.getErrorStream(), mergeLogStream)); stdout.start(); stderr.start(); exitCode = ffmpeg.waitFor(); @@ -145,4 +145,9 @@ public class FFmpegDownload extends AbstractHlsDownload { return true; } + @Override + public long getSizeInByte() { + return getTarget().length(); + } + } diff --git a/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java index 8491971a..9a26ae68 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java @@ -13,7 +13,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.text.DecimalFormat; import java.text.NumberFormat; -import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -40,6 +39,7 @@ import ctbrec.Recording.State; import ctbrec.io.BandwidthMeter; import ctbrec.io.HttpClient; import ctbrec.io.HttpException; +import ctbrec.io.IoUtils; import ctbrec.recorder.PlaylistGenerator; import ctbrec.recorder.download.HttpHeaderFactory; import okhttp3.Request; @@ -48,6 +48,8 @@ import okhttp3.Response; public class HlsDownload extends AbstractHlsDownload { + private static final int TEN_SECONDS = 10_000; + private static final Logger LOG = LoggerFactory.getLogger(HlsDownload.class); protected transient Path downloadDir; @@ -55,8 +57,8 @@ public class HlsDownload extends AbstractHlsDownload { private int segmentCounter = 1; private NumberFormat nf = new DecimalFormat("000000"); private transient AtomicBoolean downloadFinished = new AtomicBoolean(false); - private ZonedDateTime splitRecStartTime; protected transient Config config; + private transient int waitFactor = 1; public HlsDownload(HttpClient client) { super(client); @@ -71,6 +73,7 @@ public class HlsDownload extends AbstractHlsDownload { String formattedStartTime = formatter.format(ZonedDateTime.ofInstant(this.startTime, ZoneId.systemDefault())); Path modelDir = FileSystems.getDefault().getPath(config.getSettings().recordingsDir, model.getSanitizedNamed()); downloadDir = FileSystems.getDefault().getPath(modelDir.toString(), formattedStartTime); + splittingStrategy = initSplittingStrategy(config.getSettings()); } @Override @@ -78,7 +81,6 @@ public class HlsDownload extends AbstractHlsDownload { try { running = true; Thread.currentThread().setName("Download " + model.getName()); - splitRecStartTime = ZonedDateTime.now(); String segments = getSegmentPlaylistUrl(model); if (segments != null) { if (!downloadDir.toFile().exists()) { @@ -86,45 +88,13 @@ public class HlsDownload extends AbstractHlsDownload { } int lastSegmentNumber = 0; int nextSegmentNumber = 0; - int waitFactor = 1; while (running) { SegmentPlaylist playlist = getNextSegments(segments); emptyPlaylistCheck(playlist); - if (nextSegmentNumber > 0 && playlist.seq > nextSegmentNumber) { - waitFactor *= 2; - LOG.warn("Missed segments {} < {} in download for {} - setting wait factor to 1/{}", nextSegmentNumber, playlist.seq, model, - waitFactor); - } - int skip = nextSegmentNumber - playlist.seq; - for (String segment : playlist.segments) { - if (skip > 0) { - skip--; - } else { - URL segmentUrl = new URL(segment); - String prefix = nf.format(segmentCounter++); - SegmentDownload segmentDownload = new SegmentDownload(playlist, segmentUrl, downloadDir, client, prefix); - enqueueDownload(segmentDownload, prefix, segmentUrl); - } - } - - // split recordings - boolean split = splitRecording(); - if (split) { - break; - } - - long waitForMillis = 0; - if (lastSegmentNumber == playlist.seq) { - // playlist didn't change -> wait for at least half the target duration - waitForMillis = (long) playlist.targetDuration * 1000 / waitFactor; - LOG.trace("Playlist didn't change... waiting for {}ms", waitForMillis); - } else { - // playlist did change -> wait for at least last segment duration - waitForMillis = 1; - LOG.trace("Playlist changed... waiting for {}ms", waitForMillis); - } - - waitSomeTime(waitForMillis); + logMissedSegments(playlist, nextSegmentNumber); + enqueueNewSegments(playlist, nextSegmentNumber); + splitRecordingIfNecessary(); + waitSomeTime(playlist, lastSegmentNumber, waitFactor); // this if check makes sure, that we don't decrease nextSegment. for some reason // streamate playlists sometimes jump back. e.g. max sequence = 79 -> 80 -> 79 @@ -137,52 +107,103 @@ public class HlsDownload extends AbstractHlsDownload { throw new IOException("Couldn't determine segments uri"); } } catch (ParseException e) { - throw new IOException("Couldn't parse HLS playlist:\n" + e.getInput(), e); + throw new IOException("Couldn't parse HLS playlist for model " + model + "\n" + e.getInput(), e); } catch (PlaylistException e) { - throw new IOException("Couldn't parse HLS playlist", e); + throw new IOException("Couldn't parse HLS playlist for model " + model, e); } catch (EOFException e) { // end of playlist reached LOG.debug("Reached end of playlist for model {}", model); } catch (HttpException e) { - if (e.getResponseCode() == 404) { - ctbrec.Model.State modelState; - try { - modelState = model.getOnlineState(false); - } catch (ExecutionException e1) { - modelState = ctbrec.Model.State.UNKNOWN; - } - LOG.info("Playlist not found (404). Model {} probably went offline. Model state: {}", model, modelState); - waitSomeTime(10_000); - } else if (e.getResponseCode() == 403) { - ctbrec.Model.State modelState; - try { - modelState = model.getOnlineState(false); - } catch (ExecutionException e1) { - modelState = ctbrec.Model.State.UNKNOWN; - } - LOG.info("Playlist access forbidden (403). Model {} probably went private or offline. Model state: {}", model, modelState); - waitSomeTime(10_000); - } else { - throw e; - } + handleHttpException(e); } catch (Exception e) { throw new IOException("Couldn't download segment", e); } finally { - downloadThreadPool.shutdown(); - try { - LOG.debug("Waiting for last segments for {}", model); - downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - downloadFinished.set(true); - synchronized (downloadFinished) { - downloadFinished.notifyAll(); - } - LOG.debug("Download for {} terminated", model); + finalizeDownload(); } } + private void finalizeDownload() { + downloadThreadPool.shutdown(); + try { + LOG.debug("Waiting for last segments for {}", model); + downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + downloadFinished.set(true); + synchronized (downloadFinished) { + downloadFinished.notifyAll(); + } + LOG.debug("Download for {} terminated", model); + } + + private void handleHttpException(HttpException e) throws IOException { + if (e.getResponseCode() == 404) { + ctbrec.Model.State modelState; + try { + modelState = model.getOnlineState(false); + } catch (ExecutionException e1) { + modelState = ctbrec.Model.State.UNKNOWN; + } + LOG.info("Playlist not found (404). Model {} probably went offline. Model state: {}", model, modelState); + waitSomeTime(TEN_SECONDS); + } else if (e.getResponseCode() == 403) { + ctbrec.Model.State modelState; + try { + modelState = model.getOnlineState(false); + } catch (ExecutionException e1) { + modelState = ctbrec.Model.State.UNKNOWN; + } + LOG.info("Playlist access forbidden (403). Model {} probably went private or offline. Model state: {}", model, modelState); + waitSomeTime(TEN_SECONDS); + } else { + throw e; + } + } + + private void splitRecordingIfNecessary() { + if (splittingStrategy.splitNecessary(this)) { + internalStop(); + } + } + + private void enqueueNewSegments(SegmentPlaylist playlist, int nextSegmentNumber) throws IOException, ExecutionException, InterruptedException { + int skip = nextSegmentNumber - playlist.seq; + for (String segment : playlist.segments) { + if (skip > 0) { + skip--; + } else { + URL segmentUrl = new URL(segment); + String prefix = nf.format(segmentCounter++); + SegmentDownload segmentDownload = new SegmentDownload(playlist, segmentUrl, downloadDir, client, prefix); + enqueueDownload(segmentDownload, prefix, segmentUrl); + } + } + } + + private void logMissedSegments(SegmentPlaylist playlist, int nextSegmentNumber) { + if (nextSegmentNumber > 0 && playlist.seq > nextSegmentNumber) { + waitFactor *= 2; + LOG.warn("Missed segments {} < {} in download for {} - setting wait factor to 1/{}", nextSegmentNumber, playlist.seq, model, + waitFactor); + } + } + + private void waitSomeTime(SegmentPlaylist playlist, int lastSegmentNumber, int waitFactor) { + long waitForMillis = 0; + if (lastSegmentNumber == playlist.seq) { + // playlist didn't change -> wait for at least half the target duration + waitForMillis = (long) playlist.targetDuration * 1000 / waitFactor; + LOG.trace("Playlist didn't change... waiting for {}ms", waitForMillis); + } else { + // playlist did change -> wait for at least last segment duration + waitForMillis = 1; + LOG.trace("Playlist changed... waiting for {}ms", waitForMillis); + } + + waitSomeTime(waitForMillis); + } + private void enqueueDownload(SegmentDownload segmentDownload, String prefix, URL segmentUrl) throws IOException, ExecutionException, InterruptedException { try { downloadThreadPool.submit(segmentDownload); @@ -228,18 +249,6 @@ public class HlsDownload extends AbstractHlsDownload { } - private boolean splitRecording() { - if (config.getSettings().splitRecordings > 0) { - Duration recordingDuration = Duration.between(splitRecStartTime, ZonedDateTime.now()); - long seconds = recordingDuration.getSeconds(); - if (seconds >= config.getSettings().splitRecordings) { - internalStop(); - return true; - } - } - return false; - } - @Override public void stop() { if (running) { @@ -334,4 +343,9 @@ public class HlsDownload extends AbstractHlsDownload { public boolean isSingleFile() { return false; } + + @Override + public long getSizeInByte() { + return IoUtils.getDirectorySize(getTarget()); + } } diff --git a/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java index ed8f9d70..00713d63 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java @@ -4,17 +4,13 @@ import static java.util.Optional.*; import java.io.EOFException; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.MalformedURLException; import java.net.URL; import java.nio.file.Files; -import java.time.Duration; import java.time.Instant; -import java.time.ZonedDateTime; -import java.util.Arrays; import java.util.HashMap; import java.util.LinkedList; import java.util.Queue; @@ -40,7 +36,7 @@ import ctbrec.Recording; import ctbrec.io.BandwidthMeter; import ctbrec.io.HttpClient; import ctbrec.io.HttpException; -import ctbrec.io.StreamRedirectThread; +import ctbrec.recorder.FFmpeg; import ctbrec.recorder.ProgressListener; import ctbrec.recorder.download.HttpHeaderFactory; import ctbrec.recorder.download.ProcessExitedUncleanException; @@ -53,14 +49,15 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { private static final long serialVersionUID = 1L; private static final Logger LOG = LoggerFactory.getLogger(MergedFfmpegHlsDownload.class); private static final boolean IGNORE_CACHE = true; - private ZonedDateTime splitRecStartTime; private File targetFile; private transient Config config; - private transient Process ffmpeg; + private transient Process ffmpegProcess; private transient OutputStream ffmpegStdIn; protected transient Thread ffmpegThread; private transient Object ffmpegStartMonitor = new Object(); - private Queue> downloads = new LinkedList<>(); + private transient Queue> downloads = new LinkedList<>(); + private transient int lastSegment = 0; + private transient int nextSegment = 0; public MergedFfmpegHlsDownload(HttpClient client) { super(client); @@ -73,6 +70,7 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { this.model = model; String fileSuffix = config.getSettings().ffmpegFileSuffix; targetFile = config.getFileForRecording(model, fileSuffix, startTime); + splittingStrategy = initSplittingStrategy(config.getSettings()); } @Override @@ -86,7 +84,6 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { running = true; Thread.currentThread().setName("Download " + model.getName()); super.startTime = Instant.now(); - splitRecStartTime = ZonedDateTime.now(); String segments = getSegmentPlaylistUrl(model); @@ -94,17 +91,17 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { startFfmpegProcess(targetFile); synchronized (ffmpegStartMonitor) { int tries = 0; - while (ffmpeg == null && tries++ < 15) { + while (ffmpegProcess == null && tries++ < 15) { LOG.debug("Waiting for FFmpeg to spawn to record {}", model.getName()); ffmpegStartMonitor.wait(1000); } } - if (ffmpeg == null) { + if (ffmpegProcess == null) { throw new ProcessExitedUncleanException("Couldn't spawn FFmpeg"); } else { LOG.debug("Starting to download segments"); - downloadSegments(segments, true); + startDownloadLoop(segments, true); ffmpegThread.join(); LOG.debug("FFmpeg thread terminated"); } @@ -134,47 +131,20 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { private void startFfmpegProcess(File target) { ffmpegThread = new Thread(() -> { try { - String[] args = config.getSettings().ffmpegMergedDownloadArgs.split(" "); - String[] argsPlusFile = new String[args.length + 3]; - int i = 0; - argsPlusFile[i++] = "-i"; - argsPlusFile[i++] = "-"; - System.arraycopy(args, 0, argsPlusFile, i, args.length); - argsPlusFile[argsPlusFile.length - 1] = target.getAbsolutePath(); - String[] cmdline = OS.getFFmpegCommand(argsPlusFile); - - LOG.debug("Command line: {}", Arrays.toString(cmdline)); - ffmpeg = Runtime.getRuntime().exec(cmdline, new String[0], target.getParentFile()); - synchronized (ffmpegStartMonitor) { - ffmpegStartMonitor.notifyAll(); - } - ffmpegStdIn = ffmpeg.getOutputStream(); - int exitCode = 1; - File ffmpegLog = File.createTempFile(target.getName(), ".log"); - ffmpegLog.deleteOnExit(); - try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) { - Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream)); - Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream)); - stdout.start(); - stderr.start(); - exitCode = ffmpeg.waitFor(); - LOG.debug("FFmpeg exited with code {}", exitCode); - stdout.join(); - stderr.join(); - mergeLogStream.flush(); - } - if (exitCode != 1) { - if (ffmpegLog.exists()) { - Files.delete(ffmpegLog.toPath()); - } - } else { - if (running) { - LOG.info("FFmpeg exit code was {}. Logfile: {}", exitCode, ffmpegLog.getAbsolutePath()); - throw new ProcessExitedUncleanException("FFmpeg exit code was " + exitCode); - } - } + String[] cmdline = prepareCommandLine(target); + FFmpeg ffmpeg = new FFmpeg.Builder() + .logOutput(config.getSettings().logFFmpegOutput) + .onStarted(p -> { + ffmpegProcess = p; + ffmpegStdIn = ffmpegProcess.getOutputStream(); + synchronized (ffmpegStartMonitor) { + ffmpegStartMonitor.notifyAll(); + } + }) + .build(); + ffmpeg.exec(cmdline, new String[0], target.getParentFile()); } catch (IOException | ProcessExitedUncleanException e) { - LOG.error("Error in FFMpeg thread", e); + LOG.error("Error in FFmpeg thread", e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); if (running) { @@ -187,52 +157,23 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { ffmpegThread.start(); } - protected void downloadSegments(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException { - int lastSegment = 0; - int nextSegment = 0; + private String[] prepareCommandLine(File target) { + String[] args = config.getSettings().ffmpegMergedDownloadArgs.split(" "); + String[] argsPlusFile = new String[args.length + 3]; + int i = 0; + argsPlusFile[i++] = "-i"; + argsPlusFile[i++] = "-"; + System.arraycopy(args, 0, argsPlusFile, i, args.length); + argsPlusFile[argsPlusFile.length - 1] = target.getAbsolutePath(); + return OS.getFFmpegCommand(argsPlusFile); + } + + protected void startDownloadLoop(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException { while (running) { try { - SegmentPlaylist lsp = getNextSegments(segmentPlaylistUri); - emptyPlaylistCheck(lsp); - - // download new segments - long downloadStart = System.currentTimeMillis(); - if (livestreamDownload) { - downloadNewSegments(lsp, nextSegment); - } else { - downloadRecording(lsp); - } - long downloadTookMillis = System.currentTimeMillis() - downloadStart; - - // download segments, which might have been skipped - if (nextSegment > 0 && lsp.seq > nextSegment) { - LOG.warn("Missed segments {} < {} in download for {}. Download took {}ms. Playlist is {}sec", nextSegment, lsp.seq, lsp.url, - downloadTookMillis, lsp.totalDuration); - } - - if (livestreamDownload) { - // split up the recording, if configured - boolean split = splitRecording(); - if (split) { - break; - } - - // wait some time until requesting the segment playlist again to not hammer the server - waitForNewSegments(lsp, lastSegment, downloadTookMillis); - - lastSegment = lsp.seq; - nextSegment = lastSegment + lsp.segments.size(); - } else { - break; - } + downloadSegments(segmentPlaylistUri, livestreamDownload); } catch (HttpException e) { - if (e.getResponseCode() == 404) { - LOG.debug("Playlist not found (404). Model {} probably went offline", model); - } else if (e.getResponseCode() == 403) { - LOG.debug("Playlist access forbidden (403). Model {} probably went private or offline", model); - } else { - LOG.info("Unexpected error while downloading {}", model, e); - } + logHttpException(e); running = false; } catch (MalformedURLException e) { LOG.info("Malformed URL {} - {}", model, segmentPlaylistUri, e); @@ -245,6 +186,54 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { ffmpegThread.interrupt(); } + private void downloadSegments(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException, ExecutionException { + SegmentPlaylist lsp = getNextSegments(segmentPlaylistUri); + emptyPlaylistCheck(lsp); + + // download new segments + long downloadStart = System.currentTimeMillis(); + if (livestreamDownload) { + downloadNewSegments(lsp, nextSegment); + } else { + downloadRecording(lsp); + } + long downloadTookMillis = System.currentTimeMillis() - downloadStart; + + // download segments, which might have been skipped + if (nextSegment > 0 && lsp.seq > nextSegment) { + LOG.warn("Missed segments {} < {} in download for {}. Download took {}ms. Playlist is {}sec", nextSegment, lsp.seq, lsp.url, + downloadTookMillis, lsp.totalDuration); + } + + if (livestreamDownload) { + splitRecordingIfNecessary(); + + // wait some time until requesting the segment playlist again to not hammer the server + waitForNewSegments(lsp, lastSegment, downloadTookMillis); + + lastSegment = lsp.seq; + nextSegment = lastSegment + lsp.segments.size(); + } else { + running = false; + } + } + + private void logHttpException(HttpException e) { + if (e.getResponseCode() == 404) { + LOG.debug("Playlist not found (404). Model {} probably went offline", model); + } else if (e.getResponseCode() == 403) { + LOG.debug("Playlist access forbidden (403). Model {} probably went private or offline", model); + } else { + LOG.info("Unexpected error while downloading {}", model, e); + } + } + + protected void splitRecordingIfNecessary() { + if (splittingStrategy.splitNecessary(this)) { + internalStop(); + } + } + private void downloadRecording(SegmentPlaylist lsp) throws IOException { for (String segment : lsp.segments) { URL segmentUrl = new URL(segment); @@ -292,33 +281,40 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { } catch (CancellationException e) { LOG.info("Segment download cancelled"); } catch (ExecutionException e) { - Throwable cause = e.getCause(); - if (cause instanceof MissingSegmentException) { - if (model != null && !isModelOnline()) { - LOG.debug("Error while downloading segment, because model {} is offline. Stopping now", model.getName()); - running = false; - } else { - LOG.debug("Segment not available, but model {} still online. Going on", ofNullable(model).map(Model::getName).orElse("n/a")); - } - } else if (cause instanceof HttpException) { - HttpException he = (HttpException) cause; - if (model != null && !isModelOnline()) { - LOG.debug("Error {} while downloading segment, because model {} is offline. Stopping now", he.getResponseCode(), model.getName()); - running = false; - } else { - if (he.getResponseCode() == 404) { - LOG.info("Playlist for {} not found [HTTP 404]. Stopping now", ofNullable(model).map(Model::getName).orElse("n/a")); - running = false; - } else if (he.getResponseCode() == 403) { - LOG.info("Playlist for {} not accessible [HTTP 403]. Stopping now", ofNullable(model).map(Model::getName).orElse("n/a")); - running = false; - } else { - throw he; - } - } - } else { - throw e; - } + handleExecutionExceptione(e); + } + } + } + + private void handleExecutionExceptione(ExecutionException e) throws HttpException, ExecutionException { + Throwable cause = e.getCause(); + if (cause instanceof MissingSegmentException) { + if (model != null && !isModelOnline()) { + LOG.debug("Error while downloading segment, because model {} is offline. Stopping now", model.getName()); + running = false; + } else { + LOG.debug("Segment not available, but model {} still online. Going on", ofNullable(model).map(Model::getName).orElse("n/a")); + } + } else if (cause instanceof HttpException) { + handleHttpException((HttpException)cause); + } else { + throw e; + } + } + + private void handleHttpException(HttpException he) throws HttpException { + if (model != null && !isModelOnline()) { + LOG.debug("Error {} while downloading segment, because model {} is offline. Stopping now", he.getResponseCode(), model.getName()); + running = false; + } else { + if (he.getResponseCode() == 404) { + LOG.info("Playlist for {} not found [HTTP 404]. Stopping now", ofNullable(model).map(Model::getName).orElse("n/a")); + running = false; + } else if (he.getResponseCode() == 403) { + LOG.info("Playlist for {} not accessible [HTTP 403]. Stopping now", ofNullable(model).map(Model::getName).orElse("n/a")); + running = false; + } else { + throw he; } } } @@ -337,18 +333,6 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { writeSegment(segmentData, 0, segmentData.length); } - protected boolean splitRecording() { - if (config.getSettings().splitRecordings > 0) { - Duration recordingDuration = Duration.between(splitRecStartTime, ZonedDateTime.now()); - long seconds = recordingDuration.getSeconds(); - if (seconds >= config.getSettings().splitRecordings) { - internalStop(); - return true; - } - } - return false; - } - private void waitForNewSegments(SegmentPlaylist lsp, int lastSegment, long downloadTookMillis) { try { long wait = 0; @@ -409,15 +393,15 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { } } - if (ffmpeg != null) { + if (ffmpegProcess != null) { try { - boolean waitFor = ffmpeg.waitFor(45, TimeUnit.SECONDS); - if (!waitFor && ffmpeg.isAlive()) { - ffmpeg.destroy(); - if (ffmpeg.isAlive()) { + boolean waitFor = ffmpegProcess.waitFor(45, TimeUnit.SECONDS); + if (!waitFor && ffmpegProcess.isAlive()) { + ffmpegProcess.destroy(); + if (ffmpegProcess.isAlive()) { LOG.info("FFmpeg didn't terminate. Destroying the process with force!"); - ffmpeg.destroyForcibly(); - ffmpeg = null; + ffmpegProcess.destroyForcibly(); + ffmpegProcess = null; } } } catch (InterruptedException e) { @@ -493,6 +477,7 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { @Override public void postprocess(Recording recording) { + // nothing to do } public void downloadFinishedRecording(String segmentPlaylistUri, File target, ProgressListener progressListener, long sizeInBytes) throws Exception { @@ -549,4 +534,9 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { public boolean isSingleFile() { return true; } + + @Override + public long getSizeInByte() { + return getTarget().length(); + } } diff --git a/common/src/main/java/ctbrec/recorder/download/hls/NoopSplittingStrategy.java b/common/src/main/java/ctbrec/recorder/download/hls/NoopSplittingStrategy.java new file mode 100644 index 00000000..6af9404b --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/hls/NoopSplittingStrategy.java @@ -0,0 +1,19 @@ +package ctbrec.recorder.download.hls; + +import ctbrec.Settings; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.SplittingStrategy; + +public class NoopSplittingStrategy implements SplittingStrategy { + + @Override + public void init(Settings settings) { + // settings not needed + } + + @Override + public boolean splitNecessary(Download download) { + return false; + } + +} diff --git a/common/src/main/java/ctbrec/recorder/download/hls/SizeSplittingStrategy.java b/common/src/main/java/ctbrec/recorder/download/hls/SizeSplittingStrategy.java new file mode 100644 index 00000000..e37eadb0 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/hls/SizeSplittingStrategy.java @@ -0,0 +1,22 @@ +package ctbrec.recorder.download.hls; + +import ctbrec.Settings; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.SplittingStrategy; + +public class SizeSplittingStrategy implements SplittingStrategy { + + private Settings settings; + + @Override + public void init(Settings settings) { + this.settings = settings; + } + + @Override + public boolean splitNecessary(Download download) { + long sizeInByte = download.getSizeInByte(); + return sizeInByte >= settings.splitRecordingsBiggerThanBytes; + } + +} diff --git a/common/src/main/java/ctbrec/recorder/download/hls/TimeSplittingStrategy.java b/common/src/main/java/ctbrec/recorder/download/hls/TimeSplittingStrategy.java new file mode 100644 index 00000000..8fb5a119 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/hls/TimeSplittingStrategy.java @@ -0,0 +1,28 @@ +package ctbrec.recorder.download.hls; + +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import ctbrec.Settings; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.SplittingStrategy; + +public class TimeSplittingStrategy implements SplittingStrategy { + + private Settings settings; + + @Override + public void init(Settings settings) { + this.settings = settings; + } + + @Override + public boolean splitNecessary(Download download) { + ZonedDateTime startTime = download.getStartTime().atZone(ZoneId.systemDefault()); + Duration recordingDuration = Duration.between(startTime, ZonedDateTime.now()); + long seconds = recordingDuration.getSeconds(); + return seconds >= settings.splitRecordingsAfterSecs; + } + +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java b/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java index 3cf306e5..bb2ae13f 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java @@ -67,15 +67,18 @@ public abstract class AbstractPlaceholderAwarePostProcessor extends AbstractPost private String replaceDateTime(Recording rec, String filename, String placeHolder, ZoneId zone) { String pattern = "yyyy-MM-dd_HH-mm-ss"; - Matcher m = Pattern.compile("\\$\\{" + placeHolder + "(?:\\((.*?)\\))?\\}").matcher(filename); - if (m.find()) { + Pattern regex = Pattern.compile("\\$\\{" + placeHolder + "(?:\\((.*?)\\))?\\}"); + Matcher m = regex.matcher(filename); + while (m.find()) { String p = m.group(1); if (p != null) { pattern = p; } + String formattedDate = getDateTime(rec, pattern, zone); + filename = m.replaceFirst(formattedDate); + m = regex.matcher(filename); } - String formattedDate = getDateTime(rec, pattern, zone); - return m.replaceAll(formattedDate); + return filename; } private String getDateTime(Recording rec, String pattern, ZoneId zone) { diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java b/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java index 392f9872..f8a8bace 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java @@ -1,9 +1,7 @@ package ctbrec.recorder.postprocessing; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.nio.file.Files; import java.text.MessageFormat; import java.util.Arrays; import java.util.Locale; @@ -14,7 +12,7 @@ import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.OS; import ctbrec.Recording; -import ctbrec.io.StreamRedirectThread; +import ctbrec.recorder.FFmpeg; import ctbrec.recorder.RecordingManager; public class CreateContactSheet extends AbstractPlaceholderAwarePostProcessor { @@ -80,28 +78,15 @@ public class CreateContactSheet extends AbstractPlaceholderAwarePostProcessor { }; String[] cmdline = OS.getFFmpegCommand(args); LOG.info("Executing {} in working directory {}", Arrays.toString(cmdline), executionDir); - Process ffmpeg = Runtime.getRuntime().exec(cmdline, OS.getEnvironment(), executionDir); - int exitCode = 1; - File ffmpegLog = File.createTempFile("create_contact_sheet_" + rec.getId() + '_', ".log"); - ffmpegLog.deleteOnExit(); - try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) { - Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream)); - Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream)); - stdout.start(); - stderr.start(); - exitCode = ffmpeg.waitFor(); - LOG.debug("FFmpeg exited with code {}", exitCode); - stdout.join(); - stderr.join(); - mergeLogStream.flush(); - } + File ffmpegLog = new File(System.getProperty("java.io.tmpdir"), "create_contact_sheet_" + rec.getId() + ".log"); + FFmpeg ffmpeg = new FFmpeg.Builder() + .logOutput(config.getSettings().logFFmpegOutput) + .logFile(ffmpegLog) + .build(); + ffmpeg.exec(cmdline, OS.getEnvironment(), executionDir); + int exitCode = ffmpeg.waitFor(); rec.getAssociatedFiles().add(output.getCanonicalPath()); - if (exitCode != 1) { - if (ffmpegLog.exists()) { - Files.delete(ffmpegLog.toPath()); - } - } - return true; + return exitCode != 1; } private File getInputFile(Recording rec) { diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Move.java b/common/src/main/java/ctbrec/recorder/postprocessing/Move.java index 8070a080..3b33b736 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Move.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Move.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.nio.file.Files; import java.util.Objects; +import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,7 +16,7 @@ import ctbrec.recorder.RecordingManager; public class Move extends AbstractPlaceholderAwarePostProcessor { - private static final Logger LOG = LoggerFactory.getLogger(Rename.class); + private static final Logger LOG = LoggerFactory.getLogger(Move.class); public static final String PATH_TEMPLATE = "path.template"; public static final String DEFAULT = "${modelSanitizedName}" + File.separatorChar + "${localDateTime}"; @@ -36,7 +37,11 @@ public class Move extends AbstractPlaceholderAwarePostProcessor { } LOG.info("Moving {} to {}", src.getName(), target.getParentFile().getCanonicalPath()); Files.createDirectories(target.getParentFile().toPath()); - Files.move(rec.getPostProcessedFile().toPath(), target.toPath()); + if (rec.getPostProcessedFile().isDirectory()) { + FileUtils.moveDirectory(rec.getPostProcessedFile(), target); + } else { + FileUtils.moveFile(rec.getPostProcessedFile(), target); + } rec.setPostProcessedFile(target); if (Objects.equals(src, rec.getAbsoluteFile())) { rec.setAbsoluteFile(target); diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java b/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java index bc811a54..c18eb389 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java @@ -1,7 +1,6 @@ package ctbrec.recorder.postprocessing; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; import java.util.Arrays; @@ -14,9 +13,8 @@ import ctbrec.Config; import ctbrec.OS; import ctbrec.Recording; import ctbrec.io.IoUtils; -import ctbrec.io.StreamRedirectThread; +import ctbrec.recorder.FFmpeg; import ctbrec.recorder.RecordingManager; -import ctbrec.recorder.download.ProcessExitedUncleanException; public class Remux extends AbstractPostProcessor { @@ -32,70 +30,64 @@ public class Remux extends AbstractPostProcessor { @Override public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + final File inputFile; + if (rec.getPostProcessedFile().isDirectory()) { + inputFile = new File(rec.getPostProcessedFile(), "playlist.m3u8"); + } else { + inputFile = rec.getPostProcessedFile(); + } String fileExt = getConfig().get(FILE_EXT); + File remuxedFile = new File(rec.getPostProcessedFile().getAbsolutePath() + '.' + fileExt); + String[] cmdline = prepareCommandline(inputFile, remuxedFile); + File executionDir = rec.getPostProcessedFile().isDirectory() ? rec.getPostProcessedFile() : rec.getPostProcessedFile().getParentFile(); + LOG.info("Executing {} in working directory {}", Arrays.toString(cmdline), executionDir); + + File ffmpegLog = new File(System.getProperty("java.io.tmpdir"), "remux_" + rec.getId() + ".log"); + FFmpeg ffmpeg = new FFmpeg.Builder() + .logOutput(config.getSettings().logFFmpegOutput) + .logFile(ffmpegLog) + .onExit(exitCode -> finalizeStep(exitCode, rec, inputFile, remuxedFile)) + .build(); + ffmpeg.exec(cmdline, new String[0], executionDir); + int exitCode = ffmpeg.waitFor(); + return exitCode != 1; + } + + private void finalizeStep(int exitCode, Recording rec, File inputFile, File remuxedFile) { + if (exitCode != 1) { + try { + rec.setPostProcessedFile(remuxedFile); + if (inputFile.getName().equals("playlist.m3u8")) { + IoUtils.deleteDirectory(inputFile.getParentFile()); + if (Objects.equals(inputFile.getParentFile(), rec.getAbsoluteFile())) { + rec.setAbsoluteFile(remuxedFile); + } + } else { + Files.deleteIfExists(inputFile.toPath()); + if (Objects.equals(inputFile, rec.getAbsoluteFile())) { + rec.setAbsoluteFile(remuxedFile); + } + } + rec.setSingleFile(true); + rec.setSizeInByte(remuxedFile.length()); + IoUtils.deleteEmptyParents(inputFile.getParentFile()); + rec.getAssociatedFiles().remove(inputFile.getCanonicalPath()); + rec.getAssociatedFiles().add(remuxedFile.getCanonicalPath()); + } catch (IOException e) { + LOG.error("Couldn't finalize remux post-processing step", e); + } + } + } + + private String[] prepareCommandline(File inputFile, File remuxedFile) throws IOException { String[] args = getConfig().get(FFMPEG_ARGS).split(" "); String[] argsPlusFile = new String[args.length + 3]; - File inputFile = rec.getPostProcessedFile(); - if (inputFile.isDirectory()) { - inputFile = new File(inputFile, "playlist.m3u8"); - } int i = 0; argsPlusFile[i++] = "-i"; argsPlusFile[i++] = inputFile.getCanonicalPath(); System.arraycopy(args, 0, argsPlusFile, i, args.length); - File remuxedFile = new File(rec.getPostProcessedFile().getAbsolutePath() + '.' + fileExt); argsPlusFile[argsPlusFile.length - 1] = remuxedFile.getAbsolutePath(); - String[] cmdline = OS.getFFmpegCommand(argsPlusFile); - File executionDir = rec.getPostProcessedFile().isDirectory() ? rec.getPostProcessedFile() : rec.getPostProcessedFile().getParentFile(); - LOG.info("Executing {} in working directory {}", Arrays.toString(cmdline), executionDir); - Process ffmpeg = Runtime.getRuntime().exec(cmdline, new String[0], executionDir); - setupLogging(ffmpeg, rec); - rec.setPostProcessedFile(remuxedFile); - if (inputFile.getName().equals("playlist.m3u8")) { - IoUtils.deleteDirectory(inputFile.getParentFile()); - if (Objects.equals(inputFile.getParentFile(), rec.getAbsoluteFile())) { - rec.setAbsoluteFile(remuxedFile); - } - } else { - Files.deleteIfExists(inputFile.toPath()); - if (Objects.equals(inputFile, rec.getAbsoluteFile())) { - rec.setAbsoluteFile(remuxedFile); - } - } - rec.setSingleFile(true); - rec.setSizeInByte(remuxedFile.length()); - IoUtils.deleteEmptyParents(inputFile.getParentFile()); - rec.getAssociatedFiles().remove(inputFile.getCanonicalPath()); - rec.getAssociatedFiles().add(remuxedFile.getCanonicalPath()); - return true; - } - - private void setupLogging(Process ffmpeg, Recording rec) throws IOException, InterruptedException { - int exitCode = 1; - File video = rec.getPostProcessedFile(); - File ffmpegLog = new File(video.getParentFile(), video.getName() + ".ffmpeg.log"); - rec.getAssociatedFiles().add(ffmpegLog.getCanonicalPath()); - try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) { - Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream)); - Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream)); - stdout.start(); - stderr.start(); - exitCode = ffmpeg.waitFor(); - LOG.debug("FFmpeg exited with code {}", exitCode); - stdout.join(); - stderr.join(); - mergeLogStream.flush(); - } - if (exitCode != 1) { - if (ffmpegLog.exists()) { - Files.delete(ffmpegLog.toPath()); - rec.getAssociatedFiles().remove(ffmpegLog.getCanonicalPath()); - } - } else { - rec.getAssociatedFiles().add(ffmpegLog.getAbsolutePath()); - LOG.info("FFmpeg exit code was {}. Logfile: {}", exitCode, ffmpegLog.getAbsolutePath()); - throw new ProcessExitedUncleanException("FFmpeg exit code was " + exitCode); - } + return OS.getFFmpegCommand(argsPlusFile); } @Override diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Script.java b/common/src/main/java/ctbrec/recorder/postprocessing/Script.java index 69cfb779..ec5c8eb7 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Script.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Script.java @@ -13,7 +13,7 @@ import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.OS; import ctbrec.Recording; -import ctbrec.io.StreamRedirectThread; +import ctbrec.io.StreamRedirector; import ctbrec.recorder.RecordingManager; import ctbrec.recorder.download.ProcessExitedUncleanException; @@ -61,11 +61,11 @@ public class Script extends AbstractPlaceholderAwarePostProcessor { } private void startLogging(Process process, FileOutputStream logStream) { - Thread std = new Thread(new StreamRedirectThread(process.getInputStream(), logStream)); + Thread std = new Thread(new StreamRedirector(process.getInputStream(), logStream)); std.setName("Process stdout pipe"); std.setDaemon(true); std.start(); - Thread err = new Thread(new StreamRedirectThread(process.getErrorStream(), logStream)); + Thread err = new Thread(new StreamRedirector(process.getErrorStream(), logStream)); err.setName("Process stderr pipe"); err.setDaemon(true); err.start(); diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java b/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java index 4765736a..cee86c2d 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java @@ -2,9 +2,6 @@ package ctbrec.recorder.postprocessing; import java.io.IOException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.Config; import ctbrec.NotImplementedExcetion; import ctbrec.Recording; @@ -12,7 +9,6 @@ import ctbrec.recorder.RecordingManager; public class Webhook extends AbstractPlaceholderAwarePostProcessor { - private static final Logger LOG = LoggerFactory.getLogger(Webhook.class); public static final String URL = "webhook.url"; public static final String HEADERS = "webhook.headers"; public static final String METHOD = "webhook.method"; diff --git a/common/src/main/java/ctbrec/sites/ModelOfflineException.java b/common/src/main/java/ctbrec/sites/ModelOfflineException.java new file mode 100644 index 00000000..f7539caf --- /dev/null +++ b/common/src/main/java/ctbrec/sites/ModelOfflineException.java @@ -0,0 +1,10 @@ +package ctbrec.sites; + +import ctbrec.Model; + +public class ModelOfflineException extends RuntimeException { + + public ModelOfflineException(Model model) { + super("Model " + model + " is offline"); + } +} diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java index 63cf1afc..e0258478 100644 --- a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java +++ b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java @@ -2,16 +2,19 @@ package ctbrec.sites.cam4; import static ctbrec.Model.State.*; import static ctbrec.io.HttpConstants.*; +import static java.util.regex.Pattern.*; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; -import org.json.JSONArray; import org.json.JSONObject; import org.jsoup.nodes.Element; import org.slf4j.Logger; @@ -50,60 +53,48 @@ public class Cam4Model extends AbstractModel { if (ignoreCache || onlineState == UNKNOWN) { try { loadModelDetails(); - } catch (ModelDetailsEmptyException e) { - return false; + getPlaylistUrl(); + } catch (Exception e) { + onlineState = OFFLINE; } } return onlineState == ONLINE && !privateRoom && playlistUrl != null && !playlistUrl.isEmpty(); } - private void loadModelDetails() throws IOException, ModelDetailsEmptyException { - String url = site.getBaseUrl() + "/getBroadcasting?usernames=" + getName(); - LOG.trace("Loading model details {}", url); - Request req = new Request.Builder().url(url).build(); - try (Response response = site.getHttpClient().execute(req)) { - if (response.isSuccessful()) { - JSONArray json = new JSONArray(response.body().string()); - if (json.length() == 0) { - onlineState = OFFLINE; - throw new ModelDetailsEmptyException("Model details are empty"); - } - JSONObject details = json.getJSONObject(0); - String showType = details.getString("showType"); - setOnlineStateByShowType(showType); - playlistUrl = details.getString("hlsPreviewUrl"); - privateRoom = details.getBoolean("privateRoom"); - if (privateRoom) { - onlineState = PRIVATE; - } - if (details.has("resolution")) { - String res = details.getString("resolution"); - String[] tokens = res.split(":"); - resolution = new int[] { Integer.parseInt(tokens[0]), Integer.parseInt(tokens[1]) }; - } - } else { - throw new HttpException(response.code(), response.message()); - } - } + private void loadModelDetails() throws IOException { + JSONObject roomState = new Cam4WsClient(Config.getInstance(), (Cam4)getSite(), this).getRoomState(); + if(LOG.isTraceEnabled()) LOG.trace(roomState.toString(2)); + String state = roomState.optString("newShowsState"); + setOnlineStateByShowType(state); + privateRoom = roomState.optBoolean("privateRoom"); + setDescription(roomState.optString("status")); } public void setOnlineStateByShowType(String showType) { switch(showType) { case "NORMAL": + case "ACCEPTING": case "GROUP_SHOW_SELLING_TICKETS": + case "GS_SELLING_TICKETS": + case "GS_SELLING_TICKETS_UNSUCCESSFUL": onlineState = ONLINE; break; case "PRIVATE_SHOW": + case "INSIDE_PS": onlineState = PRIVATE; break; + case "INSIDE_GS": case "GROUP_SHOW": onlineState = GROUP; break; + case "PAUSED": + onlineState = AWAY; + break; case "OFFLINE": onlineState = OFFLINE; break; default: - LOG.debug("Unknown show type [{}]", showType); + LOG.debug("############################## Unknown show type [{}]", showType); onlineState = UNKNOWN; } @@ -117,7 +108,7 @@ public class Cam4Model extends AbstractModel { if(onlineState == UNKNOWN) { try { loadModelDetails(); - } catch (ModelDetailsEmptyException e) { + } catch (Exception e) { LOG.warn("Couldn't load model details {}", e.getMessage()); } } @@ -126,19 +117,58 @@ public class Cam4Model extends AbstractModel { } private String getPlaylistUrl() throws IOException { - if(playlistUrl == null || playlistUrl.trim().isEmpty()) { - try { - loadModelDetails(); - if (playlistUrl == null) { - throw new IOException("Couldn't determine playlist url"); - } - } catch (ModelDetailsEmptyException e) { - throw new IOException(e); + if (playlistUrl == null || playlistUrl.trim().isEmpty()) { + String page = loadModelPage(); + Matcher m = Pattern.compile("hlsUrl\\s*:\\s*'(.*?)'", DOTALL | MULTILINE).matcher(page); + if (m.find()) { + playlistUrl = m.group(1); + } else { + getPlaylistUrlFromStreamUrl(); + } + if (playlistUrl == null) { + throw new IOException("Couldn't determine playlist url"); } } return playlistUrl; } + private void getPlaylistUrlFromStreamUrl() throws IOException { + String url = getSite().getBaseUrl() + "/_profile/streamURL?username=" + getName(); + Request req = new Request.Builder() // @formatter:off + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, "*") + .header(REFERER, getUrl()) + .build(); // @formatter:on + try (Response response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + LOG.trace(json.toString(2)); + if (json.has("canUseCDN")) { + if (json.getBoolean("canUseCDN")) { + playlistUrl = json.getString("cdnURL"); + } else { + playlistUrl = json.getString("edgeURL"); + } + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private String loadModelPage() throws IOException { + Request req = new Request.Builder().url(getUrl()).build(); + try (Response response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + return response.body().string(); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + @Override public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { MasterPlaylist masterPlaylist = getMasterPlaylist(); @@ -160,8 +190,9 @@ public class Cam4Model extends AbstractModel { } private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException { - LOG.debug("Loading master playlist [{}]", getPlaylistUrl()); - Request req = new Request.Builder().url(getPlaylistUrl()).build(); + String playlistUrl = getPlaylistUrl(); + LOG.trace("Loading master playlist [{}]", playlistUrl); + Request req = new Request.Builder().url(playlistUrl).build(); try (Response response = site.getHttpClient().execute(req)) { if (response.isSuccessful()) { @@ -171,7 +202,7 @@ public class Cam4Model extends AbstractModel { MasterPlaylist master = playlist.getMasterPlaylist(); return master; } else { - throw new HttpException(response.code(), "Couldn't download HLS playlist"); + throw new HttpException(response.code(), "Couldn't download HLS playlist " + playlistUrl); } } } @@ -192,19 +223,27 @@ public class Cam4Model extends AbstractModel { if(resolution == null) { if(failFast) { return new int[2]; - } else { - try { - if(onlineState != OFFLINE) { - loadModelDetails(); - } else { - resolution = new int[2]; - } - } catch (Exception e) { - throw new ExecutionException(e); - } } + try { + if(!isOnline()) { + return new int[2]; + } + List sources = getStreamSources(); + Collections.sort(sources); + StreamSource best = sources.get(sources.size()-1); + resolution = new int[] {best.width, best.height}; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); + resolution = new int[2]; + } catch (ExecutionException | IOException | ParseException | PlaylistException e) { + LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); + resolution = new int[2]; + } + return resolution; + } else { + return resolution; } - return resolution; } @Override @@ -276,4 +315,13 @@ public class Cam4Model extends AbstractModel { super(msg); } } + + @Override + public void setUrl(String url) { + String normalizedUrl = url.toLowerCase(); + if (normalizedUrl.endsWith("/")) { + normalizedUrl = normalizedUrl.substring(0, normalizedUrl.length() - 1); + } + super.setUrl(normalizedUrl); + } } diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4WsClient.java b/common/src/main/java/ctbrec/sites/cam4/Cam4WsClient.java new file mode 100644 index 00000000..e5a9557b --- /dev/null +++ b/common/src/main/java/ctbrec/sites/cam4/Cam4WsClient.java @@ -0,0 +1,215 @@ +package ctbrec.sites.cam4; + +import static ctbrec.io.HttpConstants.*; + +import java.io.EOFException; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.io.HttpException; +import ctbrec.sites.ModelOfflineException; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; + +public class Cam4WsClient { + + private static final Logger LOG = LoggerFactory.getLogger(Cam4WsClient.class); + + private Cam4 site; + private Cam4Model model; + private Config config; + private String shard; + private String token; + private WebSocket websocket; + private int r = 1; + private Map> responseFuturesByPath = new HashMap<>(); + private Map> responseFuturesBySequence = new HashMap<>(); + + public Cam4WsClient(Config config, Cam4 site, Cam4Model model) { + this.config = config; + this.site = site; + this.model = model; + } + + public JSONObject getRoomState() throws IOException { + requestAccessToken(); + if (connectAndAuthorize()) { + return requestRoomState(); + } else { + throw new IOException("Connect or authorize failed"); + } + } + + private JSONObject requestRoomState() throws IOException { + String p = "chatRooms/" + model.getName() + "/roomState"; + CompletableFuture roomStateFuture = send(p, "{\"t\":\"d\",\"d\":{\"r\":" + (r++) + ",\"a\":\"q\",\"b\":{\"p\":\"" + p + "\",\"h\":\"\"}}}"); + try { + JSONObject roomState = parseRoomStateResponse(roomStateFuture.get(1, TimeUnit.SECONDS)); + websocket.close(1000, ""); + return roomState; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while getting room state with websocket"); + } catch (TimeoutException | ExecutionException e) { + throw new IOException(e); + } + } + + private boolean connectAndAuthorize() throws IOException { + CompletableFuture connectedAndAuthorized = openWebsocketConnection(); + try { + return connectedAndAuthorized.get(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while connecting with websocket"); + } catch (TimeoutException | ExecutionException e) { + throw new IOException(e); + } + } + + private CompletableFuture send(String p, String msg) { + CompletableFuture future = new CompletableFuture<>(); + LOG.trace("--> {}", msg); + boolean sent = websocket.send(msg); + if (!sent) { + future.completeExceptionally(new IOException("send() returned false")); + } else { + responseFuturesByPath.put(p, future); + } + return future; + } + + private void requestAccessToken() throws IOException { + Request req = new Request.Builder() // @formatter:off + .url("https://webchat.cam4.com/requestAccess?roomname=" + model.getName()) + .header(USER_AGENT, config.getSettings().httpUserAgent) + .header(REFERER, Cam4.BASE_URI + '/' + model.getName()) + .header(ORIGIN, Cam4.BASE_URI) + .header(ACCEPT, "*/*") + .build(); // @formatter:on + try (Response response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + JSONObject body = new JSONObject(response.body().string()); + if (body.optString("status").equals("success")) { + shard = body.getString("shard").replace("https", "wss"); + token = body.getString("token"); + } else { + throw new ModelOfflineException(model); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private JSONObject parseRoomStateResponse(String msg) { + JSONObject json = new JSONObject(msg); + JSONObject d = json.getJSONObject("d"); + JSONObject b = d.getJSONObject("b"); + return b.getJSONObject("d"); + } + + private CompletableFuture openWebsocketConnection() { + CompletableFuture connectedAndAuthorized = new CompletableFuture<>(); + + String url = shard + ".ws?v=5"; + LOG.trace("Opening websocket {}", url); + Request req = new Request.Builder() // @formatter:off + .url(url) + .header(USER_AGENT, config.getSettings().httpUserAgent) + .header(REFERER, Cam4.BASE_URI + '/' + model.getName()) + .header(ORIGIN, Cam4.BASE_URI) + .header(ACCEPT, "*/*") + .build(); // @formatter:on + + websocket = site.getHttpClient().newWebSocket(req, new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, Response response) { + super.onOpen(webSocket, response); + try { + LOG.trace("open: {}", response.body().string()); + } catch (IOException e) { + LOG.error("Connection open, but couldn't get the response body", e); + } + send("", "{\"t\":\"d\",\"d\":{\"r\":" + (r++) + ",\"a\":\"s\",\"b\":{\"c\":{\"sdk.js.2-3-1\":1}}}}"); + send("", "{\"t\":\"d\",\"d\":{\"r\":" + (r++) + ",\"a\":\"auth\",\"b\":{\"cred\":\"" + token + "\"}}}"); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + super.onClosed(webSocket, code, reason); + LOG.trace("closed: {} {}", code, reason); + connectedAndAuthorized.complete(false); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + super.onFailure(webSocket, t, response); + try { + if (t instanceof EOFException) { + return; + } + if(response != null) { + LOG.error("failure {}: {}", model, response.body().string(), t); + } else { + LOG.error("failure {}:", model, t); + } + } catch (IOException e) { + LOG.error("Connection failure and couldn't get the response body", e); + } finally { + connectedAndAuthorized.completeExceptionally(t); + } + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + super.onMessage(webSocket, text); + LOG.trace("msgt: {}", text); + JSONObject response = new JSONObject(text); + if (response.has("d")) { + JSONObject d = response.getJSONObject("d"); + int responseSequence = d.optInt("r"); + if (responseSequence == 2) { + JSONObject body = d.getJSONObject("b"); + String status = body.optString("s"); + connectedAndAuthorized.complete(status.equals("ok")); + } else if (responseFuturesBySequence.containsKey(responseSequence)) { + JSONObject body = d.getJSONObject("b"); + String status = body.optString("s"); + if (!status.equals("ok")) { + CompletableFuture future = responseFuturesBySequence.remove(responseSequence); + future.completeExceptionally(new IOException(status)); + } + } else if (d.has("b")) { + JSONObject body = d.getJSONObject("b"); + String p = body.optString("p", "-"); + if (responseFuturesByPath.containsKey(p)) { + CompletableFuture future = responseFuturesByPath.remove(p); + future.complete(text); + } + } + } + } + + @Override + public void onMessage(WebSocket webSocket, ByteString bytes) { + super.onMessage(webSocket, bytes); + LOG.trace("msgb: {}", bytes.hex()); + } + }); + return connectedAndAuthorized; + } +} diff --git a/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java b/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java index e980eb78..6266f8e1 100644 --- a/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java +++ b/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java @@ -47,6 +47,7 @@ public class CamsodaModel extends AbstractModel { private static final Logger LOG = LoggerFactory.getLogger(CamsodaModel.class); private transient List streamSources = null; private transient boolean isNew; + private transient String gender; private float sortOrder = 0; private Random random = new Random(); @@ -344,4 +345,12 @@ public class CamsodaModel extends AbstractModel { public void setNew(boolean isNew) { this.isNew = isNew; } + + public String getGender() { + return gender; + } + + public void setGender(String gender) { + this.gender = gender; + } } diff --git a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java index 8b2f087a..197c03a8 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java @@ -56,7 +56,7 @@ public class Chaturbate extends AbstractSite { @Override public Double getTokenBalance() throws IOException { - String username = Config.getInstance().getSettings().username; + String username = Config.getInstance().getSettings().chaturbateUsername; if (username == null || username.trim().isEmpty()) { throw new IOException("Not logged in"); } @@ -157,7 +157,7 @@ public class Chaturbate extends AbstractSite { @Override public boolean credentialsAvailable() { - String username = Config.getInstance().getSettings().username; + String username = Config.getInstance().getSettings().chaturbateUsername; return username != null && !username.trim().isEmpty(); } diff --git a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java index e48671f1..9084018a 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java @@ -67,8 +67,8 @@ public class ChaturbateHttpClient extends HttpClient { LOG.debug("csrf token is {}", token); RequestBody body = new FormBody.Builder() - .add("username", Config.getInstance().getSettings().username) - .add("password", Config.getInstance().getSettings().password) + .add("username", Config.getInstance().getSettings().chaturbateUsername) + .add("password", Config.getInstance().getSettings().chaturbatePassword) .add("next", "") .add("csrfmiddlewaretoken", token) .build(); @@ -103,7 +103,7 @@ public class ChaturbateHttpClient extends HttpClient { } private boolean checkLogin() throws IOException { - String url = "https://chaturbate.com/p/" + Config.getInstance().getSettings().username + "/"; + String url = "https://chaturbate.com/p/" + Config.getInstance().getSettings().chaturbateUsername + "/"; Request req = new Request.Builder() .url(url) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) diff --git a/common/src/main/java/ctbrec/sites/manyvids/MVLive.java b/common/src/main/java/ctbrec/sites/manyvids/MVLive.java index bf7b0cfc..7b7b2a78 100644 --- a/common/src/main/java/ctbrec/sites/manyvids/MVLive.java +++ b/common/src/main/java/ctbrec/sites/manyvids/MVLive.java @@ -28,8 +28,8 @@ import okhttp3.Response; public class MVLive extends AbstractSite { - public static final String WS_URL = "wss://live.manyvids.com"; - //public static final String WS_URL = "http://localhost:8080"; + public static final String APP_HOST = "app-v1.live.manyvids.com"; + public static final String WS_URL = "wss://" + APP_HOST; public static final String WS_ORIGIN = "https://live.manyvids.com"; public static final String BASE_URL = "https://www.manyvids.com/MVLive/"; @@ -111,7 +111,8 @@ public class MVLive extends AbstractSite { } @Override - public void init() throws IOException { + public void init() { + // nothing special to do for manyvids } public List getModels() throws IOException { @@ -175,9 +176,8 @@ public class MVLive extends AbstractSite { String getMvToken() throws IOException { if (mvtoken == null) { Request request = new Request.Builder() - .url(getBaseUrl()) + .url("https://www.manyvids.com/") .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) - .header(REFERER, MVLive.BASE_URL) .build(); try (Response response = getHttpClient().execute(request)) { if (response.isSuccessful()) { @@ -245,7 +245,7 @@ public class MVLive extends AbstractSite { @Override public Model createModelFromUrl(String url) { - Matcher m = Pattern.compile("https://live.manyvids.com/stream/(.*?)(?:/.*?)?").matcher(url.trim()); + Matcher m = Pattern.compile("https://live.manyvids.com/(?:stream/)?(.*?)(?:/.*?)?").matcher(url.trim()); if(m.matches()) { return createModel(m.group(1)); } diff --git a/common/src/main/java/ctbrec/sites/manyvids/MVLiveClient.java b/common/src/main/java/ctbrec/sites/manyvids/MVLiveClient.java index 370c00b0..06cb5dc5 100644 --- a/common/src/main/java/ctbrec/sites/manyvids/MVLiveClient.java +++ b/common/src/main/java/ctbrec/sites/manyvids/MVLiveClient.java @@ -23,7 +23,6 @@ import org.slf4j.LoggerFactory; import com.google.common.base.Objects; import ctbrec.Config; -import ctbrec.io.HttpException; import ctbrec.sites.manyvids.wsmsg.GetBroadcastHealth; import ctbrec.sites.manyvids.wsmsg.Message; import ctbrec.sites.manyvids.wsmsg.Ping; @@ -40,17 +39,18 @@ public class MVLiveClient { private static final Logger LOG = LoggerFactory.getLogger(MVLiveClient.class); + private final Map futureResponses = new HashMap<>(); + private final MVLiveHttpClient httpClient; + private final Object streamUrlMonitor = new Object(); + private final Random rng = new Random(); + private WebSocket ws; - private Random rng = new Random(); private volatile boolean running = false; private volatile boolean connecting = false; - private Object streamUrlMonitor = new Object(); private String masterPlaylist = null; private String roomNumber; private String roomId; private ScheduledExecutorService scheduler; - private Map futureResponses = new HashMap<>(); - private MVLiveHttpClient httpClient; public MVLiveClient(MVLiveHttpClient httpClient) { this.httpClient = httpClient; @@ -61,7 +61,7 @@ public class MVLiveClient { if (ws == null && !connecting) { httpClient.fetchAuthenticationCookies(); - JSONObject response = getRoomLocation(model); + JSONObject response = model.getRoomLocation(); roomNumber = response.optString("floorId"); roomId = response.optString("roomId"); int randomNumber = 100 + rng.nextInt(800); @@ -73,22 +73,6 @@ public class MVLiveClient { } } - private JSONObject getRoomLocation(MVLiveModel model) throws IOException { - Request req = new Request.Builder() - .url(WS_ORIGIN + "/api/roomlocation/" + model.getDisplayName() + "?private=false") - .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) - .header(ACCEPT, MIMETYPE_APPLICATION_JSON) - .header(COOKIE, getPhpSessionIdCookie()) - .build(); - try (Response response = httpClient.execute(req)) { - if (response.isSuccessful()) { - return new JSONObject(response.body().string()); - } else { - throw new HttpException(response.code(), response.message()); - } - } - } - private String getPhpSessionIdCookie() { List cookies = httpClient.getCookiesByName("PHPSESSID"); return cookies.stream().map(c -> c.name() + "=" + c.value()).findFirst().orElse(""); @@ -96,7 +80,7 @@ public class MVLiveClient { public void stop() { running = false; - scheduler.shutdown(); + Optional.ofNullable(scheduler).ifPresent(ScheduledExecutorService::shutdown); ws.close(1000, "Good Bye"); // terminate normally (1000) ws = null; } @@ -109,7 +93,7 @@ public class MVLiveClient { .header(ORIGIN, WS_ORIGIN) .header(COOKIE, getPhpSessionIdCookie()) .build(); - WebSocket websocket = httpClient.newWebSocket(req, new WebSocketListener() { + return httpClient.newWebSocket(req, new WebSocketListener() { @Override public void onOpen(WebSocket webSocket, Response response) { super.onOpen(webSocket, response); @@ -157,7 +141,6 @@ public class MVLiveClient { @Override public void onMessage(WebSocket webSocket, String text) { super.onMessage(webSocket, text); - //msgBuffer.append(text); LOG.trace("Message: {}", text); text = Optional.ofNullable(text).orElse(""); if (Objects.equal("o", text)) { @@ -202,7 +185,6 @@ public class MVLiveClient { LOG.debug("Binary Message: {}", bytes.hex()); } }); - return websocket; } void sendMessages(Message... messages) { diff --git a/common/src/main/java/ctbrec/sites/manyvids/MVLiveMergedHlsDownload.java b/common/src/main/java/ctbrec/sites/manyvids/MVLiveMergedHlsDownload.java index 6db9f879..4a7d2327 100644 --- a/common/src/main/java/ctbrec/sites/manyvids/MVLiveMergedHlsDownload.java +++ b/common/src/main/java/ctbrec/sites/manyvids/MVLiveMergedHlsDownload.java @@ -1,21 +1,20 @@ package ctbrec.sites.manyvids; +import ctbrec.io.HttpClient; +import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.IOException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import ctbrec.io.HttpClient; -import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; - public class MVLiveMergedHlsDownload extends MergedFfmpegHlsDownload { private static final Logger LOG = LoggerFactory.getLogger(MVLiveMergedHlsDownload.class); - private ScheduledExecutorService scheduler; + private transient ScheduledExecutorService scheduler; public MVLiveMergedHlsDownload(HttpClient client) { super(client); @@ -31,7 +30,7 @@ public class MVLiveMergedHlsDownload extends MergedFfmpegHlsDownload { t.setPriority(Thread.MIN_PRIORITY); return t; }); - scheduler.scheduleAtFixedRate(() -> updateCloudFlareCookies(), 2, 2, TimeUnit.MINUTES); + scheduler.scheduleAtFixedRate(this::updateCloudFlareCookies, 2, 2, TimeUnit.MINUTES); updateCloudFlareCookies(); super.start(); } finally { diff --git a/common/src/main/java/ctbrec/sites/manyvids/MVLiveModel.java b/common/src/main/java/ctbrec/sites/manyvids/MVLiveModel.java index 681ddab0..bbca090a 100644 --- a/common/src/main/java/ctbrec/sites/manyvids/MVLiveModel.java +++ b/common/src/main/java/ctbrec/sites/manyvids/MVLiveModel.java @@ -2,6 +2,7 @@ package ctbrec.sites.manyvids; import static ctbrec.Model.State.*; import static ctbrec.io.HttpConstants.*; +import static ctbrec.sites.manyvids.MVLive.*; import static java.nio.charset.StandardCharsets.*; import java.io.ByteArrayInputStream; @@ -116,8 +117,8 @@ public class MVLiveModel extends AbstractModel { } public void updateCloudFlareCookies() throws IOException, InterruptedException { - String url = MVLive.WS_ORIGIN + "/api/" + getRoomNumber() + "/player-settings/" + getDisplayName(); - LOG.trace("Getting CF cookies: {}", url); + String url = "https://" + APP_HOST + "/api/" + getRoomNumber() + "/player-settings/" + getDisplayName(); + LOG.debug("Getting CF cookies: {}", url); Request req = new Request.Builder() .url(url) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) @@ -158,8 +159,8 @@ public class MVLiveModel extends AbstractModel { public JSONObject getRoomLocation() throws IOException { fetchGeneralCookies(); httpClient.fetchAuthenticationCookies(); - String url = MVLive.WS_ORIGIN + "/api/roomlocation/" + getDisplayName() + "?private=false"; - LOG.trace("Fetching room location from {}", url); + String url = "https://roompool.live.manyvids.com/roompool/" + getDisplayName() + "?private=false"; + LOG.debug("Fetching room location from {}", url); Request req = new Request.Builder() .url(url) .header(ACCEPT, MIMETYPE_APPLICATION_JSON) @@ -169,8 +170,9 @@ public class MVLiveModel extends AbstractModel { .build(); try (Response response = getHttpClient().execute(req)) { if (response.isSuccessful()) { - JSONObject json = new JSONObject(response.body().string()); - LOG.trace("Room location response: {}", json.toString(2)); + String body = response.body().string(); + JSONObject json = new JSONObject(body); + LOG.trace("Room location response: {}", json); return json; } else { throw new HttpException(response.code(), response.message()); diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java index 5cc44079..4834b078 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java @@ -105,7 +105,7 @@ public class MyFreeCamsClient { } String server = websocketServers.get(new Random().nextInt(websocketServers.size() - 1)); - String wsUrl = "ws://" + server + ".myfreecams.com:8080/fcsl"; + String wsUrl = "wss://" + server + ".myfreecams.com/fcsl"; LOG.debug("Connecting to random websocket server {}", wsUrl); Thread watchDog = new Thread(() -> { @@ -664,6 +664,7 @@ public class MyFreeCamsClient { return camservString; } + @SuppressWarnings("unused") private boolean isBroadcasterOnWebRTC(SessionState state) { return (Optional.ofNullable(state).map(SessionState::getM).map(Model::getFlags).orElse(0) & 524288) == 524288; } diff --git a/common/src/main/java/ctbrec/sites/showup/ShowupHttpClient.java b/common/src/main/java/ctbrec/sites/showup/ShowupHttpClient.java index f03f9226..a3cd4873 100644 --- a/common/src/main/java/ctbrec/sites/showup/ShowupHttpClient.java +++ b/common/src/main/java/ctbrec/sites/showup/ShowupHttpClient.java @@ -3,8 +3,6 @@ package ctbrec.sites.showup; import static ctbrec.io.HttpConstants.*; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -12,23 +10,22 @@ import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.json.JSONObject; import com.google.common.base.Objects; import ctbrec.Config; +import ctbrec.Settings; import ctbrec.io.HttpClient; import ctbrec.io.HttpException; import okhttp3.Cookie; +import okhttp3.FormBody; import okhttp3.Request; import okhttp3.Response; public class ShowupHttpClient extends HttpClient { - private static final Logger LOG = LoggerFactory.getLogger(ShowupHttpClient.class); private String csrfToken; - private boolean loggedIn = false; protected ShowupHttpClient() { super("showup"); @@ -79,38 +76,40 @@ public class ShowupHttpClient extends HttpClient { } @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; - // } - - return false; - } - - public boolean checkLoginSuccess() throws IOException { + public boolean login() throws IOException { + Settings settings = Config.getInstance().getSettings(); + FormBody body = new FormBody.Builder() + .add("is_ajax", "1") + .add("email", settings.showupUsername) + .add("password", settings.showupPassword) + .add("remember", "1") + .build(); Request req = new Request.Builder() - .url(Showup.BASE_URL + "/site/messages") - //.url("http://dingens.showup.tv:1234/site/messages") - .header(ACCEPT, "*/*") + .url(Showup.BASE_URL) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .header(REFERER, Showup.BASE_URL + '/') + .header(ORIGIN, Showup.BASE_URL) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .post(body) .build(); try (Response response = execute(req)) { - if (response.isSuccessful() && response.code() == 200) { - Files.write(Paths.get("/tmp/messages.html"), response.body().bytes()); - //return true; - return false; + if (response.isSuccessful()) { + String responseBody = response.body().string(); + if (responseBody.startsWith("{")) { + JSONObject json = new JSONObject(responseBody); + return json.optString("status").equalsIgnoreCase("success"); + } else { + return false; + } } else { throw new HttpException(response.code(), response.message()); } } } + + public boolean checkLoginSuccess() { + return loggedIn; + } } diff --git a/common/src/main/java/ctbrec/sites/showup/ShowupMergedDownload.java b/common/src/main/java/ctbrec/sites/showup/ShowupMergedDownload.java index b9516ad0..28537c45 100644 --- a/common/src/main/java/ctbrec/sites/showup/ShowupMergedDownload.java +++ b/common/src/main/java/ctbrec/sites/showup/ShowupMergedDownload.java @@ -28,7 +28,7 @@ public class ShowupMergedDownload extends MergedFfmpegHlsDownload { } @Override - protected void downloadSegments(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException { + protected void startDownloadLoop(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException { try { SegmentPlaylist lsp = getNextSegments(segmentPlaylistUri); emptyPlaylistCheck(lsp); @@ -48,8 +48,8 @@ public class ShowupMergedDownload extends MergedFfmpegHlsDownload { BandwidthMeter.add(length); writeSegment(buffer, 0, length); keepGoing = running && !Thread.interrupted() && model.isOnline(true); - if (livestreamDownload && splitRecording()) { - break; + if (livestreamDownload) { + splitRecordingIfNecessary(); } } } else { diff --git a/common/src/main/java/ctbrec/sites/streamate/Streamate.java b/common/src/main/java/ctbrec/sites/streamate/Streamate.java index 2bed9f70..8f845622 100644 --- a/common/src/main/java/ctbrec/sites/streamate/Streamate.java +++ b/common/src/main/java/ctbrec/sites/streamate/Streamate.java @@ -179,7 +179,7 @@ public class Streamate extends AbstractSite { @Override public boolean credentialsAvailable() { - String username = Config.getInstance().getSettings().username; + String username = Config.getInstance().getSettings().streamateUsername; return StringUtil.isNotBlank(username); } diff --git a/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java b/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java index 2705e68f..4e657af3 100644 --- a/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java +++ b/common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java @@ -7,14 +7,16 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import javax.xml.bind.JAXBException; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.ParseException; import com.iheartradio.m3u8.PlaylistException; @@ -29,6 +31,7 @@ import okhttp3.RequestBody; import okhttp3.Response; public class StripchatModel extends AbstractModel { + private static final transient Logger LOG = LoggerFactory.getLogger(StripchatModel.class); private String status = null; private int[] resolution = new int[] {0, 0}; @@ -39,13 +42,33 @@ public class StripchatModel extends AbstractModel { if (jsonResponse.has("user")) { JSONObject user = jsonResponse.getJSONObject("user"); status = user.optString("status"); + mapOnlineState(status); } } - boolean online = Objects.equals(status, "public"); - if (online) { + return onlineState == ONLINE; + } + + private void mapOnlineState(String status) { + switch (status) { + case "public": setOnlineState(ONLINE); + break; + case "idle": + setOnlineState(AWAY); + break; + case "private": + case "p2p": + case "groupShow": + setOnlineState(PRIVATE); + break; + case "off": + setOnlineState(OFFLINE); + break; + default: + LOG.debug("Unknown online state {} for model {}", status, getName()); + setOnlineState(OFFLINE); + break; } - return online; } private JSONObject loadModelInfo() throws IOException { @@ -84,7 +107,7 @@ public class StripchatModel extends AbstractModel { try (Response response = site.getHttpClient().execute(req)) { if (response.isSuccessful()) { JSONObject jsonResponse = new JSONObject(response.body().string()); - String streamName = jsonResponse.optString("streamName"); + String streamName = jsonResponse.optString("streamName", jsonResponse.optString("")); JSONObject viewServers = jsonResponse.getJSONObject("viewServers"); String serverName = viewServers.optString("flashphoner-hls"); JSONObject broadcastSettings = jsonResponse.getJSONObject("broadcastSettings"); @@ -92,18 +115,20 @@ public class StripchatModel extends AbstractModel { StreamSource best = new StreamSource(); best.height = broadcastSettings.optInt("height"); best.width = broadcastSettings.optInt("width"); - best.mediaPlaylistUrl = "https://b-" + serverName + ".stripst.com/hls/" + streamName + "/" + streamName + ".m3u8"; + best.mediaPlaylistUrl = "https://b-" + serverName + ".stripst.com/hls/" + streamName + '/' + streamName + ".m3u8"; sources.add(best); - JSONObject resolutions = broadcastSettings.optJSONObject("resolutions"); - if (resolutions instanceof JSONObject) { - JSONArray heights = resolutions.names(); + JSONObject presets = broadcastSettings.optJSONObject("presets"); + Object defaultObject = presets.get("testing"); + if (defaultObject instanceof JSONObject) { + JSONObject defaults = (JSONObject) defaultObject; + JSONArray heights = defaults.names(); for (int i = 0; i < heights.length(); i++) { String h = heights.getString(i); StreamSource streamSource = new StreamSource(); - streamSource.height = Integer.parseInt(h.replace("p", "")); + streamSource.height = Integer.parseInt(h.replaceAll("[^\\d]", "")); streamSource.width = streamSource.height * best.getWidth() / best.getHeight(); - String source = streamName + "-" + streamSource.height + "p"; - streamSource.mediaPlaylistUrl = "https://b-" + serverName + ".stripst.com/hls/" + source + "/" + source + ".m3u8"; + String source = streamName + '_' + streamSource.height + 'p'; + streamSource.mediaPlaylistUrl = "https://b-" + serverName + ".stripst.com/hls/" + source + '/' + source + ".m3u8"; sources.add(streamSource); } } @@ -111,9 +136,13 @@ public class StripchatModel extends AbstractModel { } else { throw new HttpException(response.code(), response.message()); } + } catch(JSONException e) { + System.err.println(getName()); + throw e; } } + @Override public void invalidateCacheEntries() { status = null; diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java index 04e4e13d..e3e6797d 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java @@ -54,6 +54,7 @@ public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest { @Test public void testUtcTimeReplacement() { + // without user defined pattern String date = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") .withLocale(Locale.US) .withZone(ZoneOffset.UTC) @@ -61,12 +62,21 @@ public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest { String input = "asdf_${utcDateTime}_asdf"; assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + // with user defined pattern date = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") .withLocale(Locale.US) .withZone(ZoneOffset.UTC) .format(rec.getStartDate()); input = "asdf_${utcDateTime(yyyyMMdd-HHmmss)}_asdf"; assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + + // multiple occurences with user defined patterns + date = DateTimeFormatter.ofPattern("yyyy-MM-dd/yyyy") + .withLocale(Locale.US) + .withZone(ZoneOffset.UTC) + .format(rec.getStartDate()); + input = "asdf_${utcDateTime(yyyy)}-${utcDateTime(MM)}-${utcDateTime(dd)}/${utcDateTime(yyyy)}_asdf"; + assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); } @Test diff --git a/master/.gitignore b/master/.gitignore new file mode 100644 index 00000000..26fa2c6a --- /dev/null +++ b/master/.gitignore @@ -0,0 +1,2 @@ +.idea +master.iml diff --git a/master/pom.xml b/master/pom.xml index 1e2b9a5a..08bd71b8 100644 --- a/master/pom.xml +++ b/master/pom.xml @@ -1,17 +1,17 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 ctbrec master pom - 3.10.3 + 3.10.10 ../common - ../client ../server + ../client @@ -63,7 +63,7 @@ com.squareup.moshi moshi - 1.5.0 + 1.6.0 org.json @@ -73,7 +73,7 @@ org.slf4j slf4j-api - 1.7.25 + 1.7.30 ch.qos.logback diff --git a/server/.gitignore b/server/.gitignore index 6b69bd69..2bda3b99 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -7,3 +7,5 @@ /jre/ /server-local.sh ctbrec.pid +/.idea/ +*.iml diff --git a/server/pom.xml b/server/pom.xml index a30cc623..e14eaa12 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -6,11 +6,11 @@ server - ctbrec - master - 3.10.3 - ../master - + ctbrec + master + 3.10.10 + ../master + 1.8 diff --git a/server/src/main/java/ctbrec/recorder/server/ConfigServlet.java b/server/src/main/java/ctbrec/recorder/server/ConfigServlet.java index fb894d48..44a0041e 100644 --- a/server/src/main/java/ctbrec/recorder/server/ConfigServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/ConfigServlet.java @@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.Settings; +import ctbrec.Settings.SplitStrategy; public class ConfigServlet extends AbstractCtbrecServlet { @@ -27,7 +28,7 @@ public class ConfigServlet extends AbstractCtbrecServlet { private Settings settings; public enum DataType { - STRING, BOOLEAN, INTEGER, LONG, DOUBLE + STRING, BOOLEAN, INTEGER, LONG, DOUBLE, SPLIT_STRATEGY } public ConfigServlet(Config config) { @@ -62,7 +63,9 @@ public class ConfigServlet extends AbstractCtbrecServlet { addParameter("postProcessingThreads", "Post-Processing Threads", DataType.INTEGER, settings.postProcessingThreads, json); addParameter("recordingsDir", "Recordings Directory", DataType.STRING, settings.recordingsDir, json); addParameter("recordSingleFile", "Record Single File", DataType.BOOLEAN, settings.recordSingleFile, json); - addParameter("splitRecordings", "Split Recordings (secs)", DataType.INTEGER, settings.splitRecordings, json); + addParameter("splitStrategy", "Split Strategy", DataType.SPLIT_STRATEGY, settings.splitStrategy, json); + addParameter("splitRecordingsAfterSecs", "Split Recordings After (secs)", DataType.INTEGER, settings.splitRecordingsAfterSecs, json); + addParameter("splitRecordingsBiggerThanBytes", "Split Recordings Bigger Than (bytes)", DataType.LONG, settings.splitRecordingsBiggerThanBytes, json); addParameter("transportLayerSecurity", "Transport Layer Security (TLS)", DataType.BOOLEAN, settings.transportLayerSecurity, json); addParameter("webinterface", "Web-Interface", DataType.BOOLEAN, settings.webinterface, json); addParameter("webinterfaceUsername", "Web-Interface User", DataType.STRING, settings.webinterfaceUsername, json); @@ -153,6 +156,8 @@ public class ConfigServlet extends AbstractCtbrecServlet { case DOUBLE: corrected = Double.parseDouble(value.toString()); break; + case SPLIT_STRATEGY: + corrected = SplitStrategy.valueOf(value.toString()); default: break; } diff --git a/server/src/main/java/ctbrec/recorder/server/HlsServlet.java b/server/src/main/java/ctbrec/recorder/server/HlsServlet.java index 43c61233..d2837413 100644 --- a/server/src/main/java/ctbrec/recorder/server/HlsServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/HlsServlet.java @@ -50,7 +50,8 @@ public class HlsServlet extends AbstractCtbrecServlet { boolean idOnly = request.indexOf('/') < 0; if (idOnly) { requestFile = rec.get().getPostProcessedFile(); - requestedFilePath = requestFile.getCanonicalPath(); + serveSegment(req, resp, requestFile); + return; } else { requestedFilePath = request.substring(request.indexOf('/')); requestFile = new File(requestedFilePath); @@ -73,7 +74,6 @@ public class HlsServlet extends AbstractCtbrecServlet { } } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { writeResponse(resp, SC_UNAUTHORIZED, "{\"status\": \"error\", \"msg\": \"Authentication failed\"}"); - return; } } @@ -99,7 +99,6 @@ public class HlsServlet extends AbstractCtbrecServlet { private void serveSegment(HttpServletRequest req, HttpServletResponse resp, File requestedFile) throws IOException { MimetypesFileTypeMap map = new MimetypesFileTypeMap(); - //String mimetype = requestedFile.getName().endsWith(".mp4") ? "video/mp4" : "application/octet-stream"; String mimetype = map.getContentType(requestedFile); LOG.debug("Serving {} as {}", requestedFile.getName(), mimetype); serveFile(req, resp, requestedFile, mimetype); diff --git a/server/src/main/resources/html/static/index.html b/server/src/main/resources/html/static/index.html index e9cc978b..c94a29f9 100644 --- a/server/src/main/resources/html/static/index.html +++ b/server/src/main/resources/html/static/index.html @@ -273,7 +273,7 @@ }); } else { $('#addModelByUrl').autocomplete({ - source: ["BongaCams:", "Cam4:", "Camsoda:", "Chaturbate:", "Fc2Live:", "Flirt4Free:", "LiveJasmin:", "MyFreeCams:", "Showup:", "Streamate:", "Stripchat:"] + source: ["BongaCams:", "Cam4:", "Camsoda:", "Chaturbate:", "Fc2Live:", "Flirt4Free:", "LiveJasmin:", "MyFreeCams:", "MVLive:", "Showup:", "Streamate:", "Stripchat:"] }); } } diff --git a/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java b/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java deleted file mode 100644 index dba742fe..00000000 --- a/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java +++ /dev/null @@ -1,193 +0,0 @@ -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.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", "Mozilla/5.0 (Android 9.0; Mobile; rv:61.0) Gecko/61.0 Firefox/61.0") - .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() { - // TODO Auto-generated method stub - - } - - @Override - public void receiveTip(int tokens) throws IOException { - // TODO Auto-generated method stub - - } - - @Override - public int[] getStreamResolution(boolean failFast) throws ExecutionException { - if(resolution == null) { - if(failFast) { - return new int[2]; - } - try { - 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 e) { - LOG.error("Couldn't determine stream resolution", e); - } - 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; - } -}