Merge branch 'dev' into showup

# Conflicts:
#	common/src/main/java/ctbrec/sites/showup/ShowupHttpClient.java
This commit is contained in:
0xb00bface 2020-12-20 12:21:27 +01:00
commit 5f4e17c2d2
101 changed files with 2325 additions and 1458 deletions

View File

@ -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 3.10.3
======================== ========================
* Fix: Recordings couldn't be found in client server setup, if the client was * Fix: Recordings couldn't be found in client server setup, if the client was

2
client/.gitignore vendored
View File

@ -8,3 +8,5 @@
/server-local.sh /server-local.sh
/browser/ /browser/
/ffmpeg/ /ffmpeg/
/client.iml
/.idea/

View File

@ -8,7 +8,7 @@
<parent> <parent>
<groupId>ctbrec</groupId> <groupId>ctbrec</groupId>
<artifactId>master</artifactId> <artifactId>master</artifactId>
<version>3.10.3</version> <version>3.10.10</version>
<relativePath>../master</relativePath> <relativePath>../master</relativePath>
</parent> </parent>
@ -81,10 +81,6 @@
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId> <artifactId>javafx-media</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.eclipse.jetty</groupId> <groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-servlet</artifactId> <artifactId>jetty-servlet</artifactId>

View File

@ -8,12 +8,15 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException; import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Objects; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -67,7 +70,6 @@ import ctbrec.ui.tabs.logging.LoggingTab;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.HostServices; import javafx.application.HostServices;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
@ -92,24 +94,48 @@ public class CamrecApplication extends Application {
private Recorder recorder; private Recorder recorder;
private OnlineMonitor onlineMonitor; private OnlineMonitor onlineMonitor;
static HostServices hostServices; static HostServices hostServices;
private BorderPane rootPane = new BorderPane(); private final BorderPane rootPane = new BorderPane();
private HBox statusBar = new HBox(); private final HBox statusBar = new HBox();
private Label statusLabel = new Label(); private final Label statusLabel = new Label();
private TabPane tabPane = new TabPane(); private final TabPane tabPane = new TabPane();
private List<Site> sites = new ArrayList<>(); private final List<Site> sites = new ArrayList<>();
public static HttpClient httpClient; public static HttpClient httpClient;
public static String title; public static String title;
private Stage primaryStage; private Stage primaryStage;
private RecordedModelsTab modelsTab; private RecordedModelsTab modelsTab;
private RecordingsTab recordingsTab; private RecordingsTab recordingsTab;
private ScheduledExecutorService scheduler;
private int activeRecordings = 0; private int activeRecordings = 0;
private double bytesPerSecond = 0; private double bytesPerSecond = 0;
@Override @Override
public void start(Stage primaryStage) throws Exception { public void start(Stage primaryStage) throws Exception {
this.primaryStage = primaryStage; this.primaryStage = primaryStage;
scheduler = Executors.newScheduledThreadPool(1, r -> {
Thread t = new Thread(r);
t.setDaemon(true);
t.setName("Scheduler");
return t;
});
logEnvironment(); 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 BongaCams());
sites.add(new Cam4()); sites.add(new Cam4());
sites.add(new Camsoda()); sites.add(new Camsoda());
@ -122,17 +148,13 @@ public class CamrecApplication extends Application {
sites.add(new Showup()); sites.add(new Showup());
sites.add(new Streamate()); sites.add(new Streamate());
sites.add(new Stripchat()); sites.add(new Stripchat());
loadConfig(); }
registerAlertSystem();
registerActiveRecordingsCounter(); private void registerClipboardListener() {
registerBandwidthMeterListener(); if(config.getSettings().monitorClipboard) {
createHttpClient(); ClipboardListener clipboardListener = new ClipboardListener(recorder, sites);
hostServices = getHostServices(); scheduler.scheduleAtFixedRate(clipboardListener, 0, 1, TimeUnit.SECONDS);
createRecorder(); }
startOnlineMonitor();
createGui(primaryStage);
checkForUpdates();
startHelpServer();
} }
private void startHelpServer() { private void startHelpServer() {
@ -178,11 +200,11 @@ public class CamrecApplication extends Application {
Scene scene = new Scene(rootPane, windowWidth, windowHeight); Scene scene = new Scene(rootPane, windowWidth, windowHeight);
primaryStage.setScene(scene); primaryStage.setScene(scene);
Dialogs.setScene(scene);
rootPane.setCenter(tabPane); rootPane.setCenter(tabPane);
rootPane.setBottom(statusBar); rootPane.setBottom(statusBar);
for (Iterator<Site> iterator = sites.iterator(); iterator.hasNext();) { for (Site site : sites) {
Site site = iterator.next(); if (site.isEnabled()) {
if(site.isEnabled()) {
SiteTab siteTab = new SiteTab(site, scene); SiteTab siteTab = new SiteTab(site, scene);
tabPane.getTabs().add(siteTab); tabPane.getTabs().add(siteTab);
} }
@ -190,7 +212,7 @@ public class CamrecApplication extends Application {
modelsTab = new RecordedModelsTab("Recording", recorder, sites); modelsTab = new RecordedModelsTab("Recording", recorder, sites);
tabPane.getTabs().add(modelsTab); tabPane.getTabs().add(modelsTab);
recordingsTab = new RecordingsTab("Recordings", recorder, config, sites); recordingsTab = new RecordingsTab("Recordings", recorder, config);
tabPane.getTabs().add(recordingsTab); tabPane.getTabs().add(recordingsTab);
tabPane.getTabs().add(new SettingsTab(sites, recorder)); tabPane.getTabs().add(new SettingsTab(sites, recorder));
tabPane.getTabs().add(new NewsTab()); tabPane.getTabs().add(new NewsTab());
@ -206,16 +228,16 @@ public class CamrecApplication extends Application {
} }
loadStyleSheet(primaryStage, "style.css"); loadStyleSheet(primaryStage, "style.css");
primaryStage.getScene().getStylesheets().add("/ctbrec/ui/settings/ColorSettingsPane.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/SearchBox.css");
primaryStage.getScene().getStylesheets().add("/ctbrec/ui/controls/Popover.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/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().widthProperty().addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowWidth = newVal.intValue());
primaryStage.getScene().heightProperty() primaryStage.getScene().heightProperty()
.addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowHeight = newVal.intValue()); .addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowHeight = newVal.intValue());
primaryStage.setMaximized(Config.getInstance().getSettings().windowMaximized); primaryStage.setMaximized(Config.getInstance().getSettings().windowMaximized);
primaryStage.maximizedProperty() 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(); Player.scene = primaryStage.getScene();
primaryStage.setX(Config.getInstance().getSettings().windowX); primaryStage.setX(Config.getInstance().getSettings().windowX);
primaryStage.setY(Config.getInstance().getSettings().windowY); primaryStage.setY(Config.getInstance().getSettings().windowY);
@ -225,7 +247,7 @@ public class CamrecApplication extends Application {
primaryStage.setOnCloseRequest(createShutdownHandler()); primaryStage.setOnCloseRequest(createShutdownHandler());
// register changelistener to activate / deactivate tabs, when the user switches between them // register changelistener to activate / deactivate tabs, when the user switches between them
tabPane.getSelectionModel().selectedItemProperty().addListener((ChangeListener<Tab>) (ov, from, to) -> { tabPane.getSelectionModel().selectedItemProperty().addListener((ov, from, to) -> {
if (from instanceof TabSelectionListener) { if (from instanceof TabSelectionListener) {
((TabSelectionListener) from).deselected(); ((TabSelectionListener) from).deselected();
} }
@ -274,44 +296,42 @@ public class CamrecApplication extends Application {
shutdownInfo.show(); shutdownInfo.show();
final boolean immediately = shutdownNow; final boolean immediately = shutdownNow;
new Thread() { new Thread(() -> {
@Override modelsTab.saveState();
public void run() { recordingsTab.saveState();
modelsTab.saveState(); onlineMonitor.shutdown();
recordingsTab.saveState(); recorder.shutdown(immediately);
onlineMonitor.shutdown(); for (Site site : sites) {
recorder.shutdown(immediately); if (site.isEnabled()) {
for (Site site : sites) { site.shutdown();
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
} }
} }
}.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) { if (activeRecordings == 0) {
bytesPerSecond = 0; bytesPerSecond = 0;
} }
String humanreadable = ByteUnitFormatter.format(bytesPerSecond); String humanReadable = ByteUnitFormatter.format(bytesPerSecond);
String status = String.format("Recording %s / %s models @ %s/s", activeRecordings, recorder.getModels().size(), humanreadable); String status = String.format("Recording %s / %s models @ %s/s", activeRecordings, recorder.getModels().size(), humanReadable);
Platform.runLater(() -> statusLabel.setText(status)); Platform.runLater(() -> statusLabel.setText(status));
} }
@ -370,7 +390,7 @@ public class CamrecApplication extends Application {
" -fx-focus-color: -fx-accent;\n" + " -fx-focus-color: -fx-accent;\n" +
" -fx-control-inner-background-alt: derive(-fx-base, 95%);\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) { } catch(Exception e) {
LOG.error("Couldn't write stylesheet for user defined color theme"); LOG.error("Couldn't write stylesheet for user defined color theme");
} }
@ -400,7 +420,6 @@ public class CamrecApplication extends Application {
private void createRecorder() { private void createRecorder() {
if (config.getSettings().localRecording) { if (config.getSettings().localRecording) {
//recorder = new LocalRecorder(config);
try { try {
recorder = new NextGenLocalRecorder(config, sites); recorder = new NextGenLocalRecorder(config, sites);
} catch (IOException e) { } catch (IOException e) {
@ -431,7 +450,7 @@ public class CamrecApplication extends Application {
private void createHttpClient() { private void createHttpClient() {
httpClient = new HttpClient("camrec") { httpClient = new HttpClient("camrec") {
@Override @Override
public boolean login() throws IOException { public boolean login() {
return false; return false;
} }
}; };
@ -481,8 +500,7 @@ public class CamrecApplication extends Application {
try (InputStream is = CamrecApplication.class.getClassLoader().getResourceAsStream("version")) { try (InputStream is = CamrecApplication.class.getClassLoader().getResourceAsStream("version")) {
BufferedReader reader = new BufferedReader(new InputStreamReader(is)); BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String versionString = reader.readLine(); String versionString = reader.readLine();
Version version = Version.of(versionString); return Version.of(versionString);
return version;
} }
} }
} }

View File

@ -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<Site> sites;
private Clipboard systemClipboard;
private String lastUrl = null;
public ClipboardListener(Recorder recorder, List<Site> 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;
}
}
}
}

View File

@ -15,7 +15,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.io.StreamRedirectThread; import ctbrec.io.StreamRedirector;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
import javafx.scene.control.Label; import javafx.scene.control.Label;
@ -133,8 +133,8 @@ public class DesktopIntegration {
msg.replace("-", "\\\\-").replace("\\s", "\\\\ "), msg.replace("-", "\\\\-").replace("\\s", "\\\\ "),
"--icon=dialog-information" "--icon=dialog-information"
}); });
new Thread(new StreamRedirectThread(p.getInputStream(), System.out)).start(); // NOSONAR new Thread(new StreamRedirector(p.getInputStream(), System.out)).start(); // NOSONAR
new Thread(new StreamRedirectThread(p.getErrorStream(), System.err)).start(); // NOSONAR new Thread(new StreamRedirector(p.getErrorStream(), System.err)).start(); // NOSONAR
} catch (IOException e1) { } catch (IOException e1) {
LOG.error("Notification failed", e1); LOG.error("Notification failed", e1);
} }

View File

@ -1,11 +1,15 @@
package ctbrec.ui; package ctbrec.ui;
import static java.nio.charset.StandardCharsets.*;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.Socket; import java.net.Socket;
import java.util.Arrays;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer; import java.util.function.Consumer;
@ -17,7 +21,7 @@ import org.slf4j.LoggerFactory;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.Settings.ProxyType; import ctbrec.Settings.ProxyType;
import ctbrec.io.StreamRedirectThread; import ctbrec.io.StreamRedirector;
public class ExternalBrowser implements AutoCloseable { public class ExternalBrowser implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(ExternalBrowser.class); private static final Logger LOG = LoggerFactory.getLogger(ExternalBrowser.class);
@ -47,16 +51,17 @@ public class ExternalBrowser implements AutoCloseable {
addProxyConfig(jsonConfig.getJSONObject("config")); 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()) { if (LOG.isTraceEnabled()) {
p = new ProcessBuilder(OS.getBrowserCommand("--enable-logging")).start(); new Thread(new StreamRedirector(p.getInputStream(), System.out)).start();
new StreamRedirectThread(p.getInputStream(), System.err); new Thread(new StreamRedirector(p.getErrorStream(), System.err)).start();
new StreamRedirectThread(p.getErrorStream(), System.err);
} else { } else {
p = new ProcessBuilder(OS.getBrowserCommand()).start(); new Thread(new StreamRedirector(p.getInputStream(), OutputStream.nullOutputStream())).start();
new StreamRedirectThread(p.getInputStream(), OutputStream.nullOutputStream()); new Thread(new StreamRedirector(p.getErrorStream(), OutputStream.nullOutputStream())).start();
new StreamRedirectThread(p.getErrorStream(), OutputStream.nullOutputStream());
} }
LOG.debug("Browser started"); LOG.debug("Browser started: {}", Arrays.toString(cmdline));
connectToRemoteControlSocket(); connectToRemoteControlSocket();
while (!browserReady) { while (!browserReady) {
@ -69,7 +74,7 @@ public class ExternalBrowser implements AutoCloseable {
} else { } else {
LOG.debug("Connected to remote control server. Sending config"); 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.write('\n');
out.flush(); out.flush();
@ -95,6 +100,7 @@ public class ExternalBrowser implements AutoCloseable {
out = socket.getOutputStream(); out = socket.getOutputStream();
reader = new Thread(this::readBrowserOutput); reader = new Thread(this::readBrowserOutput);
reader.start(); reader.start();
LOG.debug("Connected to control socket");
return; return;
} catch (IOException e) { } catch (IOException e) {
if(i == 19) { if(i == 19) {
@ -116,7 +122,7 @@ public class ExternalBrowser implements AutoCloseable {
//LOG.debug("Executing JS {}", javaScript); //LOG.debug("Executing JS {}", javaScript);
JSONObject script = new JSONObject(); JSONObject script = new JSONObject();
script.put("execute", javaScript); script.put("execute", javaScript);
out.write(script.toString().getBytes("utf-8")); out.write(script.toString().getBytes(UTF_8));
out.write('\n'); out.write('\n');
out.flush(); out.flush();
if(javaScript.equals("quit")) { if(javaScript.equals("quit")) {

View File

@ -307,4 +307,9 @@ public class JavaFxModel implements Model {
public void setRecordUntilSubsequentAction(SubsequentAction action) { public void setRecordUntilSubsequentAction(SubsequentAction action) {
delegate.setRecordUntilSubsequentAction(action); delegate.setRecordUntilSubsequentAction(action);
} }
@Override
public boolean exists() throws IOException {
return delegate.exists();
}
} }

View File

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

View File

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

View File

@ -25,7 +25,7 @@ import ctbrec.Config;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.Recording; import ctbrec.Recording;
import ctbrec.io.StreamRedirectThread; import ctbrec.io.StreamRedirector;
import ctbrec.io.UrlUtil; import ctbrec.io.UrlUtil;
import ctbrec.recorder.download.StreamSource; import ctbrec.recorder.download.StreamSource;
import ctbrec.ui.controls.Dialogs; 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, // 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 // 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)); //Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), System.out));
std.setName("Player stdout pipe"); std.setName("Player stdout pipe");
std.setDaemon(true); std.setDaemon(true);
std.start(); 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)); //Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), System.err));
err.setName("Player stderr pipe"); err.setName("Player stderr pipe");
err.setDaemon(true); err.setDaemon(true);

View File

@ -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<Model> deletedAccounts = new ArrayList<>();
try {
List<Model> 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();
}
}

View File

@ -24,9 +24,15 @@ import javafx.stage.Stage;
public class Dialogs { public class Dialogs {
private 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) { 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) { public static void showError(Scene parent, String header, String text, Throwable t) {

View File

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

View File

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

View File

@ -221,6 +221,7 @@ public class SearchPopoverTreeList extends PopoverTreeList<Model> implements Pop
this.model = null; this.model = null;
} else { } else {
follow.setVisible(model.getSite().supportsFollow()); follow.setVisible(model.getSite().supportsFollow());
follow.setDisable(!model.getSite().credentialsAvailable());
title.setVisible(true); title.setVisible(true);
title.setText(model.getDisplayName()); title.setText(model.getDisplayName());
this.model = model; this.model = model;

View File

@ -1,15 +1,7 @@
package ctbrec.ui.news; 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.JsonAdapter;
import com.squareup.moshi.Moshi; import com.squareup.moshi.Moshi;
import ctbrec.io.HttpException; import ctbrec.io.HttpException;
import ctbrec.ui.CamrecApplication; import ctbrec.ui.CamrecApplication;
import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.Dialogs;
@ -22,10 +14,15 @@ import javafx.scene.control.Tab;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.Response; 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 { public class NewsTab extends Tab implements TabSelectionListener {
private static final String ACCESS_TOKEN = "a2804d73a89951a22e0f8483a6fcec8943afd88b7ba17c459c095aa9e6f94fd0"; 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 static final String URL = "https://mastodon.cloud/api/v1/accounts/480960/statuses?limit=20&exclude_replies=true";
private VBox layout = new VBox(); private VBox layout = new VBox();
@ -64,7 +61,7 @@ public class NewsTab extends Tab implements TabSelectionListener {
} }
} }
} catch (IOException e) { } 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);
} }
} }

View File

@ -28,6 +28,7 @@ import javafx.beans.property.ListProperty;
import javafx.beans.property.LongProperty; import javafx.beans.property.LongProperty;
import javafx.beans.property.Property; import javafx.beans.property.Property;
import javafx.beans.property.StringProperty; import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.CheckBox; import javafx.scene.control.CheckBox;
@ -47,12 +48,17 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
private Config config; private Config config;
private Settings settings; private Settings settings;
private Preferences prefs;
public CtbrecPreferencesStorage(Config config) { public CtbrecPreferencesStorage(Config config) {
this.config = config; this.config = config;
this.settings = config.getSettings(); this.settings = config.getSettings();
} }
public void setPreferences(Preferences prefs) {
this.prefs = prefs;
}
@Override @Override
public void save(Preferences preferences) throws IOException { public void save(Preferences preferences) throws IOException {
throw new RuntimeException("not implemented"); throw new RuntimeException("not implemented");
@ -102,6 +108,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
prop.addListener((obs, oldV, newV) -> saveValue(() -> { prop.addListener((obs, oldV, newV) -> saveValue(() -> {
Field field = Settings.class.getField(setting.getKey()); Field field = Settings.class.getField(setting.getKey());
field.set(settings, newV); field.set(settings, newV);
if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) {
prefs.getRestartRequiredCallback().run();
}
config.save(); config.save();
})); }));
HBox row = new HBox(); HBox row = new HBox();
@ -142,7 +151,7 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
private int getRangeSliderValue(List<Integer> values, List<Integer> labels, int value) { private int getRangeSliderValue(List<Integer> values, List<Integer> labels, int value) {
for (int i = 0; i < labels.size(); i++) { for (int i = 0; i < labels.size(); i++) {
int label = labels.get(i).intValue(); int label = labels.get(i).intValue();
if(label == value) { if (label == value) {
return values.get(i); return values.get(i);
} }
} }
@ -157,6 +166,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
String oldValue = (String) field.get(settings); String oldValue = (String) field.get(settings);
if (!Objects.equals(path, oldValue)) { if (!Objects.equals(path, oldValue)) {
field.set(settings, path); field.set(settings, path);
if (setting.doesNeedRestart()) {
prefs.getRestartRequiredCallback().run();
}
config.save(); config.save();
} }
})); }));
@ -174,6 +186,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
String oldValue = (String) field.get(settings); String oldValue = (String) field.get(settings);
if (!Objects.equals(path, oldValue)) { if (!Objects.equals(path, oldValue)) {
field.set(settings, path); field.set(settings, path);
if (setting.doesNeedRestart()) {
prefs.getRestartRequiredCallback().run();
}
config.save(); config.save();
} }
})); }));
@ -187,6 +202,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
ctrl.textProperty().addListener((obs, oldV, newV) -> saveValue(() -> { ctrl.textProperty().addListener((obs, oldV, newV) -> saveValue(() -> {
Field field = Settings.class.getField(setting.getKey()); Field field = Settings.class.getField(setting.getKey());
field.set(settings, newV); field.set(settings, newV);
if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) {
prefs.getRestartRequiredCallback().run();
}
config.save(); config.save();
})); }));
StringProperty prop = (StringProperty) setting.getProperty(); StringProperty prop = (StringProperty) setting.getProperty();
@ -204,6 +222,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
if (!ctrl.getText().isEmpty()) { if (!ctrl.getText().isEmpty()) {
Field field = Settings.class.getField(setting.getKey()); Field field = Settings.class.getField(setting.getKey());
field.set(settings, Integer.parseInt(ctrl.getText())); field.set(settings, Integer.parseInt(ctrl.getText()));
if (setting.doesNeedRestart() && !Objects.equals(oldV, newV) && prefs != null) {
prefs.getRestartRequiredCallback().run();
}
config.save(); config.save();
} }
})); }));
@ -226,6 +247,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
} }
Field field = Settings.class.getField(setting.getKey()); Field field = Settings.class.getField(setting.getKey());
field.set(settings, value); field.set(settings, value);
if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) {
prefs.getRestartRequiredCallback().run();
}
config.save(); config.save();
} }
})); }));
@ -239,6 +263,9 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
ctrl.selectedProperty().addListener((obs, oldV, newV) -> saveValue(() -> { ctrl.selectedProperty().addListener((obs, oldV, newV) -> saveValue(() -> {
Field field = Settings.class.getField(setting.getKey()); Field field = Settings.class.getField(setting.getKey());
field.set(settings, newV); field.set(settings, newV);
if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) {
prefs.getRestartRequiredCallback().run();
}
config.save(); config.save();
})); }));
BooleanProperty prop = (BooleanProperty) setting.getProperty(); BooleanProperty prop = (BooleanProperty) setting.getProperty();
@ -265,8 +292,14 @@ public class CtbrecPreferencesStorage implements PreferencesStorage {
} else { } else {
field.set(settings, newV); field.set(settings, newV);
} }
if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) {
prefs.getRestartRequiredCallback().run();
}
config.save(); config.save();
})); }));
if (setting.getChangeListener() != null) {
comboBox.valueProperty().addListener((ChangeListener<? super Object>) setting.getChangeListener());
}
return comboBox; return comboBox;
} }

View File

@ -1,25 +1,18 @@
package ctbrec.ui.settings; 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.io.IOException;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional; 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 { public class PostProcessingDialogFactory {
static Map<Class<?>, Class<?>> ppToDialogMap = new HashMap<>(); static Map<Class<?>, Class<?>> ppToDialogMap = new HashMap<>();
@ -35,19 +28,19 @@ public class PostProcessingDialogFactory {
private PostProcessingDialogFactory() { private PostProcessingDialogFactory() {
} }
public static void openNewDialog(PostProcessor pp, Config config, Scene scene, ObservableList<PostProcessor> stepList) { public static void openNewDialog(PostProcessor pp, Scene scene, ObservableList<PostProcessor> stepList) {
openDialog(pp, config, scene, stepList, true); openDialog(pp, scene, stepList, true);
} }
public static void openEditDialog(PostProcessor pp, Config config, Scene scene, ObservableList<PostProcessor> stepList) { public static void openEditDialog(PostProcessor pp, Scene scene, ObservableList<PostProcessor> stepList) {
openDialog(pp, config, scene, stepList, false); openDialog(pp, scene, stepList, false);
} }
private static void openDialog(PostProcessor pp, Config config, Scene scene, ObservableList<PostProcessor> stepList, boolean newEntry) { private static void openDialog(PostProcessor pp, Scene scene, ObservableList<PostProcessor> stepList, boolean newEntry) {
boolean ok; boolean ok;
try { try {
Optional<Preferences> preferences = createPreferences(pp); Optional<Preferences> preferences = createPreferences(pp);
if(preferences.isPresent()) { if (preferences.isPresent()) {
Region view = preferences.get().getView(false); Region view = preferences.get().getView(false);
view.setMinWidth(600); view.setMinWidth(600);
ok = Dialogs.showCustomInput(scene, "Configure " + pp.getName(), view); ok = Dialogs.showCustomInput(scene, "Configure " + pp.getName(), view);
@ -62,12 +55,12 @@ public class PostProcessingDialogFactory {
} }
} catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException
| InstantiationException | IOException e) { | 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<Preferences> createPreferences(PostProcessor pp) throws InstantiationException, IllegalAccessException, IllegalArgumentException, private static Optional<Preferences> createPreferences(PostProcessor pp) throws InstantiationException, IllegalAccessException,
InvocationTargetException, NoSuchMethodException, SecurityException { InvocationTargetException, NoSuchMethodException {
Class<?> paneFactoryClass = ppToDialogMap.get(pp.getClass()); Class<?> paneFactoryClass = ppToDialogMap.get(pp.getClass());
if (paneFactoryClass != null) { if (paneFactoryClass != null) {
AbstractPostProcessingPaneFactory factory = (AbstractPostProcessingPaneFactory) paneFactoryClass.getDeclaredConstructor().newInstance(); AbstractPostProcessingPaneFactory factory = (AbstractPostProcessingPaneFactory) paneFactoryClass.getDeclaredConstructor().newInstance();

View File

@ -1,21 +1,7 @@
package ctbrec.ui.settings; 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.Config;
import ctbrec.recorder.postprocessing.Copy; import ctbrec.recorder.postprocessing.*;
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.ui.controls.Dialogs; import ctbrec.ui.controls.Dialogs;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener; import javafx.collections.ListChangeListener;
@ -30,21 +16,25 @@ import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.stage.Stage; 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 { public class PostProcessingStepPanel extends GridPane {
private Config config; private final Config config;
private static final Class<?>[] POST_PROCESSOR_CLASSES = new Class<?>[]{ // @formatter: off
private static final Class<?>[] POST_PROCESSOR_CLASSES = new Class<?>[] { // @formatter: off Copy.class,
Copy.class, Rename.class,
Rename.class, Move.class,
Move.class, Remux.class,
Remux.class, Script.class,
Script.class, DeleteOriginal.class,
DeleteOriginal.class, DeleteTooShort.class,
DeleteTooShort.class, RemoveKeepFile.class,
RemoveKeepFile.class, CreateContactSheet.class
CreateContactSheet.class
}; // @formatter: on }; // @formatter: on
ListView<PostProcessor> stepListView; ListView<PostProcessor> stepListView;
@ -98,33 +88,33 @@ public class PostProcessingStepPanel extends GridPane {
} }
private Button createUpButton() { private Button createUpButton() {
Button up = createButton("\u25B4", "Move step up"); Button button = createButton("\u25B4", "Move step up");
up.setOnAction(evt -> { button.setOnAction(evt -> {
int idx = stepListView.getSelectionModel().getSelectedIndex(); int idx = stepListView.getSelectionModel().getSelectedIndex();
PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem();
stepList.remove(idx); stepList.remove(idx);
stepList.add(idx - 1, selectedItem); stepList.add(idx - 1, selectedItem);
stepListView.getSelectionModel().select(idx - 1); stepListView.getSelectionModel().select(idx - 1);
}); });
return up; return button;
} }
private Button createDownButton() { private Button createDownButton() {
Button down = createButton("\u25BE", "Move step down"); Button button = createButton("\u25BE", "Move step down");
down.setOnAction(evt -> { button.setOnAction(evt -> {
int idx = stepListView.getSelectionModel().getSelectedIndex(); int idx = stepListView.getSelectionModel().getSelectedIndex();
PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem();
stepList.remove(idx); stepList.remove(idx);
stepList.add(idx + 1, selectedItem); stepList.add(idx + 1, selectedItem);
stepListView.getSelectionModel().select(idx + 1); stepListView.getSelectionModel().select(idx + 1);
}); });
return down; return button;
} }
private Button createAddButton() { private Button createAddButton() {
Button add = createButton("+", "Add a new step"); Button button = createButton("+", "Add a new step");
add.setDisable(false); button.setDisable(false);
add.setOnAction(evt -> { button.setOnAction(evt -> {
PostProcessor[] options = createOptions(); PostProcessor[] options = createOptions();
ChoiceDialog<PostProcessor> choice = new ChoiceDialog<>(options[0], options); ChoiceDialog<PostProcessor> choice = new ChoiceDialog<>(options[0], options);
choice.setTitle("New Post-Processing Step"); choice.setTitle("New Post-Processing Step");
@ -138,17 +128,17 @@ public class PostProcessingStepPanel extends GridPane {
stage.getIcons().add(new Image(icon)); stage.getIcons().add(new Image(icon));
Optional<PostProcessor> result = choice.showAndWait(); Optional<PostProcessor> result = choice.showAndWait();
result.ifPresent(pp -> PostProcessingDialogFactory.openNewDialog(pp, config, getScene(), stepList)); result.ifPresent(pp -> PostProcessingDialogFactory.openNewDialog(pp, getScene(), stepList));
saveConfig(); saveConfig();
}); });
return add; return button;
} }
private void saveConfig() { private void saveConfig() {
try { try {
config.save(); config.save();
} catch (IOException e) { } 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() { private Button createRemoveButton() {
Button remove = createButton("-", "Remove selected step"); Button button = createButton("-", "Remove selected step");
remove.setOnAction(evt -> { button.setOnAction(evt -> {
PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem();
if (selectedItem != null) { if (selectedItem != null) {
stepList.remove(selectedItem); stepList.remove(selectedItem);
} }
}); });
return remove; return button;
} }
private Button createEditButton() { private Button createEditButton() {
Button edit = createButton("\u270E", "Edit selected step"); Button button = createButton("\u270E", "Edit selected step");
edit.setOnAction(evt -> { button.setOnAction(evt -> {
PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem();
PostProcessingDialogFactory.openEditDialog(selectedItem, config, getScene(), stepList); PostProcessingDialogFactory.openEditDialog(selectedItem, getScene(), stepList);
stepListView.refresh(); stepListView.refresh();
saveConfig(); saveConfig();
}); });
return edit; return button;
} }
private Button createButton(String text, String tooltip) { private Button createButton(String text, String tooltip) {

View File

@ -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<ActionEvent> {
private ComboBox<ProxyType> 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<ProxyType> 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);
}
}
}

View File

@ -2,6 +2,7 @@ package ctbrec.ui.settings;
import static ctbrec.Settings.DirectoryStructure.*; import static ctbrec.Settings.DirectoryStructure.*;
import static ctbrec.Settings.ProxyType.*; import static ctbrec.Settings.ProxyType.*;
import static ctbrec.Settings.SplitStrategy.*;
import static java.util.Optional.*; import static java.util.Optional.*;
import java.io.IOException; import java.io.IOException;
@ -36,6 +37,9 @@ import ctbrec.ui.settings.api.SimpleRangeProperty;
import ctbrec.ui.settings.api.ValueConverter; import ctbrec.ui.settings.api.ValueConverter;
import ctbrec.ui.sites.ConfigUI; import ctbrec.ui.sites.ConfigUI;
import ctbrec.ui.tabs.TabSelectionListener; 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.binding.BooleanExpression;
import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleIntegerProperty;
@ -45,18 +49,33 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane; import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tab; import javafx.scene.control.Tab;
import javafx.scene.control.TextInputDialog; 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.GridPane;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.Region; 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 { public class SettingsTab extends Tab implements TabSelectionListener {
private static final Logger LOG = LoggerFactory.getLogger(SettingsTab.class); private static final Logger LOG = LoggerFactory.getLogger(SettingsTab.class);
public static final int CHECKBOX_MARGIN = 6; public static final int CHECKBOX_MARGIN = 6;
private static final long MiB = 1024 * 1024L;
private static final long GiB = 1024 * MiB;
private List<Site> sites; private List<Site> sites;
private Recorder recorder; private Recorder recorder;
@ -71,6 +90,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private SimpleBooleanProperty determineResolution; private SimpleBooleanProperty determineResolution;
private SimpleBooleanProperty chooseStreamQuality; private SimpleBooleanProperty chooseStreamQuality;
private SimpleBooleanProperty livePreviews; private SimpleBooleanProperty livePreviews;
private SimpleBooleanProperty monitorClipboard;
private SimpleListProperty<String> startTab; private SimpleListProperty<String> startTab;
private SimpleFileProperty mediaPlayer; private SimpleFileProperty mediaPlayer;
private SimpleStringProperty mediaPlayerParams; private SimpleStringProperty mediaPlayerParams;
@ -85,6 +105,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private SimpleDirectoryProperty recordingsDir; private SimpleDirectoryProperty recordingsDir;
private SimpleListProperty<DirectoryStructure> directoryStructure; private SimpleListProperty<DirectoryStructure> directoryStructure;
private SimpleListProperty<SplitAfterOption> splitAfter; private SimpleListProperty<SplitAfterOption> splitAfter;
private SimpleListProperty<SplitBiggerThanOption> splitBiggerThan;
private SimpleRangeProperty<Integer> resolutionRange; private SimpleRangeProperty<Integer> resolutionRange;
private List<Integer> labels = Arrays.asList(0, 240, 360, 480, 600, 720, 960, 1080, 1440, 2160, 4320, 8640); private List<Integer> labels = Arrays.asList(0, 240, 360, 480, 600, 720, 960, 1080, 1440, 2160, 4320, 8640);
private List<Integer> values = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11); private List<Integer> 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 SimpleBooleanProperty onlineCheckSkipsPausedModels;
private SimpleLongProperty leaveSpaceOnDevice; private SimpleLongProperty leaveSpaceOnDevice;
private SimpleStringProperty ffmpegParameters; private SimpleStringProperty ffmpegParameters;
private SimpleBooleanProperty logFFmpegOutput;
private SimpleStringProperty fileExtension; private SimpleStringProperty fileExtension;
private SimpleStringProperty server; private SimpleStringProperty server;
private SimpleIntegerProperty port; private SimpleIntegerProperty port;
@ -104,8 +126,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private ExclusiveSelectionProperty recordLocal; private ExclusiveSelectionProperty recordLocal;
private SimpleIntegerProperty postProcessingThreads; private SimpleIntegerProperty postProcessingThreads;
private IgnoreList ignoreList; private IgnoreList ignoreList;
private PostProcessingStepPanel postProcessingStepPanel; private Label restartNotification;
private Button variablesHelpButton;
public SettingsTab(List<Site> sites, Recorder recorder) { public SettingsTab(List<Site> sites, Recorder recorder) {
this.sites = sites; this.sites = sites;
@ -124,6 +145,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
determineResolution = new SimpleBooleanProperty(null, "determineResolution", settings.determineResolution); determineResolution = new SimpleBooleanProperty(null, "determineResolution", settings.determineResolution);
chooseStreamQuality = new SimpleBooleanProperty(null, "chooseStreamQuality", settings.chooseStreamQuality); chooseStreamQuality = new SimpleBooleanProperty(null, "chooseStreamQuality", settings.chooseStreamQuality);
livePreviews = new SimpleBooleanProperty(null, "livePreviews", settings.livePreviews); livePreviews = new SimpleBooleanProperty(null, "livePreviews", settings.livePreviews);
monitorClipboard = new SimpleBooleanProperty(null, "monitorClipboard", settings.monitorClipboard);
startTab = new SimpleListProperty<>(null, "startTab", FXCollections.observableList(getTabNames())); startTab = new SimpleListProperty<>(null, "startTab", FXCollections.observableList(getTabNames()));
mediaPlayer = new SimpleFileProperty(null, "mediaPlayer", settings.mediaPlayer); mediaPlayer = new SimpleFileProperty(null, "mediaPlayer", settings.mediaPlayer);
mediaPlayerParams = new SimpleStringProperty(null, "mediaPlayerParams", settings.mediaPlayerParams); 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); proxyPassword = new SimpleStringProperty(null, "proxyPassword", settings.proxyPassword);
recordingsDir = new SimpleDirectoryProperty(null, "recordingsDir", settings.recordingsDir); recordingsDir = new SimpleDirectoryProperty(null, "recordingsDir", settings.recordingsDir);
directoryStructure = new SimpleListProperty<>(null, "recordingsDirStructure", FXCollections.observableList(List.of(FLAT, ONE_PER_MODEL, ONE_PER_RECORDING))); 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); resolutionRange = new SimpleRangeProperty<>(rangeValues, "minimumResolution", "maximumResolution", settings.minimumResolution, settings.maximumResolution);
concurrentRecordings = new SimpleIntegerProperty(null, "concurrentRecordings", settings.concurrentRecordings); concurrentRecordings = new SimpleIntegerProperty(null, "concurrentRecordings", settings.concurrentRecordings);
onlineCheckIntervalInSecs = new SimpleIntegerProperty(null, "onlineCheckIntervalInSecs", settings.onlineCheckIntervalInSecs); onlineCheckIntervalInSecs = new SimpleIntegerProperty(null, "onlineCheckIntervalInSecs", settings.onlineCheckIntervalInSecs);
leaveSpaceOnDevice = new SimpleLongProperty(null, "minimumSpaceLeftInBytes", (long) new GigabytesConverter().convertTo(settings.minimumSpaceLeftInBytes)); leaveSpaceOnDevice = new SimpleLongProperty(null, "minimumSpaceLeftInBytes", (long) new GigabytesConverter().convertTo(settings.minimumSpaceLeftInBytes));
ffmpegParameters = new SimpleStringProperty(null, "ffmpegMergedDownloadArgs", settings.ffmpegMergedDownloadArgs); ffmpegParameters = new SimpleStringProperty(null, "ffmpegMergedDownloadArgs", settings.ffmpegMergedDownloadArgs);
logFFmpegOutput = new SimpleBooleanProperty(null, "logFFmpegOutput", settings.logFFmpegOutput);
fileExtension = new SimpleStringProperty(null, "ffmpegFileSuffix", settings.ffmpegFileSuffix); fileExtension = new SimpleStringProperty(null, "ffmpegFileSuffix", settings.ffmpegFileSuffix);
server = new SimpleStringProperty(null, "httpServer", settings.httpServer); server = new SimpleStringProperty(null, "httpServer", settings.httpServer);
port = new SimpleIntegerProperty(null, "httpPort", settings.httpPort); port = new SimpleIntegerProperty(null, "httpPort", settings.httpPort);
@ -157,8 +181,8 @@ public class SettingsTab extends Tab implements TabSelectionListener {
} }
private void createGui() { private void createGui() {
postProcessingStepPanel = new PostProcessingStepPanel(config); PostProcessingStepPanel postProcessingStepPanel = new PostProcessingStepPanel(config);
variablesHelpButton = createHelpButton("Variables", "http://localhost:5689/docs/PostProcessing.md#variables"); Button variablesHelpButton = createHelpButton("Variables", "http://localhost:5689/docs/PostProcessing.md#variables");
ignoreList = new IgnoreList(sites); ignoreList = new IgnoreList(sites);
List<Category> siteCategories = new ArrayList<>(); List<Category> siteCategories = new ArrayList<>();
for (Site site : sites) { for (Site site : sites) {
@ -166,18 +190,20 @@ public class SettingsTab extends Tab implements TabSelectionListener {
.ifPresent(configPanel -> siteCategories.add(Category.of(site.getName(), configPanel))); .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", Category.of("General",
Group.of("General", Group.of("General",
Setting.of("User-Agent", httpUserAgent), Setting.of("User-Agent", httpUserAgent),
Setting.of("User-Agent mobile", httpUserAgentMobile), 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("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("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("Manually select stream quality", chooseStreamQuality, "Opens a dialog to select the video resolution before recording"),
Setting.of("Enable live previews (experimental)", livePreviews), 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("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", Group.of("Player",
Setting.of("Player", mediaPlayer), Setting.of("Player", mediaPlayer),
@ -191,7 +217,8 @@ public class SettingsTab extends Tab implements TabSelectionListener {
Group.of("Settings", Group.of("Settings",
Setting.of("Recordings Directory", recordingsDir), Setting.of("Recordings Directory", recordingsDir),
Setting.of("Directory Structure", directoryStructure), 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("Restrict Resolution", resolutionRange, "Only record streams with resolution within the given range"),
Setting.of("Concurrent Recordings (0 = unlimited)", concurrentRecordings), 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()), 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") Setting.of("Skip online check for paused models", onlineCheckSkipsPausedModels, "Skip online check for paused models")
), ),
Group.of("Location", Group.of("Location",
Setting.of("Record Location", recordLocal), Setting.of("Record Location", recordLocal).needsRestart(),
Setting.of("Server", server), Setting.of("Server", server),
Setting.of("Port", port), Setting.of("Port", port),
Setting.of("Path", path, "Leave empty, if you didn't change the servletContext in the server config"), 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("Sites", siteCategories.toArray(new Category[0])),
Category.of("Proxy", Category.of("Proxy",
Group.of("Proxy", Group.of("Proxy",
Setting.of("Type", proxyType), Setting.of("Type", proxyType).needsRestart(),
Setting.of("Host", proxyHost), Setting.of("Host", proxyHost).needsRestart(),
Setting.of("Port", proxyPort), Setting.of("Port", proxyPort).needsRestart(),
Setting.of("Username", proxyUser), Setting.of("Username", proxyUser).needsRestart(),
Setting.of("Password", proxyPassword) 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(); Region preferencesView = prefs.getView();
prefs.onRestartRequired(this::showRestartRequired);
storage.setPreferences(prefs);
preferencesView.setMinSize(800, 400); preferencesView.setMinSize(800, 400);
preferencesView.setPrefSize(1280, 960); preferencesView.setPrefSize(1280, 960);
ScrollPane scrollPane = new ScrollPane(preferencesView); ScrollPane scrollPane = new ScrollPane(preferencesView);
@ -241,7 +275,20 @@ public class SettingsTab extends Tab implements TabSelectionListener {
GridPane.setFillHeight(scrollPane, true); GridPane.setFillHeight(scrollPane, true);
GridPane.setHgrow(scrollPane, Priority.ALWAYS); GridPane.setHgrow(scrollPane, Priority.ALWAYS);
GridPane.setVgrow(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(); prefs.expandTree();
@ -251,7 +298,8 @@ public class SettingsTab extends Tab implements TabSelectionListener {
prefs.getSetting("requireAuthentication").ifPresent(s -> bindEnabledProperty(s, recordLocal)); prefs.getSetting("requireAuthentication").ifPresent(s -> bindEnabledProperty(s, recordLocal));
prefs.getSetting("transportLayerSecurity").ifPresent(s -> bindEnabledProperty(s, recordLocal)); prefs.getSetting("transportLayerSecurity").ifPresent(s -> bindEnabledProperty(s, recordLocal));
prefs.getSetting("recordingsDir").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); 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("minimumResolution").ifPresent(s -> bindEnabledProperty(s, recordLocal.not()));
prefs.getSetting("recordingsDirStructure").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); prefs.getSetting("recordingsDirStructure").ifPresent(s -> bindEnabledProperty(s, recordLocal.not()));
prefs.getSetting("onlineCheckIntervalInSecs").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)); prefs.getSetting("downloadFilename").ifPresent(s -> bindEnabledProperty(s, recordLocal));
postProcessingStepPanel.disableProperty().bind(recordLocal.not()); postProcessingStepPanel.disableProperty().bind(recordLocal.not());
variablesHelpButton.disableProperty().bind(recordLocal); 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) { private Button createHelpButton(String text, String url) {
@ -288,7 +353,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
private List<SplitAfterOption> getSplitOptions() { private List<SplitAfterOption> getSplitAfterSecsOptions() {
List<SplitAfterOption> splitOptions = new ArrayList<>(); List<SplitAfterOption> splitOptions = new ArrayList<>();
splitOptions.add(new SplitAfterOption("disabled", 0)); splitOptions.add(new SplitAfterOption("disabled", 0));
if (Config.isDevMode()) { if (Config.isDevMode()) {
@ -304,6 +369,29 @@ public class SettingsTab extends Tab implements TabSelectionListener {
return splitOptions; return splitOptions;
} }
private List<SplitBiggerThanOption> getSplitBiggerThanOptions() {
List<SplitBiggerThanOption> 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 private void requireAuthenticationChanged(ObservableValue<?> obs, Boolean oldV, Boolean newV) { // NOSONAR
boolean requiresAuthentication = newV; boolean requiresAuthentication = newV;
Config.getInstance().getSettings().requireAuthentication = requiresAuthentication; Config.getInstance().getSettings().requireAuthentication = requiresAuthentication;
@ -359,7 +447,26 @@ public class SettingsTab extends Tab implements TabSelectionListener {
} }
void showRestartRequired() { 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 { 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);
}
};
}
}
} }

View File

@ -12,8 +12,8 @@ public class GigabytesConverter implements ValueConverter {
@Override @Override
public Object convertFrom(Object b) { public Object convertFrom(Object b) {
long spaceLeftInGiB = (long) b; long gibiBytes = (long) b;
return spaceLeftInGiB * ONE_GIB_IN_BYTES; return gibiBytes * ONE_GIB_IN_BYTES;
} }
} }

View File

@ -35,6 +35,8 @@ public class Preferences {
private PreferencesStorage preferencesStorage; private PreferencesStorage preferencesStorage;
private Runnable restartRequiredCallback = () -> {};
private Preferences(PreferencesStorage preferencesStorage, Category...categories) { private Preferences(PreferencesStorage preferencesStorage, Category...categories) {
this.preferencesStorage = preferencesStorage; this.preferencesStorage = preferencesStorage;
this.categories = categories; this.categories = categories;
@ -248,4 +250,12 @@ public class Preferences {
return result; return result;
} }
} }
public void onRestartRequired(Runnable callback) {
this.restartRequiredCallback = callback;
}
public Runnable getRestartRequiredCallback() {
return restartRequiredCallback;
}
} }

View File

@ -4,6 +4,7 @@ import static java.util.Optional.*;
import ctbrec.StringUtil; import ctbrec.StringUtil;
import javafx.beans.property.Property; import javafx.beans.property.Property;
import javafx.beans.value.ChangeListener;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Control; import javafx.scene.control.Control;
import javafx.scene.control.Tooltip; import javafx.scene.control.Tooltip;
@ -17,6 +18,7 @@ public class Setting {
private PreferencesStorage preferencesStorage; private PreferencesStorage preferencesStorage;
private boolean needsRestart = false; private boolean needsRestart = false;
private ValueConverter converter; private ValueConverter converter;
private ChangeListener<?> changeListener;
protected Setting(String name, Property<?> property) { protected Setting(String name, Property<?> property) {
this.name = name; this.name = name;
@ -107,4 +109,13 @@ public class Setting {
public ValueConverter getConverter() { public ValueConverter getConverter() {
return converter; return converter;
} }
public Setting onChange(ChangeListener<?> changeListener) {
this.changeListener = changeListener;
return this;
}
public ChangeListener<?> getChangeListener() {
return changeListener;
}
} }

View File

@ -67,11 +67,10 @@ public class BongaCamsElectronLoginDialog {
browser.executeJavaScript("document.getElementById('log_in_password').value = '" + password + "';"); browser.executeJavaScript("document.getElementById('log_in_password').value = '" + password + "';");
} }
String[] simplify = new String[] { String[] simplify = new String[] {
"$('div#header').css('display','none');", "$('div[class~=\"page_header\"]').css('display','none');",
"$('div.footer').css('display','none');", "$('div[class~=\"header_bar\"]').css('display','none')",
"$('div.footer_copy').css('display','none')", "$('footer').css('display','none');",
"$('div[class~=\"banner_top_index\"]').css('display','none');", "$('div[class~=\"footer_copy\"]').css('display','none')",
"$('td.menu_container').css('display','none');",
"$('div[class~=\"fancybox-overlay\"]').css('display','none');" "$('div[class~=\"fancybox-overlay\"]').css('display','none');"
}; };
for (String js : simplify) { for (String js : simplify) {

View File

@ -1,25 +1,24 @@
package ctbrec.ui.sites.bonga; 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.BongaCams;
import ctbrec.sites.bonga.BongaCamsHttpClient; import ctbrec.sites.bonga.BongaCamsHttpClient;
import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.sites.AbstractSiteUi; import ctbrec.ui.sites.AbstractSiteUi;
import ctbrec.ui.sites.ConfigUI; import ctbrec.ui.sites.ConfigUI;
import ctbrec.ui.tabs.TabProvider; 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 { public class BongaCamsSiteUi extends AbstractSiteUi {
private static final transient Logger LOG = LoggerFactory.getLogger(BongaCamsSiteUi.class); private static final Logger LOG = LoggerFactory.getLogger(BongaCamsSiteUi.class);
private BongaCamsTabProvider tabProvider; private final BongaCamsTabProvider tabProvider;
private BongaCamsConfigUI configUi; private final BongaCamsConfigUI configUi;
private BongaCams bongaCams; private final BongaCams bongaCams;
public BongaCamsSiteUi(BongaCams bongaCams) { public BongaCamsSiteUi(BongaCams bongaCams) {
this.bongaCams = bongaCams; this.bongaCams = bongaCams;
@ -57,11 +56,13 @@ public class BongaCamsSiteUi extends AbstractSiteUi {
try { try {
queue.put(true); queue.put(true);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Error while signaling termination", e); LOG.error("Error while signaling termination", e);
} }
}).start(); }).start();
queue.take(); queue.take();
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Error while waiting for login dialog to close", e); LOG.error("Error while waiting for login dialog to close", e);
throw new IOException(e); throw new IOException(e);
} }

View File

@ -47,7 +47,7 @@ public class BongaCamsTabProvider extends TabProvider {
tabs.add(createTab("Transsexual", updateService)); tabs.add(createTab("Transsexual", updateService));
// new // 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); updateService = new BongaCamsUpdateService(bongaCams, url);
tabs.add(createTab("New", updateService)); tabs.add(createTab("New", updateService));

View File

@ -4,6 +4,7 @@ import static ctbrec.sites.camsoda.Camsoda.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.function.Predicate; import java.util.function.Predicate;
import ctbrec.recorder.Recorder; import ctbrec.recorder.Recorder;
@ -16,6 +17,7 @@ import javafx.scene.control.Tab;
public class CamsodaTabProvider extends TabProvider { public class CamsodaTabProvider extends TabProvider {
private static final String API_URL = BASE_URI + "/api/v1/browse/online";
private Camsoda camsoda; private Camsoda camsoda;
private Recorder recorder; private Recorder recorder;
CamsodaFollowedTab followedTab; CamsodaFollowedTab followedTab;
@ -29,8 +31,13 @@ public class CamsodaTabProvider extends TabProvider {
@Override @Override
public List<Tab> getTabs(Scene scene) { public List<Tab> getTabs(Scene scene) {
List<Tab> tabs = new ArrayList<>(); List<Tab> tabs = new ArrayList<>();
tabs.add(createTab("Online", BASE_URI + "/api/v1/browse/online", m -> true)); tabs.add(createTab("All", API_URL, m -> true));
//tabs.add(createTab("New", BASE_URI + "/api/v1/browse/online", CamsodaModel::isNew)); 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.setRecorder(recorder);
followedTab.setScene(scene); followedTab.setScene(scene);
tabs.add(followedTab); tabs.add(followedTab);

View File

@ -74,60 +74,58 @@ public class CamsodaUpdateService extends PaginatedScheduledService {
if (response.isSuccessful()) { if (response.isSuccessful()) {
String body = response.body().string(); String body = response.body().string();
JSONObject json = new JSONObject(body); JSONObject json = new JSONObject(body);
if (json.optBoolean("status")) { JSONArray template = json.getJSONArray("template");
JSONArray template = json.getJSONArray("template"); JSONArray results = json.getJSONArray("results");
JSONArray results = json.getJSONArray("results"); for (int i = 0; i < results.length(); i++) {
for (int i = 0; i < results.length(); i++) { JSONObject result = results.getJSONObject(i);
Object result = results.getJSONObject(i).get("tpl"); Object templateObject = result.get("tpl");
CamsodaModel model; CamsodaModel model;
try { try {
if (result instanceof JSONObject) { if (templateObject instanceof JSONObject) {
JSONObject tpl = (JSONObject) result; JSONObject tpl = (JSONObject) templateObject;
String name = tpl.getString(Integer.toString(getTemplateIndex(template, "username"))); String name = tpl.getString(Integer.toString(getTemplateIndex(template, "username")));
model = (CamsodaModel) camsoda.createModel(name); model = (CamsodaModel) camsoda.createModel(name);
model.setDescription(tpl.getString(Integer.toString(getTemplateIndex(template, "subject_html")))); model.setDescription(tpl.getString(Integer.toString(getTemplateIndex(template, "subject_html"))));
model.setSortOrder(tpl.getFloat(Integer.toString(getTemplateIndex(template, "sort_value")))); model.setSortOrder(tpl.getFloat(Integer.toString(getTemplateIndex(template, "sort_value"))));
String preview = "https:" + tpl.getString(Integer.toString(getTemplateIndex(template, "thumb"))); model.setNew(result.optBoolean("new"));
model.setPreview(preview); model.setGender(tpl.getString(Integer.toString(getTemplateIndex(template, "gender"))));
String displayName = tpl.getString(Integer.toString(getTemplateIndex(template, "display_name"))); String preview = "https:" + tpl.getString(Integer.toString(getTemplateIndex(template, "thumb")));
model.setDisplayName(displayName.replaceAll("[^a-zA-Z0-9]", "")); model.setPreview(preview);
if (model.getDisplayName().isBlank()) { String displayName = tpl.getString(Integer.toString(getTemplateIndex(template, "display_name")));
model.setDisplayName(name); model.setDisplayName(displayName.replaceAll("[^a-zA-Z0-9]", ""));
} if (model.getDisplayName().isBlank()) {
model.setOnlineState(tpl.getString(Integer.toString(getTemplateIndex(template, "stream_name"))).isEmpty() ? OFFLINE : ONLINE); model.setDisplayName(name);
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);
} }
} catch (Exception e) { model.setOnlineState(tpl.getString(Integer.toString(getTemplateIndex(template, "stream_name"))).isEmpty() ? OFFLINE : ONLINE);
LOG.warn("Couldn't parse one of the models: {}", result, e); 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 { } else {
throw new HttpException(response.code(), response.message()); throw new HttpException(response.code(), response.message());
} }

View File

@ -45,10 +45,10 @@ public class ChaturbateConfigUi extends AbstractConfigUI {
layout.add(enabled, 1, row++); layout.add(enabled, 1, row++);
layout.add(new Label("Chaturbate User"), 0, 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) -> { username.textProperty().addListener((ob, o, n) -> {
if(!n.equals(Config.getInstance().getSettings().username)) { if(!n.equals(Config.getInstance().getSettings().chaturbateUsername)) {
Config.getInstance().getSettings().username = n; Config.getInstance().getSettings().chaturbateUsername = n;
chaturbate.getHttpClient().logout(); chaturbate.getHttpClient().logout();
save(); save();
} }
@ -60,10 +60,10 @@ public class ChaturbateConfigUi extends AbstractConfigUI {
layout.add(new Label("Chaturbate Password"), 0, row); layout.add(new Label("Chaturbate Password"), 0, row);
PasswordField password = new PasswordField(); PasswordField password = new PasswordField();
password.setText(Config.getInstance().getSettings().password); password.setText(Config.getInstance().getSettings().chaturbatePassword);
password.textProperty().addListener((ob, o, n) -> { password.textProperty().addListener((ob, o, n) -> {
if(!n.equals(Config.getInstance().getSettings().password)) { if(!n.equals(Config.getInstance().getSettings().chaturbatePassword)) {
Config.getInstance().getSettings().password = n; Config.getInstance().getSettings().chaturbatePassword = n;
chaturbate.getHttpClient().logout(); chaturbate.getHttpClient().logout();
save(); save();
} }

View File

@ -11,7 +11,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.StringUtil;
import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.chaturbate.Chaturbate;
import ctbrec.sites.chaturbate.ChaturbateModelParser; import ctbrec.sites.chaturbate.ChaturbateModelParser;
import ctbrec.ui.SiteUiFactory; import ctbrec.ui.SiteUiFactory;
@ -49,7 +48,7 @@ public class ChaturbateUpdateService extends PaginatedScheduledService {
return new Task<List<Model>>() { return new Task<List<Model>>() {
@Override @Override
public List<Model> call() throws IOException { public List<Model> call() throws IOException {
if(loginRequired && StringUtil.isBlank(ctbrec.Config.getInstance().getSettings().username)) { if(loginRequired && !chaturbate.credentialsAvailable()) {
return Collections.emptyList(); return Collections.emptyList();
} else { } else {
String url = ChaturbateUpdateService.this.url + "?page="+page+"&keywords=&_=" + System.currentTimeMillis(); String url = ChaturbateUpdateService.this.url + "?page="+page+"&keywords=&_=" + System.currentTimeMillis();

View File

@ -62,6 +62,7 @@ public class StreamateUpdateService extends PaginatedScheduledService {
model.setId(p.getLong("id")); model.setId(p.getLong("id"));
//model.setPreview(p.getString("thumbnail")); //model.setPreview(p.getString("thumbnail"));
model.setPreview("https://cdn.nsimg.net/snap/320x240/" + model.getId() + ".jpg"); model.setPreview("https://cdn.nsimg.net/snap/320x240/" + model.getId() + ".jpg");
model.setDescription(p.optString("headlineMessage"));
boolean online = p.optBoolean("online"); boolean online = p.optBoolean("online");
model.setOnline(online); model.setOnline(online);
model.setOnlineState(online ? ONLINE : OFFLINE); model.setOnlineState(online ? ONLINE : OFFLINE);

View File

@ -15,7 +15,6 @@ import org.slf4j.LoggerFactory;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.StringUtil;
import ctbrec.io.HttpException; import ctbrec.io.HttpException;
import ctbrec.sites.stripchat.Stripchat; import ctbrec.sites.stripchat.Stripchat;
import ctbrec.sites.stripchat.StripchatModel; import ctbrec.sites.stripchat.StripchatModel;
@ -46,7 +45,7 @@ public class StripchatUpdateService extends PaginatedScheduledService {
@Override @Override
public List<Model> call() throws IOException { public List<Model> call() throws IOException {
List<Model> models = new ArrayList<>(); List<Model> models = new ArrayList<>();
if(loginRequired && StringUtil.isBlank(ctbrec.Config.getInstance().getSettings().username)) { if(loginRequired && !stripchat.credentialsAvailable()) {
return Collections.emptyList(); return Collections.emptyList();
} else { } else {
int offset = (getPage() - 1) * modelsPerPage; int offset = (getPage() - 1) * modelsPerPage;

View File

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

View File

@ -38,6 +38,7 @@ import ctbrec.ui.DesktopIntegration;
import ctbrec.ui.JavaFxModel; import ctbrec.ui.JavaFxModel;
import ctbrec.ui.PreviewPopupHandler; import ctbrec.ui.PreviewPopupHandler;
import ctbrec.ui.StreamSourceSelectionDialog; import ctbrec.ui.StreamSourceSelectionDialog;
import ctbrec.ui.action.CheckModelAccountAction;
import ctbrec.ui.action.EditNotesAction; import ctbrec.ui.action.EditNotesAction;
import ctbrec.ui.action.FollowAction; import ctbrec.ui.action.FollowAction;
import ctbrec.ui.action.OpenRecordingsDir; import ctbrec.ui.action.OpenRecordingsDir;
@ -125,6 +126,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
Button pauseAll = new Button("Pause All"); Button pauseAll = new Button("Pause All");
Button resumeAll = new Button("Resume All"); Button resumeAll = new Button("Resume All");
ToggleButton toggleRecording = new ToggleButton("Pause Recording"); ToggleButton toggleRecording = new ToggleButton("Pause Recording");
Button checkModelAccountExistance = new Button("Check URLs");
TextField filter; TextField filter;
public RecordedModelsTab(String title, Recorder recorder, List<Site> sites) { public RecordedModelsTab(String title, Recorder recorder, List<Site> sites) {
@ -269,7 +271,9 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
ObservableList<String> suggestions = FXCollections.observableArrayList(); ObservableList<String> suggestions = FXCollections.observableArrayList();
sites.forEach(site -> suggestions.add(site.getClass().getSimpleName())); sites.forEach(site -> suggestions.add(site.getClass().getSimpleName()));
model = new AutoFillTextField(new ObservableListSuggester(suggestions)); 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.setPromptText("e.g. MyFreeCams:ModelName or an URL like https://chaturbate.com/modelname/");
model.onActionHandler(this::addModel); model.onActionHandler(this::addModel);
model.setTooltip(new Tooltip("To add a model enter SiteName:ModelName\n" + 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)); BorderPane.setMargin(addModelBox, new Insets(5));
addModelButton.setOnAction(this::addModel); addModelButton.setOnAction(this::addModel);
addModelButton.setPadding(new Insets(5)); 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)); HBox.setMargin(pauseAll, new Insets(0, 0, 0, 20));
pauseAll.setOnAction(this::pauseAll); pauseAll.setOnAction(this::pauseAll);
resumeAll.setOnAction(this::resumeAll); resumeAll.setOnAction(this::resumeAll);
@ -286,13 +290,21 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
toggleRecording.setPadding(new Insets(5)); toggleRecording.setPadding(new Insets(5));
toggleRecording.setOnAction(this::toggleRecording); toggleRecording.setOnAction(this::toggleRecording);
HBox.setMargin(toggleRecording, new Insets(0, 0, 0, 20)); 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(); HBox filterContainer = new HBox();
filterContainer.setSpacing(0); filterContainer.setSpacing(0);
filterContainer.setPadding(new Insets(0)); filterContainer.setPadding(new Insets(0));
filterContainer.setAlignment(Pos.CENTER_RIGHT); filterContainer.setAlignment(Pos.CENTER_RIGHT);
filterContainer.minWidth(100);
filterContainer.prefWidth(150);
HBox.setHgrow(filterContainer, Priority.ALWAYS); HBox.setHgrow(filterContainer, Priority.ALWAYS);
filter = new SearchBox(false); filter = new SearchBox(false);
filter.minWidth(100);
filter.prefWidth(150);
filter.setPromptText("Filter"); filter.setPromptText("Filter");
filter.textProperty().addListener( (observableValue, oldValue, newValue) -> { filter.textProperty().addListener( (observableValue, oldValue, newValue) -> {
String q = filter.getText(); String q = filter.getText();
@ -645,6 +657,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
switchStreamSource.setOnAction(e -> switchStreamSource(selectedModels.get(0))); switchStreamSource.setOnAction(e -> switchStreamSource(selectedModels.get(0)));
MenuItem follow = new MenuItem("Follow"); MenuItem follow = new MenuItem("Follow");
follow.setOnAction(e -> follow(selectedModels)); follow.setOnAction(e -> follow(selectedModels));
follow.setDisable(!selectedModels.stream().allMatch(m -> m.getSite().supportsFollow() && m.getSite().credentialsAvailable()));
MenuItem ignore = new MenuItem("Ignore"); MenuItem ignore = new MenuItem("Ignore");
ignore.setOnAction(e -> ignore(selectedModels)); ignore.setOnAction(e -> ignore(selectedModels));
MenuItem notes = new MenuItem("Notes"); MenuItem notes = new MenuItem("Notes");

View File

@ -14,7 +14,6 @@ import java.text.DecimalFormat;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@ -23,11 +22,13 @@ import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.Model;
import ctbrec.Recording; import ctbrec.Recording;
import ctbrec.Recording.State; import ctbrec.Recording.State;
import ctbrec.StringUtil; import ctbrec.StringUtil;
@ -38,13 +39,16 @@ import ctbrec.recorder.ProgressListener;
import ctbrec.recorder.Recorder; import ctbrec.recorder.Recorder;
import ctbrec.recorder.RecordingPinnedException; import ctbrec.recorder.RecordingPinnedException;
import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload;
import ctbrec.sites.Site;
import ctbrec.ui.AutosizeAlert; import ctbrec.ui.AutosizeAlert;
import ctbrec.ui.CamrecApplication; import ctbrec.ui.CamrecApplication;
import ctbrec.ui.DesktopIntegration; import ctbrec.ui.DesktopIntegration;
import ctbrec.ui.FileDownload; import ctbrec.ui.FileDownload;
import ctbrec.ui.JavaFxRecording; import ctbrec.ui.JavaFxRecording;
import ctbrec.ui.Player; 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.DateTimeCellFactory;
import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.controls.Toast; 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 static final Logger LOG = LoggerFactory.getLogger(RecordingsTab.class);
private ScheduledService<List<JavaFxRecording>> updateService; private ScheduledService<List<JavaFxRecording>> updateService;
private Config config; private final Config config;
private Recorder recorder; private final Recorder recorder;
@SuppressWarnings("unused")
private List<Site> sites;
private long spaceTotal = -1; private long spaceTotal = -1;
private long spaceFree = -1; private long spaceFree = -1;
@ -107,11 +109,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
Label spaceLabel; Label spaceLabel;
Lock recordingsLock = new ReentrantLock(); Lock recordingsLock = new ReentrantLock();
public RecordingsTab(String title, Recorder recorder, Config config, List<Site> sites) { public RecordingsTab(String title, Recorder recorder, Config config) {
super(title); super(title);
this.recorder = recorder; this.recorder = recorder;
this.config = config; this.config = config;
this.sites = sites;
createGui(); createGui();
setClosable(false); setClosable(false);
initializeUpdateService(); initializeUpdateService();
@ -138,9 +139,9 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
date.setId("date"); date.setId("date");
date.setCellValueFactory(cdf -> { date.setCellValueFactory(cdf -> {
Instant instant = cdf.getValue().getStartDate(); Instant instant = cdf.getValue().getStartDate();
return new SimpleObjectProperty<Instant>(instant); return new SimpleObjectProperty<>(instant);
}); });
date.setCellFactory(new DateTimeCellFactory<JavaFxRecording>()); date.setCellFactory(new DateTimeCellFactory<>());
date.setPrefWidth(200); date.setPrefWidth(200);
TableColumn<JavaFxRecording, String> status = new TableColumn<>("Status"); TableColumn<JavaFxRecording, String> status = new TableColumn<>("Status");
status.setId("status"); status.setId("status");
@ -191,32 +192,29 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
} }
public boolean isDownloadRunning() { public boolean isDownloadRunning() {
return observableRecordings.stream() return observableRecordings.stream().map(Recording::getStatus).anyMatch(s -> s == DOWNLOADING);
.map(Recording::getStatus)
.anyMatch(s -> s == DOWNLOADING);
} }
private TableCell<JavaFxRecording, Number> createSizeCell() { private TableCell<JavaFxRecording, Number> createSizeCell() {
TableCell<JavaFxRecording, Number> cell = new TableCell<JavaFxRecording, Number>() { return new TableCell<>() {
@Override @Override
protected void updateItem(Number sizeInByte, boolean empty) { protected void updateItem(Number sizeInByte, boolean empty) {
if(empty || sizeInByte == null) { if (empty || sizeInByte == null) {
setText(null); setText(null);
setStyle(null); setStyle(null);
} else { } else {
setText(StringUtil.formatSize(sizeInByte)); setText(StringUtil.formatSize(sizeInByte));
setStyle("-fx-alignment: CENTER-RIGHT;"); 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(); int row = this.getTableRow().getIndex();
JavaFxRecording rec = tableViewProperty().get().getItems().get(row); 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"); setStyle("-fx-alignment: CENTER-RIGHT; -fx-background-color: red");
} }
} }
} }
} }
}; };
return cell;
} }
private void onContextMenuRequested(ContextMenuEvent event) { private void onContextMenuRequested(ContextMenuEvent event) {
@ -231,26 +229,29 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
} }
private void onMousePressed(MouseEvent event) { private void onMousePressed(MouseEvent event) {
if(popup != null) { if (popup != null) {
popup.hide(); popup.hide();
} }
} }
private void onMouseClicked(MouseEvent event) { 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(); Recording recording = table.getSelectionModel().getSelectedItem();
if(recording != null) { if (recording != null) {
play(recording); 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<JavaFxRecording> recordings = table.getSelectionModel().getSelectedItems(); List<JavaFxRecording> recordings = table.getSelectionModel().getSelectedItems();
if (recordings != null && !recordings.isEmpty()) { if (recordings != null && !recordings.isEmpty()) {
State status = recordings.get(0).getStatus(); State status = recordings.get(0).getStatus();
if (event.getCode() == KeyCode.DELETE) { 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); delete(recordings);
} }
} else if (event.getCode() == KeyCode.ENTER && status == FINISHED) { } 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 autosizeAlert = new AutosizeAlert(AlertType.ERROR, getTabPane().getScene());
autosizeAlert.setTitle("Whoopsie!"); autosizeAlert.setTitle("Whoopsie!");
autosizeAlert.setHeaderText("Recordings not available"); 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(); autosizeAlert.showAndWait();
}); });
} }
private void updateFreeSpaceDisplay() { private void updateFreeSpaceDisplay() {
if(spaceTotal != -1 && spaceFree != -1) { if (spaceTotal != -1 && spaceFree != -1) {
double free = ((double)spaceFree) / spaceTotal; double free = ((double) spaceFree) / spaceTotal;
spaceLeft.setProgress(free); spaceLeft.setProgress(free);
double totalGiB = ((double) spaceTotal) / 1024 / 1024 / 1024; double totalGiB = ((double) spaceTotal) / 1024 / 1024 / 1024;
double freeGiB = ((double) spaceFree) / 1024 / 1024 / 1024; double freeGiB = ((double) spaceFree) / 1024 / 1024 / 1024;
@ -299,13 +300,9 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
recordingsLock.lock(); recordingsLock.lock();
try { try {
for (Iterator<JavaFxRecording> iterator = observableRecordings.iterator(); iterator.hasNext();) { // remove deleted recordings
JavaFxRecording old = iterator.next(); observableRecordings.removeIf(old -> !recordings.contains(old));
if (!recordings.contains(old)) {
// remove deleted recordings
iterator.remove();
}
}
for (JavaFxRecording recording : recordings) { for (JavaFxRecording recording : recordings) {
if (!observableRecordings.contains(recording)) { if (!observableRecordings.contains(recording)) {
// add new recordings // add new recordings
@ -324,10 +321,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
} }
private ScheduledService<List<JavaFxRecording>> createUpdateService() { private ScheduledService<List<JavaFxRecording>> createUpdateService() {
ScheduledService<List<JavaFxRecording>> service = new ScheduledService<List<JavaFxRecording>>() { ScheduledService<List<JavaFxRecording>> service = new ScheduledService<>() {
@Override @Override
protected Task<List<JavaFxRecording>> createTask() { protected Task<List<JavaFxRecording>> createTask() {
return new Task<List<JavaFxRecording>>() { return new Task<>() {
@Override @Override
public List<JavaFxRecording> call() throws IOException, InvalidKeyException, NoSuchAlgorithmException { public List<JavaFxRecording> call() throws IOException, InvalidKeyException, NoSuchAlgorithmException {
updateSpace(); updateSpace();
@ -388,7 +385,11 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
JavaFxRecording first = recordings.get(0); JavaFxRecording first = recordings.get(0);
MenuItem openInPlayer = new MenuItem("Open in Player"); MenuItem openInPlayer = new MenuItem("Open in Player");
openInPlayer.setOnAction(e -> play(first)); 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); contextMenu.getItems().add(openInPlayer);
} }
@ -397,30 +398,33 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
openContactSheet.setDisable(first.getContactSheet().isEmpty()); openContactSheet.setDisable(first.getContactSheet().isEmpty());
contextMenu.getItems().add(openContactSheet); contextMenu.getItems().add(openContactSheet);
// TODO find a way to reenable this MenuItem stopRecording = new MenuItem("Stop Recording");
// MenuItem stopRecording = new MenuItem("Stop recording"); stopRecording.setOnAction(e -> stopRecording(recordings.stream().map(JavaFxRecording::getModel).collect(Collectors.toList())));
// stopRecording.setOnAction((e) -> { if (recordings.stream().anyMatch(r -> r.getStatus() == RECORDING)) {
// Model m = site.createModel(recording.getModelName()); contextMenu.getItems().add(stopRecording);
// try { }
// recorder.stopRecording(m);
// } catch (Exception e1) { MenuItem pauseRecording = new MenuItem("Pause Recording");
// showErrorDialog("Stop recording", "Couldn't stop recording of model " + m.getName(), e1); pauseRecording.setOnAction(e -> pauseRecording(recordings.stream().map(JavaFxRecording::getModel).collect(Collectors.toList())));
// } if (recordings.stream().anyMatch(r -> r.getStatus() == RECORDING)) {
// }); contextMenu.getItems().add(pauseRecording);
// if(recording.getStatus() == STATUS.RECORDING) { }
// contextMenu.getItems().add(stopRecording);
// }
MenuItem deleteRecording = new MenuItem("Delete"); MenuItem deleteRecording = new MenuItem("Delete");
deleteRecording.setOnAction(e -> delete(recordings)); 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); contextMenu.getItems().add(deleteRecording);
deleteRecording.setDisable(recordings.stream().allMatch(Recording::isPinned)); 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"); MenuItem openDir = new MenuItem("Open directory");
openDir.setOnAction(e -> onOpenDirectory(first)); openDir.setOnAction(e -> onOpenDirectory(first));
if(Config.getInstance().getSettings().localRecording) { if (Config.getInstance().getSettings().localRecording) {
contextMenu.getItems().add(openDir); contextMenu.getItems().add(openDir);
} }
@ -449,7 +453,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
contextMenu.getItems().add(rerunPostProcessing); contextMenu.getItems().add(rerunPostProcessing);
rerunPostProcessing.setDisable(!recordings.stream().allMatch(Recording::canBePostProcessed)); rerunPostProcessing.setDisable(!recordings.stream().allMatch(Recording::canBePostProcessed));
if(recordings.size() > 1) { if (recordings.size() > 1) {
openInPlayer.setDisable(true); openInPlayer.setDisable(true);
openDir.setDisable(true); openDir.setDisable(true);
} }
@ -457,6 +461,18 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
return contextMenu; return contextMenu;
} }
private void follow(List<Model> selectedModels) {
new FollowAction(getTabPane(), selectedModels).execute();
}
private void stopRecording(List<Model> selectedModels) {
new StopRecordingAction(getTabPane(), selectedModels, recorder).execute();
}
private void pauseRecording(List<Model> selectedModels) {
new PauseAction(getTabPane(), selectedModels, recorder).execute();
}
private void openContactSheet(JavaFxRecording recording) { private void openContactSheet(JavaFxRecording recording) {
if (config.getSettings().localRecording) { if (config.getSettings().localRecording) {
recording.getContactSheet().ifPresent(f -> new Thread(() -> DesktopIntegration.open(f)).start()); recording.getContactSheet().ifPresent(f -> new Thread(() -> DesktopIntegration.open(f)).start());
@ -466,7 +482,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
try { try {
target = File.createTempFile("cs_", ".jpg"); target = File.createTempFile("cs_", ".jpg");
target.deleteOnExit(); target.deleteOnExit();
FileDownload download = new FileDownload(CamrecApplication.httpClient, (p) -> { FileDownload download = new FileDownload(CamrecApplication.httpClient, p -> {
if (p == 100) { if (p == 100) {
DesktopIntegration.open(target); DesktopIntegration.open(target);
} }
@ -487,7 +503,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
Node source = getTabPane(); Node source = getTabPane();
String notes = recording.getNote(); String notes = recording.getNote();
Optional<String> newNote = Dialogs.showTextInput(source.getScene(), "Recording Notes", "", notes); Optional<String> newNote = Dialogs.showTextInput(source.getScene(), "Recording Notes", "", notes);
if(newNote.isPresent()) { if (newNote.isPresent()) {
table.setCursor(Cursor.WAIT); table.setCursor(Cursor.WAIT);
Thread backgroundThread = new Thread(() -> { Thread backgroundThread = new Thread(() -> {
List<Exception> exceptions = new ArrayList<>(); List<Exception> exceptions = new ArrayList<>();
@ -562,7 +578,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
} }
private void jumpToNextModel(KeyCode code) { 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 // determine where to start looking for the next model
int startAt = 0; int startAt = 0;
if (table.getSelectionModel().getSelectedIndex() >= 0) { if (table.getSelectionModel().getSelectedIndex() >= 0) {
@ -587,6 +606,20 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
i = 0; i = 0;
} }
} while (i != startAt); } 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) { private String proposeTargetFilename(Recording recording) {
if(recording.isSingleFile()) { if (recording.isSingleFile()) {
return recording.getPostProcessedFile().getName(); return recording.getPostProcessedFile().getName();
} else { } else {
String downloadFilename = config.getSettings().downloadFilename; String downloadFilename = config.getSettings().downloadFilename;
String fileSuffix = config.getSettings().ffmpegFileSuffix; String fileSuffix = config.getSettings().ffmpegFileSuffix;
String filename = new DownloadPostprocessor().fillInPlaceHolders(downloadFilename, recording, config) + '.' + fileSuffix; return new DownloadPostprocessor().fillInPlaceHolders(downloadFilename, recording, config) + '.' + fileSuffix;
return filename;
} }
} }
@ -665,7 +697,8 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
Platform.runLater(() -> { Platform.runLater(() -> {
recording.setStatus(FINISHED); recording.setStatus(FINISHED);
recording.setProgress(-1); 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); EventBusHolder.BUS.post(evt);
}); });
} }
@ -697,7 +730,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR, getTabPane().getScene()); AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR, getTabPane().getScene());
autosizeAlert.setTitle(title); autosizeAlert.setTitle(title);
autosizeAlert.setHeaderText(msg); 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) { for (Exception exception : exceptions) {
contentText.append("\n• ").append(exception.getLocalizedMessage()); contentText.append("\n• ").append(exception.getLocalizedMessage());
} }
@ -707,21 +740,22 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
} }
private void play(Recording recording) { private void play(Recording recording) {
new Thread() { new Thread(() -> {
@Override boolean started = Player.play(recording);
public void run() { if (started && Config.getInstance().getSettings().showPlayerStarting) {
boolean started = Player.play(recording); Platform.runLater(() -> Toast.makeText(getTabPane().getScene(), "Starting Player", 2000, 500, 500));
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<JavaFxRecording> recordings) { private void delete(List<JavaFxRecording> recordings) {
table.setCursor(Cursor.WAIT); table.setCursor(Cursor.WAIT);
String msg; String msg;
if(recordings.size() > 1) { if (recordings.size() > 1) {
msg = "Delete " + recordings.size() + " recordings for good?"; msg = "Delete " + recordings.size() + " recordings for good?";
} else { } else {
Recording r = recordings.get(0); Recording r = recordings.get(0);
@ -745,8 +779,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
try { try {
List<Recording> deleted = new ArrayList<>(); List<Recording> deleted = new ArrayList<>();
List<Exception> exceptions = new ArrayList<>(); List<Exception> exceptions = new ArrayList<>();
for (Iterator<JavaFxRecording> iterator = recordings.iterator(); iterator.hasNext();) { for (JavaFxRecording r : recordings) {
JavaFxRecording r = iterator.next();
if (r.getStatus() != FINISHED && r.getStatus() != FAILED && r.getStatus() != WAITING) { if (r.getStatus() != FINISHED && r.getStatus() != FAILED && r.getStatus() != WAITING) {
continue; continue;
} }
@ -771,7 +804,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
} }
public void saveState() { public void saveState() {
if(!table.getSortOrder().isEmpty()) { if (!table.getSortOrder().isEmpty()) {
TableColumn<JavaFxRecording, ?> col = table.getSortOrder().get(0); TableColumn<JavaFxRecording, ?> col = table.getSortOrder().get(0);
Config.getInstance().getSettings().recordingsSortColumn = col.getText(); Config.getInstance().getSettings().recordingsSortColumn = col.getText();
Config.getInstance().getSettings().recordingsSortType = col.getSortType().toString(); Config.getInstance().getSettings().recordingsSortType = col.getSortType().toString();
@ -795,9 +828,9 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
private void restoreSorting() { private void restoreSorting() {
String sortCol = Config.getInstance().getSettings().recordingsSortColumn; String sortCol = Config.getInstance().getSettings().recordingsSortColumn;
if(StringUtil.isNotBlank(sortCol)) { if (StringUtil.isNotBlank(sortCol)) {
for (TableColumn<JavaFxRecording, ?> col : table.getColumns()) { for (TableColumn<JavaFxRecording, ?> col : table.getColumns()) {
if(Objects.equals(sortCol, col.getText())) { if (Objects.equals(sortCol, col.getText())) {
col.setSortType(SortType.valueOf(Config.getInstance().getSettings().recordingsSortType)); col.setSortType(SortType.valueOf(Config.getInstance().getSettings().recordingsSortType));
table.getSortOrder().clear(); table.getSortOrder().clear();
table.getSortOrder().add(col); table.getSortOrder().add(col);
@ -809,10 +842,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
private void restoreColumnOrder() { private void restoreColumnOrder() {
String[] columnIds = Config.getInstance().getSettings().recordingsColumnIds; String[] columnIds = Config.getInstance().getSettings().recordingsColumnIds;
ObservableList<TableColumn<JavaFxRecording,?>> columns = table.getColumns(); ObservableList<TableColumn<JavaFxRecording, ?>> columns = table.getColumns();
for (int i = 0; i < columnIds.length; i++) { for (int i = 0; i < columnIds.length; i++) {
for (int j = 0; j < table.getColumns().size(); j++) { 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<JavaFxRecording, ?> col = columns.get(j); TableColumn<JavaFxRecording, ?> col = columns.get(j);
columns.remove(j); // NOSONAR columns.remove(j); // NOSONAR
columns.add(i, col); columns.add(i, col);
@ -823,7 +856,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
private void restoreColumnWidths() { private void restoreColumnWidths() {
double[] columnWidths = Config.getInstance().getSettings().recordingsColumnWidths; 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++) { for (int i = 0; i < columnWidths.length; i++) {
table.getColumns().get(i).setPrefWidth(columnWidths[i]); table.getColumns().get(i).setPrefWidth(columnWidths[i]);
} }

View File

@ -1,25 +1,8 @@
package ctbrec.ui.tabs; 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.CacheBuilder;
import com.google.common.cache.CacheLoader; import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache; import com.google.common.cache.LoadingCache;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.Model.State; import ctbrec.Model.State;
@ -27,10 +10,11 @@ import ctbrec.io.HttpException;
import ctbrec.recorder.Recorder; import ctbrec.recorder.Recorder;
import ctbrec.ui.AutosizeAlert; import ctbrec.ui.AutosizeAlert;
import ctbrec.ui.CamrecApplication; import ctbrec.ui.CamrecApplication;
import ctbrec.ui.PauseIndicator;
import ctbrec.ui.SiteUiFactory; import ctbrec.ui.SiteUiFactory;
import ctbrec.ui.StreamSourceSelectionDialog; import ctbrec.ui.StreamSourceSelectionDialog;
import ctbrec.ui.action.PlayAction; import ctbrec.ui.action.PlayAction;
import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.controls.PausedIndicator;
import ctbrec.ui.controls.StreamPreview; import ctbrec.ui.controls.StreamPreview;
import javafx.animation.FadeTransition; import javafx.animation.FadeTransition;
import javafx.animation.FillTransition; import javafx.animation.FillTransition;
@ -39,7 +23,6 @@ import javafx.animation.Transition;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.geometry.Insets; import javafx.geometry.Insets;
@ -48,6 +31,7 @@ import javafx.scene.Cursor;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
import javafx.scene.control.ContextMenu; import javafx.scene.control.ContextMenu;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.ImageView; import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
@ -63,6 +47,18 @@ import javafx.scene.text.TextAlignment;
import javafx.util.Duration; import javafx.util.Duration;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.Response; 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 { public class ThumbCell extends StackPane {
@ -85,7 +81,7 @@ public class ThumbCell extends StackPane {
private Text resolutionTag; private Text resolutionTag;
private Recorder recorder; private Recorder recorder;
private Circle recordingIndicator; private Circle recordingIndicator;
private PauseIndicator pausedIndicator; private PausedIndicator pausedIndicator;
private int index = 0; private int index = 0;
ContextMenu popup; ContextMenu popup;
private static final Color colorNormal = Color.BLACK; private static final Color colorNormal = Color.BLACK;
@ -177,12 +173,16 @@ public class ThumbCell extends StackPane {
recordingIndicator = new Circle(8); recordingIndicator = new Circle(8);
recordingIndicator.setFill(colorRecording); 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.setMargin(recordingIndicator, new Insets(3));
StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT); StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT);
getChildren().add(recordingIndicator); getChildren().add(recordingIndicator);
pausedIndicator = new PauseIndicator(colorRecording, 16); pausedIndicator = new PausedIndicator(16, colorRecording);
pausedIndicator.setVisible(false); pausedIndicator.setOnMouseClicked(e -> pauseResumeAction(false));
StackPane.setMargin(pausedIndicator, new Insets(3)); StackPane.setMargin(pausedIndicator, new Insets(3));
StackPane.setAlignment(pausedIndicator, Pos.TOP_LEFT); StackPane.setAlignment(pausedIndicator, Pos.TOP_LEFT);
getChildren().add(pausedIndicator); getChildren().add(pausedIndicator);
@ -236,7 +236,7 @@ public class ThumbCell extends StackPane {
play.setStyle("-fx-background-color: black;"); play.setStyle("-fx-background-color: black;");
previewTrigger.getChildren().add(play); previewTrigger.getChildren().add(play);
Circle clip = new Circle(s / 2); Circle clip = new Circle(s / 2.0);
clip.setTranslateX(clip.getRadius()); clip.setTranslateX(clip.getRadius());
clip.setTranslateY(clip.getRadius()); clip.setTranslateY(clip.getRadius());
previewTrigger.setClip(clip); previewTrigger.setClip(clip);
@ -287,8 +287,7 @@ public class ThumbCell extends StackPane {
resolution = resolutionCache.get(model); resolution = resolutionCache.get(model);
resolutionBackgroundColor = resolutionOnlineColor; resolutionBackgroundColor = resolutionOnlineColor;
final int w = resolution[1]; final int w = resolution[1];
String width = w != Integer.MAX_VALUE ? Integer.toString(w) : "HD"; tagText = w != Integer.MAX_VALUE ? Integer.toString(w) : "HD";
tagText = width;
if (w == 0) { if (w == 0) {
State state = model.getOnlineState(false); State state = model.getOnlineState(false);
tagText = state.name(); tagText = state.name();
@ -364,7 +363,7 @@ public class ThumbCell extends StackPane {
setThumbWidth(Config.getInstance().getSettings().thumbWidth); setThumbWidth(Config.getInstance().getSettings().thumbWidth);
}); });
} else { } else {
img.progressProperty().addListener((ChangeListener<Number>) (observable, oldValue, newValue) -> { img.progressProperty().addListener((observable, oldValue, newValue) -> {
if (newValue.doubleValue() == 1.0) { if (newValue.doubleValue() == 1.0) {
iv.setImage(img); iv.setImage(img);
setThumbWidth(Config.getInstance().getSettings().thumbWidth); setThumbWidth(Config.getInstance().getSettings().thumbWidth);
@ -403,13 +402,13 @@ public class ThumbCell extends StackPane {
private void setRecording(boolean recording) { private void setRecording(boolean recording) {
this.recording = recording; this.recording = recording;
Color c;
if (recording) { if (recording) {
Color c = mouseHovering ? colorHighlight : colorRecording; c = mouseHovering ? colorHighlight : colorRecording;
nameBackground.setFill(c);
} else { } else {
Color c = mouseHovering ? colorHighlight : colorNormal; c = mouseHovering ? colorHighlight : colorNormal;
nameBackground.setFill(c);
} }
nameBackground.setFill(c);
updateRecordingIndicator(); updateRecordingIndicator();
} }
@ -430,7 +429,7 @@ public class ThumbCell extends StackPane {
boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality; boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality;
if (selectSource && start) { if (selectSource && start) {
Function<Model, Void> onSuccess = modl -> { Function<Model, Void> onSuccess = modl -> {
startStopActionAsync(modl, start); startStopActionAsync(modl, true);
return null; return null;
}; };
Function<Throwable, Void> onFail = throwable -> { Function<Throwable, Void> onFail = throwable -> {
@ -484,13 +483,7 @@ public class ThumbCell extends StackPane {
} }
} catch (Exception e1) { } catch (Exception e1) {
LOG.error(COULDNT_START_STOP_RECORDING, e1); LOG.error(COULDNT_START_STOP_RECORDING, e1);
Platform.runLater(() -> { Dialogs.showError(getScene(), COULDNT_START_STOP_RECORDING, "I/O error while starting/stopping the recording: ", e1);
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();
});
} finally { } finally {
setCursor(Cursor.DEFAULT); setCursor(Cursor.DEFAULT);
} }
@ -507,13 +500,7 @@ public class ThumbCell extends StackPane {
if (followed) { if (followed) {
return true; return true;
} else { } else {
Platform.runLater(() -> { Dialogs.showError(getScene(), "Couldn't follow model", "", null);
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene());
alert.setTitle(ERROR);
alert.setHeaderText("Couldn't follow model");
alert.setContentText("");
alert.showAndWait();
});
return false; return false;
} }
} else { } else {
@ -523,25 +510,14 @@ public class ThumbCell extends StackPane {
Platform.runLater(() -> thumbCellList.remove(ThumbCell.this)); Platform.runLater(() -> thumbCellList.remove(ThumbCell.this));
return true; return true;
} else { } else {
Platform.runLater(() -> { Dialogs.showError(getScene(), "Couldn't unfollow model", "", null);
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene());
alert.setTitle(ERROR);
alert.setHeaderText("Couldn't unfollow model");
alert.setContentText("");
alert.showAndWait();
});
return false; return false;
} }
} }
} catch (Exception e1) { } catch (Exception e1) {
LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1); LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1);
Platform.runLater(() -> { String msg = "I/O error while following/unfollowing model " + model.getName() + ": ";
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); Dialogs.showError(getScene(), "Couldn't follow/unfollow model", msg, e1);
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();
});
return false; return false;
} finally { } finally {
setCursor(Cursor.DEFAULT); setCursor(Cursor.DEFAULT);
@ -616,8 +592,8 @@ public class ThumbCell extends StackPane {
public void setThumbWidth(int width) { public void setThumbWidth(int width) {
int height = (int) (width * imgAspectRatio); int height = (int) (width * imgAspectRatio);
setSize(width, height); setSize(width, height);
iv.prefHeight(height); iv.prefHeight(width);
iv.prefWidth(width); iv.prefWidth(height);
} }
private void setSize(int w, int h) { private void setSize(int w, int h) {
@ -638,8 +614,8 @@ public class ThumbCell extends StackPane {
topic.prefHeight(getHeight() - 25); topic.prefHeight(getHeight() - 25);
topic.maxHeight(getHeight() - 25); topic.maxHeight(getHeight() - 25);
int margin = 4; int margin = 4;
topic.maxWidth(w - margin * 2); topic.maxWidth(w - margin * 2.0);
topic.setWrappingWidth(w - margin * 2); topic.setWrappingWidth(w - margin * 2.0);
streamPreview.resizeTo(w, h); streamPreview.resizeTo(w, h);

View File

@ -1,31 +1,5 @@
package ctbrec.ui.tabs; 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.Config;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.event.EventBusHolder; import ctbrec.event.EventBusHolder;
@ -33,21 +7,13 @@ import ctbrec.recorder.Recorder;
import ctbrec.sites.Site; import ctbrec.sites.Site;
import ctbrec.sites.mfc.MyFreeCamsClient; import ctbrec.sites.mfc.MyFreeCamsClient;
import ctbrec.sites.mfc.MyFreeCamsModel; import ctbrec.sites.mfc.MyFreeCamsModel;
import ctbrec.ui.AutosizeAlert; import ctbrec.ui.*;
import ctbrec.ui.DesktopIntegration;
import ctbrec.ui.SiteUiFactory;
import ctbrec.ui.TipDialog;
import ctbrec.ui.TokenLabel;
import ctbrec.ui.action.OpenRecordingsDir; import ctbrec.ui.action.OpenRecordingsDir;
import ctbrec.ui.controls.FasterVerticalScrollPaneSkin;
import ctbrec.ui.controls.SearchBox; import ctbrec.ui.controls.SearchBox;
import ctbrec.ui.controls.SearchPopover; import ctbrec.ui.controls.SearchPopover;
import ctbrec.ui.controls.SearchPopoverTreeList; import ctbrec.ui.controls.SearchPopoverTreeList;
import javafx.animation.FadeTransition; import javafx.animation.*;
import javafx.animation.Interpolator;
import javafx.animation.ParallelTransition;
import javafx.animation.ScaleTransition;
import javafx.animation.Transition;
import javafx.animation.TranslateTransition;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleBooleanProperty;
@ -57,38 +23,30 @@ import javafx.collections.ObservableList;
import javafx.concurrent.Task; import javafx.concurrent.Task;
import javafx.concurrent.Worker.State; import javafx.concurrent.Worker.State;
import javafx.concurrent.WorkerStateEvent; import javafx.concurrent.WorkerStateEvent;
import javafx.event.ActionEvent;
import javafx.event.EventHandler; import javafx.event.EventHandler;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.Parent; import javafx.scene.Parent;
import javafx.scene.control.Alert; import javafx.scene.control.*;
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.image.ImageView; import javafx.scene.image.ImageView;
import javafx.scene.input.Clipboard; import javafx.scene.input.*;
import javafx.scene.input.ClipboardContent; import javafx.scene.layout.*;
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.transform.Transform; import javafx.scene.transform.Transform;
import javafx.util.Duration; 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 { public class ThumbOverviewTab extends Tab implements TabSelectionListener {
private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTab.class); private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTab.class);
@ -106,7 +64,6 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
private String filter; private String filter;
ReentrantLock gridLock = new ReentrantLock(); ReentrantLock gridLock = new ReentrantLock();
ScrollPane scrollPane = new ScrollPane(); ScrollPane scrollPane = new ScrollPane();
boolean loginRequired;
TextField pageInput = new TextField(Integer.toString(1)); TextField pageInput = new TextField(Integer.toString(1));
Button pageFirst = new Button("1"); Button pageFirst = new Button("1");
Button pagePrev = new Button(""); Button pagePrev = new Button("");
@ -117,9 +74,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
StackPane root = new StackPane(); StackPane root = new StackPane();
Task<List<Model>> searchTask; Task<List<Model>> searchTask;
SearchPopover popover; SearchPopover popover;
SearchPopoverTreeList popoverTreelist = new SearchPopoverTreeList(); SearchPopoverTreeList popoverTreeList = new SearchPopoverTreeList();
double imageAspectRatio = 3.0 / 4.0; double imageAspectRatio = 3.0 / 4.0;
private SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true); private final SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true);
private ComboBox<Integer> thumbWidth; private ComboBox<Integer> thumbWidth;
@ -172,7 +129,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
popover.maxHeightProperty().bind(popover.minHeightProperty()); popover.maxHeightProperty().bind(popover.minHeightProperty());
popover.prefHeightProperty().bind(popover.minHeightProperty()); popover.prefHeightProperty().bind(popover.minHeightProperty());
popover.setMinHeight(450); popover.setMinHeight(450);
popover.pushPage(popoverTreelist); popover.pushPage(popoverTreeList);
StackPane.setAlignment(popover, Pos.TOP_RIGHT); StackPane.setAlignment(popover, Pos.TOP_RIGHT);
StackPane.setMargin(popover, new Insets(35, 50, 0, 0)); StackPane.setMargin(popover, new Insets(35, 50, 0, 0));
@ -196,6 +153,8 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
scrollPane.setContent(grid); scrollPane.setContent(grid);
scrollPane.setFitToHeight(true); scrollPane.setFitToHeight(true);
scrollPane.setFitToWidth(true); scrollPane.setFitToWidth(true);
FasterVerticalScrollPaneSkin scrollPaneSkin = new FasterVerticalScrollPaneSkin(scrollPane);
scrollPane.setSkin(scrollPaneSkin);
BorderPane.setMargin(scrollPane, new Insets(5)); BorderPane.setMargin(scrollPane, new Insets(5));
pagination = new HBox(5); pagination = new HBox(5);
@ -205,29 +164,13 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
pagination.getChildren().add(pageInput); pagination.getChildren().add(pageInput);
BorderPane.setMargin(pagination, new Insets(5)); BorderPane.setMargin(pagination, new Insets(5));
pageInput.setPrefWidth(50); pageInput.setPrefWidth(50);
pageInput.setOnAction(e -> handlePageNumberInput()); pageInput.setOnAction(this::handlePageNumberInput);
pageFirst.setTooltip(new Tooltip("First Page")); pageFirst.setTooltip(new Tooltip("First Page"));
pageFirst.setOnAction(e -> { pageFirst.setOnAction(e -> changePageTo(1));
pageInput.setText(Integer.toString(1));
updateService.setPage(1);
restartUpdateService();
});
pagePrev.setTooltip(new Tooltip("Previous Page")); pagePrev.setTooltip(new Tooltip("Previous Page"));
pagePrev.setOnAction(e -> { pagePrev.setOnAction(e -> previousPage());
int page = updateService.getPage();
page = Math.max(1, --page);
pageInput.setText(Integer.toString(page));
updateService.setPage(page);
restartUpdateService();
});
pageNext.setTooltip(new Tooltip("Next Page")); pageNext.setTooltip(new Tooltip("Next Page"));
pageNext.setOnAction(e -> { pageNext.setOnAction(e -> nextPage());
int page = updateService.getPage();
page++;
pageInput.setText(Integer.toString(page));
updateService.setPage(page);
restartUpdateService();
});
HBox thumbSizeSelector = new HBox(5); HBox thumbSizeSelector = new HBox(5);
Label l = new Label("Thumb Size"); Label l = new Label("Thumb Size");
@ -242,8 +185,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
thumbWidth = new ComboBox<>(FXCollections.observableList(thumbWidths)); thumbWidth = new ComboBox<>(FXCollections.observableList(thumbWidths));
thumbWidth.getSelectionModel().select(Integer.valueOf(Config.getInstance().getSettings().thumbWidth)); thumbWidth.getSelectionModel().select(Integer.valueOf(Config.getInstance().getSettings().thumbWidth));
thumbWidth.setOnAction(e -> { thumbWidth.setOnAction(e -> {
int width = thumbWidth.getSelectionModel().getSelectedItem(); Config.getInstance().getSettings().thumbWidth = thumbWidth.getSelectionModel().getSelectedItem();
Config.getInstance().getSettings().thumbWidth = width;
updateThumbSize(); updateThumbSize();
}); });
thumbSizeSelector.getChildren().add(thumbWidth); thumbSizeSelector.getChildren().add(thumbWidth);
@ -263,6 +205,35 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
root.getChildren().add(borderPane); root.getChildren().add(borderPane);
root.getChildren().add(popover); root.getChildren().add(popover);
setContent(root); 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<? super String> search() { private ChangeListener<? super String> search() {
@ -273,7 +244,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
if(newValue.length() < 2) { if(newValue.length() < 2) {
return; return;
} }
searchTask = new ThumbOverviewTabSearchTask(site, popover, popoverTreelist, newValue); searchTask = new ThumbOverviewTabSearchTask(site, popover, popoverTreeList, newValue);
new Thread(searchTask).start(); new Thread(searchTask).start();
}; };
} }
@ -292,12 +263,11 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
} }
} }
private void handlePageNumberInput() { private void handlePageNumberInput(ActionEvent event) {
try { try {
int page = Integer.parseInt(pageInput.getText()); int page = Integer.parseInt(pageInput.getText());
page = Math.max(1, page); page = Math.max(1, page);
updateService.setPage(page); changePageTo(page);
restartUpdateService();
} catch(NumberFormatException e) { } catch(NumberFormatException e) {
// noop // noop
} finally { } finally {
@ -426,11 +396,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
if(popup != null) { if(popup != null) {
popup.hide(); popup.hide();
popup = null; popup = null;
return;
} }
}); });
newCell.selectionProperty().addListener((obs, oldValue, newValue) -> { newCell.selectionProperty().addListener((obs, oldValue, newValue) -> {
if(newValue.booleanValue()) { if (Boolean.TRUE.equals(newValue)) {
selectedThumbCells.add(newCell); selectedThumbCells.add(newCell);
} else { } else {
selectedThumbCells.remove(newCell); selectedThumbCells.remove(newCell);
@ -555,10 +524,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
event.put("amount", tokens.doubleValue()); event.put("amount", tokens.doubleValue());
EventBusHolder.BUS.post(event); EventBusHolder.BUS.post(event);
} catch (IOException ex) { } catch (IOException ex) {
LOG.error("An error occured while sending tip", ex); LOG.error("An error occurred while sending tip", ex);
showError("Couldn't send tip", "An error occured while sending tip:", ex); showError(getTabPane().getScene(), "Couldn't send tip", "An error occurred while sending tip:", ex);
} catch (Exception 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<ThumbCell> selection, boolean follow) { protected void follow(List<ThumbCell> selection, boolean follow) {
for (ThumbCell thumbCell : selection) { for (ThumbCell thumbCell : selection) {
thumbCell.follow(follow).thenAccept(success -> { thumbCell.follow(follow).thenAccept(success -> {
if(follow && success.booleanValue()) { if (follow && Boolean.TRUE.equals(success)) {
showAddToFollowedAnimation(thumbCell); showAddToFollowedAnimation(thumbCell);
} }
}); });
@ -625,35 +594,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
ParallelTransition pt = new ParallelTransition(translate, scale); ParallelTransition pt = new ParallelTransition(translate, scale);
pt.play(); pt.play();
pt.setOnFinished(evt -> root.getChildren().remove(iv)); pt.setOnFinished(evt -> root.getChildren().remove(iv));
FollowTabBlinkTransition blink = new FollowTabBlinkTransition(followedTab);
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));
blink.play(); blink.play();
}); });
} }
@ -692,13 +633,13 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
} }
} }
private EventHandler<MouseEvent> mouseClickListener = e -> { private final EventHandler<MouseEvent> mouseClickListener = e -> {
ThumbCell cell = (ThumbCell) e.getSource(); ThumbCell cell = (ThumbCell) e.getSource();
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) { if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
cell.setSelected(false); cell.setSelected(false);
cell.startPlayer(); cell.startPlayer();
} else if (e.getButton() == MouseButton.PRIMARY && e.isControlDown()) { } else if (e.getButton() == MouseButton.PRIMARY && e.isControlDown()) {
if(popup == null) { if (popup == null) {
cell.setSelected(!cell.isSelected()); cell.setSelected(!cell.isSelected());
} }
} else if (e.getButton() == MouseButton.PRIMARY) { } else if (e.getButton() == MouseButton.PRIMARY) {
@ -728,12 +669,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
} }
void filter() { void filter() {
Collections.sort(filteredThumbCells, (o1, o2) -> { filteredThumbCells.sort((c1, c2) -> {
ThumbCell c1 = o1; if (c1.getIndex() < c2.getIndex()) return -1;
ThumbCell c2 = o2; if (c1.getIndex() > c2.getIndex()) return 1;
if(c1.getIndex() < c2.getIndex()) return -1;
if(c1.getIndex() > c2.getIndex()) return 1;
return c1.getModel().getName().compareTo(c2.getModel().getName()); return c1.getModel().getName().compareTo(c2.getModel().getName());
}); });
@ -839,7 +777,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
return !tokensMissing; return !tokensMissing;
} }
private String createSearchText(Model m) throws ExecutionException { private String createSearchText(Model m) {
StringBuilder searchTextBuilder = new StringBuilder(m.getName()); StringBuilder searchTextBuilder = new StringBuilder(m.getName());
searchTextBuilder.append(' '); searchTextBuilder.append(' ');
searchTextBuilder.append(m.getDisplayName()); searchTextBuilder.append(m.getDisplayName());
@ -856,7 +794,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
public void setRecorder(Recorder recorder) { public void setRecorder(Recorder recorder) {
this.recorder = recorder; this.recorder = recorder;
popoverTreelist.setRecorder(recorder); popoverTreeList.setRecorder(recorder);
} }
@Override @Override

View File

@ -1,13 +1,5 @@
package ctbrec.ui.tabs; 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.Model;
import ctbrec.sites.Site; import ctbrec.sites.Site;
import ctbrec.ui.SiteUiFactory; import ctbrec.ui.SiteUiFactory;
@ -15,20 +7,27 @@ import ctbrec.ui.controls.SearchPopover;
import ctbrec.ui.controls.SearchPopoverTreeList; import ctbrec.ui.controls.SearchPopoverTreeList;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.concurrent.Task; 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<List<Model>> { public class ThumbOverviewTabSearchTask extends Task<List<Model>> {
private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTabSearchTask.class); private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTabSearchTask.class);
private Site site; private final Site site;
private SearchPopover popover; private final SearchPopover popover;
private SearchPopoverTreeList popoverTreelist; private final SearchPopoverTreeList popoverTreeList;
private String query; 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.site = site;
this.popover = popover; this.popover = popover;
this.popoverTreelist = popoverTreelist; this.popoverTreeList = popoverTreeList;
this.query = query; this.query = query;
} }
@ -39,10 +38,10 @@ public class ThumbOverviewTabSearchTask extends Task<List<Model>> {
try { try {
loggedin = SiteUiFactory.getUi(site).login(); loggedin = SiteUiFactory.getUi(site).login();
} catch (IOException e) { } catch (IOException e) {
loggedin = false; // nothing to do
} }
if(!loggedin) { 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); return site.search(query);
@ -61,9 +60,9 @@ public class ThumbOverviewTabSearchTask extends Task<List<Model>> {
if(models.isEmpty()) { if(models.isEmpty()) {
popover.hide(); popover.hide();
} else { } else {
popoverTreelist.getItems().clear(); popoverTreeList.getItems().clear();
for (Model model : getValue()) { for (Model model : getValue()) {
popoverTreelist.getItems().add(model); popoverTreeList.getItems().add(model);
} }
popover.show(); popover.show();
} }

View File

@ -1,8 +1,5 @@
package ctbrec.ui.tabs; package ctbrec.ui.tabs;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ctbrec.io.HttpException; import ctbrec.io.HttpException;
import ctbrec.ui.CamrecApplication; import ctbrec.ui.CamrecApplication;
import ctbrec.ui.CamrecApplication.Release; import ctbrec.ui.CamrecApplication.Release;
@ -18,12 +15,14 @@ import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.Response; import okhttp3.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class UpdateTab extends Tab { public class UpdateTab extends Tab {
private static final Logger LOG = LoggerFactory.getLogger(UpdateTab.class); private static final Logger LOG = LoggerFactory.getLogger(UpdateTab.class);
private TextArea changelog; private final TextArea changelog;
public UpdateTab(Release latest) { public UpdateTab(Release latest) {
setText("Update Available"); setText("Update Available");
@ -52,7 +51,7 @@ public class UpdateTab extends Tab {
} }
} catch (Exception e1) { } catch (Exception e1) {
LOG.error("Couldn't download the changelog", 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(); }).start();
} }

View File

@ -90,9 +90,13 @@ public class LoggingTab extends Tab {
TableColumn<LoggingEvent, String> location = createTableColumn("Location", 250, idx++); TableColumn<LoggingEvent, String> location = createTableColumn("Location", 250, idx++);
location.setCellValueFactory(cdf -> { location.setCellValueFactory(cdf -> {
StackTraceElement loc = cdf.getValue().getCallerData()[0]; if(cdf.getValue().getCallerData().length > 0) {
String l = loc.getFileName() + ":" + loc.getLineNumber(); StackTraceElement loc = cdf.getValue().getCallerData()[0];
return new SimpleStringProperty(l); String l = loc.getFileName() + ":" + loc.getLineNumber();
return new SimpleStringProperty(l);
} else {
return new SimpleStringProperty("");
}
}); });
table.getColumns().add(location); table.getColumns().add(location);

View File

@ -53,7 +53,7 @@ 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. - **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. - **recordingsDir** - Where ctbrec saves the recordings.
@ -61,8 +61,13 @@ the port ctbrec tries to connect to, if it is run in remote mode.
- **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, - **splitStrategy** - [`DONT`, `TIME`, `SIZE`, `TIME_OR_SIZE`] Defines if and how to split recordings. Also see `splitRecordingsAfterSecs` and `splitRecordingsBiggerThanBytes`
which have the defined length (roughly). 0 means no splitting. The server does not support splitRecordings.
- **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 - **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. a machine, which can be accessed from the internet, because this is totally unprotected at the moment.

View File

@ -1,4 +1,4 @@
<configuration scan="true" scanPeriod="30 seconds"> <configuration scan="true" scanPeriod="10 seconds">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder <!-- encoders are assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder

2
common/.gitignore vendored
View File

@ -1,2 +1,4 @@
/bin/ /bin/
/target/ /target/
/common.iml
/.idea/

View File

@ -8,7 +8,7 @@
<parent> <parent>
<groupId>ctbrec</groupId> <groupId>ctbrec</groupId>
<artifactId>master</artifactId> <artifactId>master</artifactId>
<version>3.10.3</version> <version>3.10.10</version>
<relativePath>../master</relativePath> <relativePath>../master</relativePath>
</parent> </parent>

View File

@ -1,5 +1,7 @@
package ctbrec; package ctbrec;
import static ctbrec.io.HttpConstants.*;
import java.io.IOException; import java.io.IOException;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
@ -17,6 +19,8 @@ import ctbrec.recorder.download.HttpHeaderFactoryImpl;
import ctbrec.recorder.download.hls.HlsDownload; import ctbrec.recorder.download.hls.HlsDownload;
import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload;
import ctbrec.sites.Site; import ctbrec.sites.Site;
import okhttp3.Request;
import okhttp3.Response;
public abstract class AbstractModel implements Model { public abstract class AbstractModel implements Model {
@ -270,4 +274,18 @@ public abstract class AbstractModel implements Model {
fac.setSegmentHeaders(new HashMap<>()); fac.setSegmentHeaders(new HashMap<>());
return fac; return fac;
} }
@Override
public boolean exists() throws IOException {
Request req = new Request.Builder() // @formatter:off
.url(getUrl())
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
.build(); // @formatter:on
try (Response response = getSite().getHttpClient().execute(req)) {
if (!response.isSuccessful() && response.code() == 404) {
return false;
}
}
return true;
}
} }

View File

@ -17,12 +17,14 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi; import com.squareup.moshi.Moshi;
import ctbrec.Settings.SplitStrategy;
import ctbrec.io.FileJsonAdapter; import ctbrec.io.FileJsonAdapter;
import ctbrec.io.ModelJsonAdapter; import ctbrec.io.ModelJsonAdapter;
import ctbrec.io.PostProcessorJsonAdapter; import ctbrec.io.PostProcessorJsonAdapter;
@ -102,6 +104,7 @@ public class Config {
migrateOldSettings(); migrateOldSettings();
} }
@SuppressWarnings("deprecation")
private void migrateOldSettings() { private void migrateOldSettings() {
// 3.8.0 from maxResolution only to resolution range // 3.8.0 from maxResolution only to resolution range
if(settings.minimumResolution == settings.maximumResolution && settings.minimumResolution == 0) { if(settings.minimumResolution == settings.maximumResolution && settings.minimumResolution == 0) {
@ -128,6 +131,36 @@ public class Config {
settings.postProcessors.add(removeKeepFile); settings.postProcessors.add(removeKeepFile);
settings.removeRecordingAfterPostProcessing = false; settings.removeRecordingAfterPostProcessing = false;
} }
// 3.10.7
if (StringUtil.isNotBlank(settings.username)) {
settings.chaturbateUsername = settings.username;
settings.username = null;
}
if (StringUtil.isNotBlank(settings.password)) {
settings.chaturbatePassword = settings.password;
settings.password = null;
}
if (settings.splitRecordings > 0) {
settings.splitStrategy = SplitStrategy.TIME;
settings.splitRecordingsAfterSecs = settings.splitRecordings;
settings.splitRecordings = 0;
}
// migrate old config from ctbrec-minimal browser
File oldLocation = new File(OS.getConfigDir().getParentFile(), "ctbrec-minimal-browser");
if (oldLocation.exists()) {
File newLocation = new File(getConfigDir(), oldLocation.getName());
try {
if (!newLocation.exists()) {
LOG.debug("Moving minimal browser config {} --> {}", 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) { private void makeBackup(File source) {

View File

@ -136,4 +136,11 @@ public interface Model extends Comparable<Model>, Serializable {
public SubsequentAction getRecordUntilSubsequentAction(); public SubsequentAction getRecordUntilSubsequentAction();
public void setRecordUntilSubsequentAction(SubsequentAction action); 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;
} }

View File

@ -86,10 +86,16 @@ public class OS {
System.arraycopy(args, 0, cmd, 1, args.length); System.arraycopy(args, 0, cmd, 1, args.length);
break; break;
case MAC: case MAC:
cmd = new String[args.length + 2]; cmd = new String[args.length + 5];
cmd[0] = "open"; int index = 0;
cmd[1] = new File(browserDir, "ctbrec-minimal-browser.app").getAbsolutePath(); cmd[index++] = "open";
System.arraycopy(args, 0, cmd, 2, args.length); 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; break;
default: default:
throw new UnsupportedOperatingSystemException(System.getProperty(OS_NAME)); throw new UnsupportedOperatingSystemException(System.getProperty(OS_NAME));

View File

@ -3,35 +3,23 @@ package ctbrec;
import static ctbrec.Recording.State.*; import static ctbrec.Recording.State.*;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.io.Serializable; 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.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.EnumSet;
import java.util.HashSet; import java.util.HashSet;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ctbrec.event.EventBusHolder; import ctbrec.event.EventBusHolder;
import ctbrec.event.RecordingStateChangedEvent; import ctbrec.event.RecordingStateChangedEvent;
import ctbrec.io.IoUtils;
import ctbrec.recorder.download.Download; import ctbrec.recorder.download.Download;
import ctbrec.recorder.download.VideoLengthDetector; import ctbrec.recorder.download.VideoLengthDetector;
public class Recording implements Serializable { public class Recording implements Serializable {
private static final transient Logger LOG = LoggerFactory.getLogger(Recording.class);
private String id; private String id;
private Model model; private Model model;
private transient Download download; private transient Download download;
@ -211,7 +199,7 @@ public class Recording implements Serializable {
public Duration getLength() { public Duration getLength() {
File ppFile = getPostProcessedFile(); File ppFile = getPostProcessedFile();
if (ppFile.isDirectory()) { if (ppFile.isDirectory()) {
File playlist = new File(ppFile, "playlist.m3u8a"); File playlist = new File(ppFile, "playlist.m3u8");
return VideoLengthDetector.getLength(playlist); return VideoLengthDetector.getLength(playlist);
} else { } else {
return VideoLengthDetector.getLength(ppFile); return VideoLengthDetector.getLength(ppFile);
@ -253,11 +241,11 @@ public class Recording implements Serializable {
private long getSize() { private long getSize() {
File rec = getAbsoluteFile(); File rec = getAbsoluteFile();
if (rec.isDirectory()) { if (rec.isDirectory()) {
return getDirectorySize(rec); return IoUtils.getDirectorySize(rec);
} else { } else {
if (!rec.exists()) { if (!rec.exists()) {
if (rec.getName().endsWith(".m3u8")) { if (rec.getName().endsWith(".m3u8")) {
return getDirectorySize(rec.getParentFile()); return IoUtils.getDirectorySize(rec.getParentFile());
} else { } else {
return -1; 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<Path>() {
@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() { public void refresh() {
sizeInByte = getSize(); sizeInByte = getSize();
} }

View File

@ -34,6 +34,13 @@ public class Settings {
SOCKS5 SOCKS5
} }
public enum SplitStrategy {
DONT,
TIME,
SIZE,
TIME_OR_SIZE
}
public String bongacamsBaseUrl = "https://bongacams.com"; public String bongacamsBaseUrl = "https://bongacams.com";
public String bongaPassword = ""; public String bongaPassword = "";
public String bongaUsername = ""; public String bongaUsername = "";
@ -41,6 +48,8 @@ public class Settings {
public String cam4Username = ""; public String cam4Username = "";
public String camsodaPassword = ""; public String camsodaPassword = "";
public String camsodaUsername = ""; public String camsodaUsername = "";
public String chaturbatePassword = "";
public String chaturbateUsername = "";
public String chaturbateBaseUrl = "https://chaturbate.com"; public String chaturbateBaseUrl = "https://chaturbate.com";
public boolean chooseStreamQuality = false; public boolean chooseStreamQuality = false;
public String colorAccent = "#FFFFFF"; public String colorAccent = "#FFFFFF";
@ -61,8 +70,8 @@ public class Settings {
public int httpSecurePort = 8443; public int httpSecurePort = 8443;
public String httpServer = "localhost"; public String httpServer = "localhost";
public int httpTimeout = 10000; public int httpTimeout = 10000;
public String httpUserAgent = "Mozilla/5.0 Gecko/20100101 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:73.0) Gecko/63.0 Firefox/73.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 byte[] key = null;
public String lastDownloadDir = ""; public String lastDownloadDir = "";
public String livejasminBaseUrl = "https://www.livejasmin.com"; public String livejasminBaseUrl = "https://www.livejasmin.com";
@ -71,6 +80,7 @@ public class Settings {
public String livejasminUsername = ""; public String livejasminUsername = "";
public boolean livePreviews = false; public boolean livePreviews = false;
public boolean localRecording = true; public boolean localRecording = true;
public boolean logFFmpegOutput = false;
public int minimumResolution = 0; public int minimumResolution = 0;
public int maximumResolution = 8640; public int maximumResolution = 8640;
public int maximumResolutionPlayer = 0; public int maximumResolutionPlayer = 0;
@ -90,10 +100,13 @@ public class Settings {
public Map<String, String> modelNotes = new HashMap<>(); public Map<String, String> modelNotes = new HashMap<>();
public List<Model> models = new ArrayList<>(); public List<Model> models = new ArrayList<>();
public List<Model> modelsIgnored = new ArrayList<>(); public List<Model> modelsIgnored = new ArrayList<>();
public boolean monitorClipboard = false;
public int onlineCheckIntervalInSecs = 60; public int onlineCheckIntervalInSecs = 60;
public boolean onlineCheckSkipsPausedModels = false; public boolean onlineCheckSkipsPausedModels = false;
public int overviewUpdateIntervalInSecs = 10; public int overviewUpdateIntervalInSecs = 10;
public String password = ""; // chaturbate password TODO maybe rename this onetime @Deprecated
public String password = "";
@Deprecated
public String postProcessing = ""; public String postProcessing = "";
public int postProcessingThreads = 2; public int postProcessingThreads = 2;
public List<PostProcessor> postProcessors = new ArrayList<>(); public List<PostProcessor> postProcessors = new ArrayList<>();
@ -120,7 +133,11 @@ public class Settings {
public String showupUsername = ""; public String showupUsername = "";
public String showupPassword = ""; public String showupPassword = "";
public boolean singlePlayer = true; public boolean singlePlayer = true;
@Deprecated
public int splitRecordings = 0; public int splitRecordings = 0;
public SplitStrategy splitStrategy = SplitStrategy.DONT;
public int splitRecordingsAfterSecs = 0;
public long splitRecordingsBiggerThanBytes = 0;
public String startTab = "Settings"; public String startTab = "Settings";
public String streamatePassword = ""; public String streamatePassword = "";
public String streamateUsername = ""; public String streamateUsername = "";
@ -130,7 +147,8 @@ public class Settings {
public boolean transportLayerSecurity = true; public boolean transportLayerSecurity = true;
public int thumbWidth = 180; public int thumbWidth = 180;
public boolean updateThumbnails = true; public boolean updateThumbnails = true;
public String username = ""; // chaturbate username TODO maybe rename this onetime @Deprecated
public String username = "";
public int windowHeight = 800; public int windowHeight = 800;
public boolean windowMaximized = false; public boolean windowMaximized = false;
public int windowWidth = 1340; public int windowWidth = 1340;

View File

@ -7,7 +7,7 @@ import org.slf4j.LoggerFactory;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.event.EventHandlerConfiguration.ActionConfiguration; import ctbrec.event.EventHandlerConfiguration.ActionConfiguration;
import ctbrec.io.StreamRedirectThread; import ctbrec.io.StreamRedirector;
public class ExecuteProgram extends Action { 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, // 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 // 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.setName("Player stdout pipe");
std.setDaemon(true); std.setDaemon(true);
std.start(); 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.setName("Player stderr pipe");
err.setDaemon(true); err.setDaemon(true);
err.start(); err.start();

View File

@ -2,7 +2,13 @@ package ctbrec.io;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.FileVisitOption;
import java.nio.file.FileVisitResult;
import java.nio.file.Files; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -50,4 +56,27 @@ public class IoUtils {
throw new IOException("Couldn't delete all files in " + directory); 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<Path>() {
@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];
}
} }

View File

@ -6,13 +6,13 @@ import java.io.OutputStream;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
public class StreamRedirectThread implements Runnable { public class StreamRedirector implements Runnable {
private static final Logger LOG = LoggerFactory.getLogger(StreamRedirectThread.class); private static final Logger LOG = LoggerFactory.getLogger(StreamRedirector.class);
private InputStream in; private InputStream in;
private OutputStream out; private OutputStream out;
public StreamRedirectThread(InputStream in, OutputStream out) { public StreamRedirector(InputStream in, OutputStream out) {
super(); super();
this.in = in; this.in = in;
this.out = out; this.out = out;

View File

@ -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<Process> startCallback;
private Consumer<Integer> 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<Process> startCallback;
private Consumer<Integer> 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<Process> callback) {
this.startCallback = callback;
return this;
}
public Builder onExit(Consumer<Integer> 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;
}
}
}

View File

@ -159,6 +159,7 @@ public class NextGenLocalRecorder implements Recorder {
ppPool.submit(() -> { ppPool.submit(() -> {
try { try {
setRecordingStatus(recording, State.POST_PROCESSING); setRecordingStatus(recording, State.POST_PROCESSING);
recording.refresh();
recordingManager.saveRecording(recording); recordingManager.saveRecording(recording);
recording.postprocess(); recording.postprocess();
List<PostProcessor> postProcessors = config.getSettings().postProcessors; List<PostProcessor> postProcessors = config.getSettings().postProcessors;

View File

@ -38,4 +38,6 @@ public interface Download extends Serializable {
* @return true, if the recording is only a single file * @return true, if the recording is only a single file
*/ */
public boolean isSingleFile(); public boolean isSingleFile();
public long getSizeInByte();
} }

View File

@ -0,0 +1,9 @@
package ctbrec.recorder.download;
import ctbrec.Settings;
public interface SplittingStrategy {
void init(Settings settings);
boolean splitNecessary(Download download);
}

View File

@ -13,7 +13,7 @@ import org.slf4j.LoggerFactory;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.io.DevNull; import ctbrec.io.DevNull;
import ctbrec.io.StreamRedirectThread; import ctbrec.io.StreamRedirector;
public class VideoLengthDetector { public class VideoLengthDetector {
private static final Logger LOG = LoggerFactory.getLogger(VideoLengthDetector.class); 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()); Process ffmpeg = Runtime.getRuntime().exec(cmdline, new String[0], videoFile.getParentFile());
int exitCode = 1; int exitCode = 1;
ByteArrayOutputStream stdErrBuffer = new ByteArrayOutputStream(); ByteArrayOutputStream stdErrBuffer = new ByteArrayOutputStream();
Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), new DevNull())); Thread stdout = new Thread(new StreamRedirector(ffmpeg.getInputStream(), new DevNull()));
Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), stdErrBuffer)); Thread stderr = new Thread(new StreamRedirector(ffmpeg.getErrorStream(), stdErrBuffer));
stdout.start(); stdout.start();
stderr.start(); stderr.start();
exitCode = ffmpeg.waitFor(); exitCode = ffmpeg.waitFor();

View File

@ -37,6 +37,7 @@ import ctbrec.Recording;
import ctbrec.io.BandwidthMeter; import ctbrec.io.BandwidthMeter;
import ctbrec.io.HttpClient; import ctbrec.io.HttpClient;
import ctbrec.io.HttpException; import ctbrec.io.HttpException;
import ctbrec.io.IoUtils;
import ctbrec.recorder.download.AbstractDownload; import ctbrec.recorder.download.AbstractDownload;
import ctbrec.recorder.download.dash.SegmentTimelineType.S; import ctbrec.recorder.download.dash.SegmentTimelineType.S;
import ctbrec.recorder.download.hls.PostProcessingException; import ctbrec.recorder.download.hls.PostProcessingException;
@ -416,4 +417,9 @@ public class DashDownload extends AbstractDownload {
return false; return false;
} }
@Override
public long getSizeInByte() {
return IoUtils.getDirectorySize(downloadDir.toFile());
}
} }

View File

@ -12,7 +12,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.io.StreamRedirectThread; import ctbrec.io.StreamRedirector;
import ctbrec.recorder.download.ProcessExitedUncleanException; import ctbrec.recorder.download.ProcessExitedUncleanException;
public class FfmpegMuxer { public class FfmpegMuxer {
@ -99,8 +99,8 @@ public class FfmpegMuxer {
// @formatter:on // @formatter:on
LOG.debug("Command line: {}", Arrays.toString(cmdline)); LOG.debug("Command line: {}", Arrays.toString(cmdline));
Process ffmpeg = Runtime.getRuntime().exec(cmdline); Process ffmpeg = Runtime.getRuntime().exec(cmdline);
Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), muxLogStream)); Thread stdout = new Thread(new StreamRedirector(ffmpeg.getInputStream(), muxLogStream));
Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), muxLogStream)); Thread stderr = new Thread(new StreamRedirector(ffmpeg.getErrorStream(), muxLogStream));
stdout.start(); stdout.start();
stderr.start(); stderr.start();
int exitCode = ffmpeg.waitFor(); int exitCode = ffmpeg.waitFor();

View File

@ -44,6 +44,7 @@ import com.iheartradio.m3u8.data.TrackData;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.Recording.State; import ctbrec.Recording.State;
import ctbrec.Settings;
import ctbrec.UnknownModel; import ctbrec.UnknownModel;
import ctbrec.io.BandwidthMeter; import ctbrec.io.BandwidthMeter;
import ctbrec.io.HttpClient; import ctbrec.io.HttpClient;
@ -51,6 +52,7 @@ import ctbrec.io.HttpException;
import ctbrec.recorder.PlaylistGenerator.InvalidPlaylistException; import ctbrec.recorder.PlaylistGenerator.InvalidPlaylistException;
import ctbrec.recorder.download.AbstractDownload; import ctbrec.recorder.download.AbstractDownload;
import ctbrec.recorder.download.HttpHeaderFactory; import ctbrec.recorder.download.HttpHeaderFactory;
import ctbrec.recorder.download.SplittingStrategy;
import ctbrec.recorder.download.StreamSource; import ctbrec.recorder.download.StreamSource;
import ctbrec.sites.Site; import ctbrec.sites.Site;
import okhttp3.Request; import okhttp3.Request;
@ -67,6 +69,7 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
protected Model model = new UnknownModel(); protected Model model = new UnknownModel();
protected transient LinkedBlockingQueue<Runnable> downloadQueue = new LinkedBlockingQueue<>(50); protected transient LinkedBlockingQueue<Runnable> downloadQueue = new LinkedBlockingQueue<>(50);
protected transient ExecutorService downloadThreadPool = new ThreadPoolExecutor(0, 5, 20, TimeUnit.SECONDS, downloadQueue, createThreadFactory()); protected transient ExecutorService downloadThreadPool = new ThreadPoolExecutor(0, 5, 20, TimeUnit.SECONDS, downloadQueue, createThreadFactory());
protected transient SplittingStrategy splittingStrategy;
protected State state = State.UNKNOWN; protected State state = State.UNKNOWN;
private int playlistEmptyCount = 0; private int playlistEmptyCount = 0;
@ -235,4 +238,27 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
this.url = url; 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;
}
} }

View File

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

View File

@ -24,7 +24,7 @@ import ctbrec.Model;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.Recording; import ctbrec.Recording;
import ctbrec.io.HttpClient; import ctbrec.io.HttpClient;
import ctbrec.io.StreamRedirectThread; import ctbrec.io.StreamRedirector;
import ctbrec.recorder.download.ProcessExitedUncleanException; import ctbrec.recorder.download.ProcessExitedUncleanException;
/** /**
@ -73,8 +73,8 @@ public class FFmpegDownload extends AbstractHlsDownload {
File ffmpegLog = File.createTempFile(targetFile.getName(), ".log"); File ffmpegLog = File.createTempFile(targetFile.getName(), ".log");
ffmpegLog.deleteOnExit(); ffmpegLog.deleteOnExit();
try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) { try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) {
Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream)); Thread stdout = new Thread(new StreamRedirector(ffmpeg.getInputStream(), mergeLogStream));
Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream)); Thread stderr = new Thread(new StreamRedirector(ffmpeg.getErrorStream(), mergeLogStream));
stdout.start(); stdout.start();
stderr.start(); stderr.start();
exitCode = ffmpeg.waitFor(); exitCode = ffmpeg.waitFor();
@ -145,4 +145,9 @@ public class FFmpegDownload extends AbstractHlsDownload {
return true; return true;
} }
@Override
public long getSizeInByte() {
return getTarget().length();
}
} }

View File

@ -13,7 +13,6 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.text.DecimalFormat; import java.text.DecimalFormat;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
@ -40,6 +39,7 @@ import ctbrec.Recording.State;
import ctbrec.io.BandwidthMeter; import ctbrec.io.BandwidthMeter;
import ctbrec.io.HttpClient; import ctbrec.io.HttpClient;
import ctbrec.io.HttpException; import ctbrec.io.HttpException;
import ctbrec.io.IoUtils;
import ctbrec.recorder.PlaylistGenerator; import ctbrec.recorder.PlaylistGenerator;
import ctbrec.recorder.download.HttpHeaderFactory; import ctbrec.recorder.download.HttpHeaderFactory;
import okhttp3.Request; import okhttp3.Request;
@ -48,6 +48,8 @@ import okhttp3.Response;
public class HlsDownload extends AbstractHlsDownload { public class HlsDownload extends AbstractHlsDownload {
private static final int TEN_SECONDS = 10_000;
private static final Logger LOG = LoggerFactory.getLogger(HlsDownload.class); private static final Logger LOG = LoggerFactory.getLogger(HlsDownload.class);
protected transient Path downloadDir; protected transient Path downloadDir;
@ -55,8 +57,8 @@ public class HlsDownload extends AbstractHlsDownload {
private int segmentCounter = 1; private int segmentCounter = 1;
private NumberFormat nf = new DecimalFormat("000000"); private NumberFormat nf = new DecimalFormat("000000");
private transient AtomicBoolean downloadFinished = new AtomicBoolean(false); private transient AtomicBoolean downloadFinished = new AtomicBoolean(false);
private ZonedDateTime splitRecStartTime;
protected transient Config config; protected transient Config config;
private transient int waitFactor = 1;
public HlsDownload(HttpClient client) { public HlsDownload(HttpClient client) {
super(client); super(client);
@ -71,6 +73,7 @@ public class HlsDownload extends AbstractHlsDownload {
String formattedStartTime = formatter.format(ZonedDateTime.ofInstant(this.startTime, ZoneId.systemDefault())); String formattedStartTime = formatter.format(ZonedDateTime.ofInstant(this.startTime, ZoneId.systemDefault()));
Path modelDir = FileSystems.getDefault().getPath(config.getSettings().recordingsDir, model.getSanitizedNamed()); Path modelDir = FileSystems.getDefault().getPath(config.getSettings().recordingsDir, model.getSanitizedNamed());
downloadDir = FileSystems.getDefault().getPath(modelDir.toString(), formattedStartTime); downloadDir = FileSystems.getDefault().getPath(modelDir.toString(), formattedStartTime);
splittingStrategy = initSplittingStrategy(config.getSettings());
} }
@Override @Override
@ -78,7 +81,6 @@ public class HlsDownload extends AbstractHlsDownload {
try { try {
running = true; running = true;
Thread.currentThread().setName("Download " + model.getName()); Thread.currentThread().setName("Download " + model.getName());
splitRecStartTime = ZonedDateTime.now();
String segments = getSegmentPlaylistUrl(model); String segments = getSegmentPlaylistUrl(model);
if (segments != null) { if (segments != null) {
if (!downloadDir.toFile().exists()) { if (!downloadDir.toFile().exists()) {
@ -86,45 +88,13 @@ public class HlsDownload extends AbstractHlsDownload {
} }
int lastSegmentNumber = 0; int lastSegmentNumber = 0;
int nextSegmentNumber = 0; int nextSegmentNumber = 0;
int waitFactor = 1;
while (running) { while (running) {
SegmentPlaylist playlist = getNextSegments(segments); SegmentPlaylist playlist = getNextSegments(segments);
emptyPlaylistCheck(playlist); emptyPlaylistCheck(playlist);
if (nextSegmentNumber > 0 && playlist.seq > nextSegmentNumber) { logMissedSegments(playlist, nextSegmentNumber);
waitFactor *= 2; enqueueNewSegments(playlist, nextSegmentNumber);
LOG.warn("Missed segments {} < {} in download for {} - setting wait factor to 1/{}", nextSegmentNumber, playlist.seq, model, splitRecordingIfNecessary();
waitFactor); waitSomeTime(playlist, lastSegmentNumber, 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);
// this if check makes sure, that we don't decrease nextSegment. for some reason // 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 // 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"); throw new IOException("Couldn't determine segments uri");
} }
} catch (ParseException e) { } 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) { } 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) { } catch (EOFException e) {
// end of playlist reached // end of playlist reached
LOG.debug("Reached end of playlist for model {}", model); LOG.debug("Reached end of playlist for model {}", model);
} catch (HttpException e) { } catch (HttpException e) {
if (e.getResponseCode() == 404) { handleHttpException(e);
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;
}
} catch (Exception e) { } catch (Exception e) {
throw new IOException("Couldn't download segment", e); throw new IOException("Couldn't download segment", e);
} finally { } finally {
downloadThreadPool.shutdown(); finalizeDownload();
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 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 { private void enqueueDownload(SegmentDownload segmentDownload, String prefix, URL segmentUrl) throws IOException, ExecutionException, InterruptedException {
try { try {
downloadThreadPool.submit(segmentDownload); 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 @Override
public void stop() { public void stop() {
if (running) { if (running) {
@ -334,4 +343,9 @@ public class HlsDownload extends AbstractHlsDownload {
public boolean isSingleFile() { public boolean isSingleFile() {
return false; return false;
} }
@Override
public long getSizeInByte() {
return IoUtils.getDirectorySize(getTarget());
}
} }

View File

@ -4,17 +4,13 @@ import static java.util.Optional.*;
import java.io.EOFException; import java.io.EOFException;
import java.io.File; import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URL; import java.net.URL;
import java.nio.file.Files; import java.nio.file.Files;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.Queue; import java.util.Queue;
@ -40,7 +36,7 @@ import ctbrec.Recording;
import ctbrec.io.BandwidthMeter; import ctbrec.io.BandwidthMeter;
import ctbrec.io.HttpClient; import ctbrec.io.HttpClient;
import ctbrec.io.HttpException; import ctbrec.io.HttpException;
import ctbrec.io.StreamRedirectThread; import ctbrec.recorder.FFmpeg;
import ctbrec.recorder.ProgressListener; import ctbrec.recorder.ProgressListener;
import ctbrec.recorder.download.HttpHeaderFactory; import ctbrec.recorder.download.HttpHeaderFactory;
import ctbrec.recorder.download.ProcessExitedUncleanException; import ctbrec.recorder.download.ProcessExitedUncleanException;
@ -53,14 +49,15 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private static final Logger LOG = LoggerFactory.getLogger(MergedFfmpegHlsDownload.class); private static final Logger LOG = LoggerFactory.getLogger(MergedFfmpegHlsDownload.class);
private static final boolean IGNORE_CACHE = true; private static final boolean IGNORE_CACHE = true;
private ZonedDateTime splitRecStartTime;
private File targetFile; private File targetFile;
private transient Config config; private transient Config config;
private transient Process ffmpeg; private transient Process ffmpegProcess;
private transient OutputStream ffmpegStdIn; private transient OutputStream ffmpegStdIn;
protected transient Thread ffmpegThread; protected transient Thread ffmpegThread;
private transient Object ffmpegStartMonitor = new Object(); private transient Object ffmpegStartMonitor = new Object();
private Queue<Future<byte[]>> downloads = new LinkedList<>(); private transient Queue<Future<byte[]>> downloads = new LinkedList<>();
private transient int lastSegment = 0;
private transient int nextSegment = 0;
public MergedFfmpegHlsDownload(HttpClient client) { public MergedFfmpegHlsDownload(HttpClient client) {
super(client); super(client);
@ -73,6 +70,7 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
this.model = model; this.model = model;
String fileSuffix = config.getSettings().ffmpegFileSuffix; String fileSuffix = config.getSettings().ffmpegFileSuffix;
targetFile = config.getFileForRecording(model, fileSuffix, startTime); targetFile = config.getFileForRecording(model, fileSuffix, startTime);
splittingStrategy = initSplittingStrategy(config.getSettings());
} }
@Override @Override
@ -86,7 +84,6 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
running = true; running = true;
Thread.currentThread().setName("Download " + model.getName()); Thread.currentThread().setName("Download " + model.getName());
super.startTime = Instant.now(); super.startTime = Instant.now();
splitRecStartTime = ZonedDateTime.now();
String segments = getSegmentPlaylistUrl(model); String segments = getSegmentPlaylistUrl(model);
@ -94,17 +91,17 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
startFfmpegProcess(targetFile); startFfmpegProcess(targetFile);
synchronized (ffmpegStartMonitor) { synchronized (ffmpegStartMonitor) {
int tries = 0; int tries = 0;
while (ffmpeg == null && tries++ < 15) { while (ffmpegProcess == null && tries++ < 15) {
LOG.debug("Waiting for FFmpeg to spawn to record {}", model.getName()); LOG.debug("Waiting for FFmpeg to spawn to record {}", model.getName());
ffmpegStartMonitor.wait(1000); ffmpegStartMonitor.wait(1000);
} }
} }
if (ffmpeg == null) { if (ffmpegProcess == null) {
throw new ProcessExitedUncleanException("Couldn't spawn FFmpeg"); throw new ProcessExitedUncleanException("Couldn't spawn FFmpeg");
} else { } else {
LOG.debug("Starting to download segments"); LOG.debug("Starting to download segments");
downloadSegments(segments, true); startDownloadLoop(segments, true);
ffmpegThread.join(); ffmpegThread.join();
LOG.debug("FFmpeg thread terminated"); LOG.debug("FFmpeg thread terminated");
} }
@ -134,47 +131,20 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
private void startFfmpegProcess(File target) { private void startFfmpegProcess(File target) {
ffmpegThread = new Thread(() -> { ffmpegThread = new Thread(() -> {
try { try {
String[] args = config.getSettings().ffmpegMergedDownloadArgs.split(" "); String[] cmdline = prepareCommandLine(target);
String[] argsPlusFile = new String[args.length + 3]; FFmpeg ffmpeg = new FFmpeg.Builder()
int i = 0; .logOutput(config.getSettings().logFFmpegOutput)
argsPlusFile[i++] = "-i"; .onStarted(p -> {
argsPlusFile[i++] = "-"; ffmpegProcess = p;
System.arraycopy(args, 0, argsPlusFile, i, args.length); ffmpegStdIn = ffmpegProcess.getOutputStream();
argsPlusFile[argsPlusFile.length - 1] = target.getAbsolutePath(); synchronized (ffmpegStartMonitor) {
String[] cmdline = OS.getFFmpegCommand(argsPlusFile); ffmpegStartMonitor.notifyAll();
}
LOG.debug("Command line: {}", Arrays.toString(cmdline)); })
ffmpeg = Runtime.getRuntime().exec(cmdline, new String[0], target.getParentFile()); .build();
synchronized (ffmpegStartMonitor) { ffmpeg.exec(cmdline, new String[0], target.getParentFile());
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);
}
}
} catch (IOException | ProcessExitedUncleanException e) { } catch (IOException | ProcessExitedUncleanException e) {
LOG.error("Error in FFMpeg thread", e); LOG.error("Error in FFmpeg thread", e);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
if (running) { if (running) {
@ -187,52 +157,23 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
ffmpegThread.start(); ffmpegThread.start();
} }
protected void downloadSegments(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException { private String[] prepareCommandLine(File target) {
int lastSegment = 0; String[] args = config.getSettings().ffmpegMergedDownloadArgs.split(" ");
int nextSegment = 0; 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) { while (running) {
try { try {
SegmentPlaylist lsp = getNextSegments(segmentPlaylistUri); downloadSegments(segmentPlaylistUri, livestreamDownload);
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;
}
} catch (HttpException e) { } catch (HttpException e) {
if (e.getResponseCode() == 404) { logHttpException(e);
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);
}
running = false; running = false;
} catch (MalformedURLException e) { } catch (MalformedURLException e) {
LOG.info("Malformed URL {} - {}", model, segmentPlaylistUri, e); LOG.info("Malformed URL {} - {}", model, segmentPlaylistUri, e);
@ -245,6 +186,54 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
ffmpegThread.interrupt(); 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 { private void downloadRecording(SegmentPlaylist lsp) throws IOException {
for (String segment : lsp.segments) { for (String segment : lsp.segments) {
URL segmentUrl = new URL(segment); URL segmentUrl = new URL(segment);
@ -292,33 +281,40 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
} catch (CancellationException e) { } catch (CancellationException e) {
LOG.info("Segment download cancelled"); LOG.info("Segment download cancelled");
} catch (ExecutionException e) { } catch (ExecutionException e) {
Throwable cause = e.getCause(); handleExecutionExceptione(e);
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 { private void handleExecutionExceptione(ExecutionException e) throws HttpException, ExecutionException {
LOG.debug("Segment not available, but model {} still online. Going on", ofNullable(model).map(Model::getName).orElse("n/a")); Throwable cause = e.getCause();
} if (cause instanceof MissingSegmentException) {
} else if (cause instanceof HttpException) { if (model != null && !isModelOnline()) {
HttpException he = (HttpException) cause; LOG.debug("Error while downloading segment, because model {} is offline. Stopping now", model.getName());
if (model != null && !isModelOnline()) { running = false;
LOG.debug("Error {} while downloading segment, because model {} is offline. Stopping now", he.getResponseCode(), model.getName()); } else {
running = false; LOG.debug("Segment not available, but model {} still online. Going on", ofNullable(model).map(Model::getName).orElse("n/a"));
} else { }
if (he.getResponseCode() == 404) { } else if (cause instanceof HttpException) {
LOG.info("Playlist for {} not found [HTTP 404]. Stopping now", ofNullable(model).map(Model::getName).orElse("n/a")); handleHttpException((HttpException)cause);
running = false; } else {
} else if (he.getResponseCode() == 403) { throw e;
LOG.info("Playlist for {} not accessible [HTTP 403]. Stopping now", ofNullable(model).map(Model::getName).orElse("n/a")); }
running = false; }
} else {
throw he; 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());
} else { running = false;
throw e; } 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); 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) { private void waitForNewSegments(SegmentPlaylist lsp, int lastSegment, long downloadTookMillis) {
try { try {
long wait = 0; long wait = 0;
@ -409,15 +393,15 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
} }
} }
if (ffmpeg != null) { if (ffmpegProcess != null) {
try { try {
boolean waitFor = ffmpeg.waitFor(45, TimeUnit.SECONDS); boolean waitFor = ffmpegProcess.waitFor(45, TimeUnit.SECONDS);
if (!waitFor && ffmpeg.isAlive()) { if (!waitFor && ffmpegProcess.isAlive()) {
ffmpeg.destroy(); ffmpegProcess.destroy();
if (ffmpeg.isAlive()) { if (ffmpegProcess.isAlive()) {
LOG.info("FFmpeg didn't terminate. Destroying the process with force!"); LOG.info("FFmpeg didn't terminate. Destroying the process with force!");
ffmpeg.destroyForcibly(); ffmpegProcess.destroyForcibly();
ffmpeg = null; ffmpegProcess = null;
} }
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
@ -493,6 +477,7 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
@Override @Override
public void postprocess(Recording recording) { public void postprocess(Recording recording) {
// nothing to do
} }
public void downloadFinishedRecording(String segmentPlaylistUri, File target, ProgressListener progressListener, long sizeInBytes) throws Exception { 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() { public boolean isSingleFile() {
return true; return true;
} }
@Override
public long getSizeInByte() {
return getTarget().length();
}
} }

View File

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

View File

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

View File

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

View File

@ -67,15 +67,18 @@ public abstract class AbstractPlaceholderAwarePostProcessor extends AbstractPost
private String replaceDateTime(Recording rec, String filename, String placeHolder, ZoneId zone) { private String replaceDateTime(Recording rec, String filename, String placeHolder, ZoneId zone) {
String pattern = "yyyy-MM-dd_HH-mm-ss"; String pattern = "yyyy-MM-dd_HH-mm-ss";
Matcher m = Pattern.compile("\\$\\{" + placeHolder + "(?:\\((.*?)\\))?\\}").matcher(filename); Pattern regex = Pattern.compile("\\$\\{" + placeHolder + "(?:\\((.*?)\\))?\\}");
if (m.find()) { Matcher m = regex.matcher(filename);
while (m.find()) {
String p = m.group(1); String p = m.group(1);
if (p != null) { if (p != null) {
pattern = p; pattern = p;
} }
String formattedDate = getDateTime(rec, pattern, zone);
filename = m.replaceFirst(formattedDate);
m = regex.matcher(filename);
} }
String formattedDate = getDateTime(rec, pattern, zone); return filename;
return m.replaceAll(formattedDate);
} }
private String getDateTime(Recording rec, String pattern, ZoneId zone) { private String getDateTime(Recording rec, String pattern, ZoneId zone) {

View File

@ -1,9 +1,7 @@
package ctbrec.recorder.postprocessing; package ctbrec.recorder.postprocessing;
import java.io.File; import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.text.MessageFormat; import java.text.MessageFormat;
import java.util.Arrays; import java.util.Arrays;
import java.util.Locale; import java.util.Locale;
@ -14,7 +12,7 @@ import org.slf4j.LoggerFactory;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.Recording; import ctbrec.Recording;
import ctbrec.io.StreamRedirectThread; import ctbrec.recorder.FFmpeg;
import ctbrec.recorder.RecordingManager; import ctbrec.recorder.RecordingManager;
public class CreateContactSheet extends AbstractPlaceholderAwarePostProcessor { public class CreateContactSheet extends AbstractPlaceholderAwarePostProcessor {
@ -80,28 +78,15 @@ public class CreateContactSheet extends AbstractPlaceholderAwarePostProcessor {
}; };
String[] cmdline = OS.getFFmpegCommand(args); String[] cmdline = OS.getFFmpegCommand(args);
LOG.info("Executing {} in working directory {}", Arrays.toString(cmdline), executionDir); LOG.info("Executing {} in working directory {}", Arrays.toString(cmdline), executionDir);
Process ffmpeg = Runtime.getRuntime().exec(cmdline, OS.getEnvironment(), executionDir); File ffmpegLog = new File(System.getProperty("java.io.tmpdir"), "create_contact_sheet_" + rec.getId() + ".log");
int exitCode = 1; FFmpeg ffmpeg = new FFmpeg.Builder()
File ffmpegLog = File.createTempFile("create_contact_sheet_" + rec.getId() + '_', ".log"); .logOutput(config.getSettings().logFFmpegOutput)
ffmpegLog.deleteOnExit(); .logFile(ffmpegLog)
try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) { .build();
Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream)); ffmpeg.exec(cmdline, OS.getEnvironment(), executionDir);
Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream)); int exitCode = ffmpeg.waitFor();
stdout.start();
stderr.start();
exitCode = ffmpeg.waitFor();
LOG.debug("FFmpeg exited with code {}", exitCode);
stdout.join();
stderr.join();
mergeLogStream.flush();
}
rec.getAssociatedFiles().add(output.getCanonicalPath()); rec.getAssociatedFiles().add(output.getCanonicalPath());
if (exitCode != 1) { return exitCode != 1;
if (ffmpegLog.exists()) {
Files.delete(ffmpegLog.toPath());
}
}
return true;
} }
private File getInputFile(Recording rec) { private File getInputFile(Recording rec) {

View File

@ -6,6 +6,7 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.Objects; import java.util.Objects;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -15,7 +16,7 @@ import ctbrec.recorder.RecordingManager;
public class Move extends AbstractPlaceholderAwarePostProcessor { 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 PATH_TEMPLATE = "path.template";
public static final String DEFAULT = "${modelSanitizedName}" + File.separatorChar + "${localDateTime}"; 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()); LOG.info("Moving {} to {}", src.getName(), target.getParentFile().getCanonicalPath());
Files.createDirectories(target.getParentFile().toPath()); 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); rec.setPostProcessedFile(target);
if (Objects.equals(src, rec.getAbsoluteFile())) { if (Objects.equals(src, rec.getAbsoluteFile())) {
rec.setAbsoluteFile(target); rec.setAbsoluteFile(target);

View File

@ -1,7 +1,6 @@
package ctbrec.recorder.postprocessing; package ctbrec.recorder.postprocessing;
import java.io.File; import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.Arrays; import java.util.Arrays;
@ -14,9 +13,8 @@ import ctbrec.Config;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.Recording; import ctbrec.Recording;
import ctbrec.io.IoUtils; import ctbrec.io.IoUtils;
import ctbrec.io.StreamRedirectThread; import ctbrec.recorder.FFmpeg;
import ctbrec.recorder.RecordingManager; import ctbrec.recorder.RecordingManager;
import ctbrec.recorder.download.ProcessExitedUncleanException;
public class Remux extends AbstractPostProcessor { public class Remux extends AbstractPostProcessor {
@ -32,70 +30,64 @@ public class Remux extends AbstractPostProcessor {
@Override @Override
public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { 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); 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[] args = getConfig().get(FFMPEG_ARGS).split(" ");
String[] argsPlusFile = new String[args.length + 3]; String[] argsPlusFile = new String[args.length + 3];
File inputFile = rec.getPostProcessedFile();
if (inputFile.isDirectory()) {
inputFile = new File(inputFile, "playlist.m3u8");
}
int i = 0; int i = 0;
argsPlusFile[i++] = "-i"; argsPlusFile[i++] = "-i";
argsPlusFile[i++] = inputFile.getCanonicalPath(); argsPlusFile[i++] = inputFile.getCanonicalPath();
System.arraycopy(args, 0, argsPlusFile, i, args.length); System.arraycopy(args, 0, argsPlusFile, i, args.length);
File remuxedFile = new File(rec.getPostProcessedFile().getAbsolutePath() + '.' + fileExt);
argsPlusFile[argsPlusFile.length - 1] = remuxedFile.getAbsolutePath(); argsPlusFile[argsPlusFile.length - 1] = remuxedFile.getAbsolutePath();
String[] cmdline = OS.getFFmpegCommand(argsPlusFile); return 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);
}
} }
@Override @Override

View File

@ -13,7 +13,7 @@ import org.slf4j.LoggerFactory;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.Recording; import ctbrec.Recording;
import ctbrec.io.StreamRedirectThread; import ctbrec.io.StreamRedirector;
import ctbrec.recorder.RecordingManager; import ctbrec.recorder.RecordingManager;
import ctbrec.recorder.download.ProcessExitedUncleanException; import ctbrec.recorder.download.ProcessExitedUncleanException;
@ -61,11 +61,11 @@ public class Script extends AbstractPlaceholderAwarePostProcessor {
} }
private void startLogging(Process process, FileOutputStream logStream) { 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.setName("Process stdout pipe");
std.setDaemon(true); std.setDaemon(true);
std.start(); 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.setName("Process stderr pipe");
err.setDaemon(true); err.setDaemon(true);
err.start(); err.start();

View File

@ -2,9 +2,6 @@ package ctbrec.recorder.postprocessing;
import java.io.IOException; import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.NotImplementedExcetion; import ctbrec.NotImplementedExcetion;
import ctbrec.Recording; import ctbrec.Recording;
@ -12,7 +9,6 @@ import ctbrec.recorder.RecordingManager;
public class Webhook extends AbstractPlaceholderAwarePostProcessor { 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 URL = "webhook.url";
public static final String HEADERS = "webhook.headers"; public static final String HEADERS = "webhook.headers";
public static final String METHOD = "webhook.method"; public static final String METHOD = "webhook.method";

View File

@ -0,0 +1,10 @@
package ctbrec.sites;
import ctbrec.Model;
public class ModelOfflineException extends RuntimeException {
public ModelOfflineException(Model model) {
super("Model " + model + " is offline");
}
}

View File

@ -2,16 +2,19 @@ package ctbrec.sites.cam4;
import static ctbrec.Model.State.*; import static ctbrec.Model.State.*;
import static ctbrec.io.HttpConstants.*; import static ctbrec.io.HttpConstants.*;
import static java.util.regex.Pattern.*;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ExecutionException; 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.json.JSONObject;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -50,60 +53,48 @@ public class Cam4Model extends AbstractModel {
if (ignoreCache || onlineState == UNKNOWN) { if (ignoreCache || onlineState == UNKNOWN) {
try { try {
loadModelDetails(); loadModelDetails();
} catch (ModelDetailsEmptyException e) { getPlaylistUrl();
return false; } catch (Exception e) {
onlineState = OFFLINE;
} }
} }
return onlineState == ONLINE && !privateRoom && playlistUrl != null && !playlistUrl.isEmpty(); return onlineState == ONLINE && !privateRoom && playlistUrl != null && !playlistUrl.isEmpty();
} }
private void loadModelDetails() throws IOException, ModelDetailsEmptyException { private void loadModelDetails() throws IOException {
String url = site.getBaseUrl() + "/getBroadcasting?usernames=" + getName(); JSONObject roomState = new Cam4WsClient(Config.getInstance(), (Cam4)getSite(), this).getRoomState();
LOG.trace("Loading model details {}", url); if(LOG.isTraceEnabled()) LOG.trace(roomState.toString(2));
Request req = new Request.Builder().url(url).build(); String state = roomState.optString("newShowsState");
try (Response response = site.getHttpClient().execute(req)) { setOnlineStateByShowType(state);
if (response.isSuccessful()) { privateRoom = roomState.optBoolean("privateRoom");
JSONArray json = new JSONArray(response.body().string()); setDescription(roomState.optString("status"));
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());
}
}
} }
public void setOnlineStateByShowType(String showType) { public void setOnlineStateByShowType(String showType) {
switch(showType) { switch(showType) {
case "NORMAL": case "NORMAL":
case "ACCEPTING":
case "GROUP_SHOW_SELLING_TICKETS": case "GROUP_SHOW_SELLING_TICKETS":
case "GS_SELLING_TICKETS":
case "GS_SELLING_TICKETS_UNSUCCESSFUL":
onlineState = ONLINE; onlineState = ONLINE;
break; break;
case "PRIVATE_SHOW": case "PRIVATE_SHOW":
case "INSIDE_PS":
onlineState = PRIVATE; onlineState = PRIVATE;
break; break;
case "INSIDE_GS":
case "GROUP_SHOW": case "GROUP_SHOW":
onlineState = GROUP; onlineState = GROUP;
break; break;
case "PAUSED":
onlineState = AWAY;
break;
case "OFFLINE": case "OFFLINE":
onlineState = OFFLINE; onlineState = OFFLINE;
break; break;
default: default:
LOG.debug("Unknown show type [{}]", showType); LOG.debug("############################## Unknown show type [{}]", showType);
onlineState = UNKNOWN; onlineState = UNKNOWN;
} }
@ -117,7 +108,7 @@ public class Cam4Model extends AbstractModel {
if(onlineState == UNKNOWN) { if(onlineState == UNKNOWN) {
try { try {
loadModelDetails(); loadModelDetails();
} catch (ModelDetailsEmptyException e) { } catch (Exception e) {
LOG.warn("Couldn't load model details {}", e.getMessage()); LOG.warn("Couldn't load model details {}", e.getMessage());
} }
} }
@ -126,19 +117,58 @@ public class Cam4Model extends AbstractModel {
} }
private String getPlaylistUrl() throws IOException { private String getPlaylistUrl() throws IOException {
if(playlistUrl == null || playlistUrl.trim().isEmpty()) { if (playlistUrl == null || playlistUrl.trim().isEmpty()) {
try { String page = loadModelPage();
loadModelDetails(); Matcher m = Pattern.compile("hlsUrl\\s*:\\s*'(.*?)'", DOTALL | MULTILINE).matcher(page);
if (playlistUrl == null) { if (m.find()) {
throw new IOException("Couldn't determine playlist url"); playlistUrl = m.group(1);
} } else {
} catch (ModelDetailsEmptyException e) { getPlaylistUrlFromStreamUrl();
throw new IOException(e); }
if (playlistUrl == null) {
throw new IOException("Couldn't determine playlist url");
} }
} }
return playlistUrl; 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 @Override
public List<StreamSource> getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { public List<StreamSource> getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException {
MasterPlaylist masterPlaylist = getMasterPlaylist(); MasterPlaylist masterPlaylist = getMasterPlaylist();
@ -160,8 +190,9 @@ public class Cam4Model extends AbstractModel {
} }
private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException { private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException {
LOG.debug("Loading master playlist [{}]", getPlaylistUrl()); String playlistUrl = getPlaylistUrl();
Request req = new Request.Builder().url(getPlaylistUrl()).build(); LOG.trace("Loading master playlist [{}]", playlistUrl);
Request req = new Request.Builder().url(playlistUrl).build();
try (Response response = site.getHttpClient().execute(req)) { try (Response response = site.getHttpClient().execute(req)) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
@ -171,7 +202,7 @@ public class Cam4Model extends AbstractModel {
MasterPlaylist master = playlist.getMasterPlaylist(); MasterPlaylist master = playlist.getMasterPlaylist();
return master; return master;
} else { } 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(resolution == null) {
if(failFast) { if(failFast) {
return new int[2]; 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<StreamSource> 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 @Override
@ -276,4 +315,13 @@ public class Cam4Model extends AbstractModel {
super(msg); 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);
}
} }

View File

@ -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<String, CompletableFuture<String>> responseFuturesByPath = new HashMap<>();
private Map<Integer, CompletableFuture<String>> 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<String> 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<Boolean> 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<String> send(String p, String msg) {
CompletableFuture<String> 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<Boolean> openWebsocketConnection() {
CompletableFuture<Boolean> 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<String> 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<String> 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;
}
}

View File

@ -47,6 +47,7 @@ public class CamsodaModel extends AbstractModel {
private static final Logger LOG = LoggerFactory.getLogger(CamsodaModel.class); private static final Logger LOG = LoggerFactory.getLogger(CamsodaModel.class);
private transient List<StreamSource> streamSources = null; private transient List<StreamSource> streamSources = null;
private transient boolean isNew; private transient boolean isNew;
private transient String gender;
private float sortOrder = 0; private float sortOrder = 0;
private Random random = new Random(); private Random random = new Random();
@ -344,4 +345,12 @@ public class CamsodaModel extends AbstractModel {
public void setNew(boolean isNew) { public void setNew(boolean isNew) {
this.isNew = isNew; this.isNew = isNew;
} }
public String getGender() {
return gender;
}
public void setGender(String gender) {
this.gender = gender;
}
} }

View File

@ -56,7 +56,7 @@ public class Chaturbate extends AbstractSite {
@Override @Override
public Double getTokenBalance() throws IOException { public Double getTokenBalance() throws IOException {
String username = Config.getInstance().getSettings().username; String username = Config.getInstance().getSettings().chaturbateUsername;
if (username == null || username.trim().isEmpty()) { if (username == null || username.trim().isEmpty()) {
throw new IOException("Not logged in"); throw new IOException("Not logged in");
} }
@ -157,7 +157,7 @@ public class Chaturbate extends AbstractSite {
@Override @Override
public boolean credentialsAvailable() { public boolean credentialsAvailable() {
String username = Config.getInstance().getSettings().username; String username = Config.getInstance().getSettings().chaturbateUsername;
return username != null && !username.trim().isEmpty(); return username != null && !username.trim().isEmpty();
} }

View File

@ -67,8 +67,8 @@ public class ChaturbateHttpClient extends HttpClient {
LOG.debug("csrf token is {}", token); LOG.debug("csrf token is {}", token);
RequestBody body = new FormBody.Builder() RequestBody body = new FormBody.Builder()
.add("username", Config.getInstance().getSettings().username) .add("username", Config.getInstance().getSettings().chaturbateUsername)
.add("password", Config.getInstance().getSettings().password) .add("password", Config.getInstance().getSettings().chaturbatePassword)
.add("next", "") .add("next", "")
.add("csrfmiddlewaretoken", token) .add("csrfmiddlewaretoken", token)
.build(); .build();
@ -103,7 +103,7 @@ public class ChaturbateHttpClient extends HttpClient {
} }
private boolean checkLogin() throws IOException { 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() Request req = new Request.Builder()
.url(url) .url(url)
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)

View File

@ -28,8 +28,8 @@ import okhttp3.Response;
public class MVLive extends AbstractSite { public class MVLive extends AbstractSite {
public static final String WS_URL = "wss://live.manyvids.com"; public static final String APP_HOST = "app-v1.live.manyvids.com";
//public static final String WS_URL = "http://localhost:8080"; public static final String WS_URL = "wss://" + APP_HOST;
public static final String WS_ORIGIN = "https://live.manyvids.com"; public static final String WS_ORIGIN = "https://live.manyvids.com";
public static final String BASE_URL = "https://www.manyvids.com/MVLive/"; public static final String BASE_URL = "https://www.manyvids.com/MVLive/";
@ -111,7 +111,8 @@ public class MVLive extends AbstractSite {
} }
@Override @Override
public void init() throws IOException { public void init() {
// nothing special to do for manyvids
} }
public List<Model> getModels() throws IOException { public List<Model> getModels() throws IOException {
@ -175,9 +176,8 @@ public class MVLive extends AbstractSite {
String getMvToken() throws IOException { String getMvToken() throws IOException {
if (mvtoken == null) { if (mvtoken == null) {
Request request = new Request.Builder() Request request = new Request.Builder()
.url(getBaseUrl()) .url("https://www.manyvids.com/")
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
.header(REFERER, MVLive.BASE_URL)
.build(); .build();
try (Response response = getHttpClient().execute(request)) { try (Response response = getHttpClient().execute(request)) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
@ -245,7 +245,7 @@ public class MVLive extends AbstractSite {
@Override @Override
public Model createModelFromUrl(String url) { 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()) { if(m.matches()) {
return createModel(m.group(1)); return createModel(m.group(1));
} }

View File

@ -23,7 +23,6 @@ import org.slf4j.LoggerFactory;
import com.google.common.base.Objects; import com.google.common.base.Objects;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.io.HttpException;
import ctbrec.sites.manyvids.wsmsg.GetBroadcastHealth; import ctbrec.sites.manyvids.wsmsg.GetBroadcastHealth;
import ctbrec.sites.manyvids.wsmsg.Message; import ctbrec.sites.manyvids.wsmsg.Message;
import ctbrec.sites.manyvids.wsmsg.Ping; import ctbrec.sites.manyvids.wsmsg.Ping;
@ -40,17 +39,18 @@ public class MVLiveClient {
private static final Logger LOG = LoggerFactory.getLogger(MVLiveClient.class); private static final Logger LOG = LoggerFactory.getLogger(MVLiveClient.class);
private final Map<String, Message> futureResponses = new HashMap<>();
private final MVLiveHttpClient httpClient;
private final Object streamUrlMonitor = new Object();
private final Random rng = new Random();
private WebSocket ws; private WebSocket ws;
private Random rng = new Random();
private volatile boolean running = false; private volatile boolean running = false;
private volatile boolean connecting = false; private volatile boolean connecting = false;
private Object streamUrlMonitor = new Object();
private String masterPlaylist = null; private String masterPlaylist = null;
private String roomNumber; private String roomNumber;
private String roomId; private String roomId;
private ScheduledExecutorService scheduler; private ScheduledExecutorService scheduler;
private Map<String, Message> futureResponses = new HashMap<>();
private MVLiveHttpClient httpClient;
public MVLiveClient(MVLiveHttpClient httpClient) { public MVLiveClient(MVLiveHttpClient httpClient) {
this.httpClient = httpClient; this.httpClient = httpClient;
@ -61,7 +61,7 @@ public class MVLiveClient {
if (ws == null && !connecting) { if (ws == null && !connecting) {
httpClient.fetchAuthenticationCookies(); httpClient.fetchAuthenticationCookies();
JSONObject response = getRoomLocation(model); JSONObject response = model.getRoomLocation();
roomNumber = response.optString("floorId"); roomNumber = response.optString("floorId");
roomId = response.optString("roomId"); roomId = response.optString("roomId");
int randomNumber = 100 + rng.nextInt(800); 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() { private String getPhpSessionIdCookie() {
List<Cookie> cookies = httpClient.getCookiesByName("PHPSESSID"); List<Cookie> cookies = httpClient.getCookiesByName("PHPSESSID");
return cookies.stream().map(c -> c.name() + "=" + c.value()).findFirst().orElse(""); return cookies.stream().map(c -> c.name() + "=" + c.value()).findFirst().orElse("");
@ -96,7 +80,7 @@ public class MVLiveClient {
public void stop() { public void stop() {
running = false; running = false;
scheduler.shutdown(); Optional.ofNullable(scheduler).ifPresent(ScheduledExecutorService::shutdown);
ws.close(1000, "Good Bye"); // terminate normally (1000) ws.close(1000, "Good Bye"); // terminate normally (1000)
ws = null; ws = null;
} }
@ -109,7 +93,7 @@ public class MVLiveClient {
.header(ORIGIN, WS_ORIGIN) .header(ORIGIN, WS_ORIGIN)
.header(COOKIE, getPhpSessionIdCookie()) .header(COOKIE, getPhpSessionIdCookie())
.build(); .build();
WebSocket websocket = httpClient.newWebSocket(req, new WebSocketListener() { return httpClient.newWebSocket(req, new WebSocketListener() {
@Override @Override
public void onOpen(WebSocket webSocket, Response response) { public void onOpen(WebSocket webSocket, Response response) {
super.onOpen(webSocket, response); super.onOpen(webSocket, response);
@ -157,7 +141,6 @@ public class MVLiveClient {
@Override @Override
public void onMessage(WebSocket webSocket, String text) { public void onMessage(WebSocket webSocket, String text) {
super.onMessage(webSocket, text); super.onMessage(webSocket, text);
//msgBuffer.append(text);
LOG.trace("Message: {}", text); LOG.trace("Message: {}", text);
text = Optional.ofNullable(text).orElse(""); text = Optional.ofNullable(text).orElse("");
if (Objects.equal("o", text)) { if (Objects.equal("o", text)) {
@ -202,7 +185,6 @@ public class MVLiveClient {
LOG.debug("Binary Message: {}", bytes.hex()); LOG.debug("Binary Message: {}", bytes.hex());
} }
}); });
return websocket;
} }
void sendMessages(Message... messages) { void sendMessages(Message... messages) {

View File

@ -1,21 +1,20 @@
package ctbrec.sites.manyvids; 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.io.IOException;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit; 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 { public class MVLiveMergedHlsDownload extends MergedFfmpegHlsDownload {
private static final Logger LOG = LoggerFactory.getLogger(MVLiveMergedHlsDownload.class); private static final Logger LOG = LoggerFactory.getLogger(MVLiveMergedHlsDownload.class);
private ScheduledExecutorService scheduler; private transient ScheduledExecutorService scheduler;
public MVLiveMergedHlsDownload(HttpClient client) { public MVLiveMergedHlsDownload(HttpClient client) {
super(client); super(client);
@ -31,7 +30,7 @@ public class MVLiveMergedHlsDownload extends MergedFfmpegHlsDownload {
t.setPriority(Thread.MIN_PRIORITY); t.setPriority(Thread.MIN_PRIORITY);
return t; return t;
}); });
scheduler.scheduleAtFixedRate(() -> updateCloudFlareCookies(), 2, 2, TimeUnit.MINUTES); scheduler.scheduleAtFixedRate(this::updateCloudFlareCookies, 2, 2, TimeUnit.MINUTES);
updateCloudFlareCookies(); updateCloudFlareCookies();
super.start(); super.start();
} finally { } finally {

View File

@ -2,6 +2,7 @@ package ctbrec.sites.manyvids;
import static ctbrec.Model.State.*; import static ctbrec.Model.State.*;
import static ctbrec.io.HttpConstants.*; import static ctbrec.io.HttpConstants.*;
import static ctbrec.sites.manyvids.MVLive.*;
import static java.nio.charset.StandardCharsets.*; import static java.nio.charset.StandardCharsets.*;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@ -116,8 +117,8 @@ public class MVLiveModel extends AbstractModel {
} }
public void updateCloudFlareCookies() throws IOException, InterruptedException { public void updateCloudFlareCookies() throws IOException, InterruptedException {
String url = MVLive.WS_ORIGIN + "/api/" + getRoomNumber() + "/player-settings/" + getDisplayName(); String url = "https://" + APP_HOST + "/api/" + getRoomNumber() + "/player-settings/" + getDisplayName();
LOG.trace("Getting CF cookies: {}", url); LOG.debug("Getting CF cookies: {}", url);
Request req = new Request.Builder() Request req = new Request.Builder()
.url(url) .url(url)
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
@ -158,8 +159,8 @@ public class MVLiveModel extends AbstractModel {
public JSONObject getRoomLocation() throws IOException { public JSONObject getRoomLocation() throws IOException {
fetchGeneralCookies(); fetchGeneralCookies();
httpClient.fetchAuthenticationCookies(); httpClient.fetchAuthenticationCookies();
String url = MVLive.WS_ORIGIN + "/api/roomlocation/" + getDisplayName() + "?private=false"; String url = "https://roompool.live.manyvids.com/roompool/" + getDisplayName() + "?private=false";
LOG.trace("Fetching room location from {}", url); LOG.debug("Fetching room location from {}", url);
Request req = new Request.Builder() Request req = new Request.Builder()
.url(url) .url(url)
.header(ACCEPT, MIMETYPE_APPLICATION_JSON) .header(ACCEPT, MIMETYPE_APPLICATION_JSON)
@ -169,8 +170,9 @@ public class MVLiveModel extends AbstractModel {
.build(); .build();
try (Response response = getHttpClient().execute(req)) { try (Response response = getHttpClient().execute(req)) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
JSONObject json = new JSONObject(response.body().string()); String body = response.body().string();
LOG.trace("Room location response: {}", json.toString(2)); JSONObject json = new JSONObject(body);
LOG.trace("Room location response: {}", json);
return json; return json;
} else { } else {
throw new HttpException(response.code(), response.message()); throw new HttpException(response.code(), response.message());

View File

@ -105,7 +105,7 @@ public class MyFreeCamsClient {
} }
String server = websocketServers.get(new Random().nextInt(websocketServers.size() - 1)); 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); LOG.debug("Connecting to random websocket server {}", wsUrl);
Thread watchDog = new Thread(() -> { Thread watchDog = new Thread(() -> {
@ -664,6 +664,7 @@ public class MyFreeCamsClient {
return camservString; return camservString;
} }
@SuppressWarnings("unused")
private boolean isBroadcasterOnWebRTC(SessionState state) { private boolean isBroadcasterOnWebRTC(SessionState state) {
return (Optional.ofNullable(state).map(SessionState::getM).map(Model::getFlags).orElse(0) & 524288) == 524288; return (Optional.ofNullable(state).map(SessionState::getM).map(Model::getFlags).orElse(0) & 524288) == 524288;
} }

View File

@ -3,8 +3,6 @@ package ctbrec.sites.showup;
import static ctbrec.io.HttpConstants.*; import static ctbrec.io.HttpConstants.*;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
@ -12,23 +10,22 @@ import java.util.Map;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.slf4j.Logger; import org.json.JSONObject;
import org.slf4j.LoggerFactory;
import com.google.common.base.Objects; import com.google.common.base.Objects;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.Settings;
import ctbrec.io.HttpClient; import ctbrec.io.HttpClient;
import ctbrec.io.HttpException; import ctbrec.io.HttpException;
import okhttp3.Cookie; import okhttp3.Cookie;
import okhttp3.FormBody;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.Response; import okhttp3.Response;
public class ShowupHttpClient extends HttpClient { public class ShowupHttpClient extends HttpClient {
private static final Logger LOG = LoggerFactory.getLogger(ShowupHttpClient.class);
private String csrfToken; private String csrfToken;
private boolean loggedIn = false;
protected ShowupHttpClient() { protected ShowupHttpClient() {
super("showup"); super("showup");
@ -79,38 +76,40 @@ public class ShowupHttpClient extends HttpClient {
} }
@Override @Override
public synchronized boolean login() throws IOException { public boolean login() throws IOException {
if (loggedIn) { Settings settings = Config.getInstance().getSettings();
return true; FormBody body = new FormBody.Builder()
} .add("is_ajax", "1")
.add("email", settings.showupUsername)
// boolean cookiesWorked = checkLoginSuccess(); .add("password", settings.showupPassword)
// if (cookiesWorked) { .add("remember", "1")
// loggedIn = true; .build();
// LOG.debug("Logged in with cookies");
// return true;
// }
return false;
}
public boolean checkLoginSuccess() throws IOException {
Request req = new Request.Builder() Request req = new Request.Builder()
.url(Showup.BASE_URL + "/site/messages") .url(Showup.BASE_URL)
//.url("http://dingens.showup.tv:1234/site/messages")
.header(ACCEPT, "*/*")
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
.header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
.header(REFERER, Showup.BASE_URL + '/') .header(REFERER, Showup.BASE_URL + '/')
.header(ORIGIN, Showup.BASE_URL)
.header(ACCEPT, MIMETYPE_APPLICATION_JSON)
.post(body)
.build(); .build();
try (Response response = execute(req)) { try (Response response = execute(req)) {
if (response.isSuccessful() && response.code() == 200) { if (response.isSuccessful()) {
Files.write(Paths.get("/tmp/messages.html"), response.body().bytes()); String responseBody = response.body().string();
//return true; if (responseBody.startsWith("{")) {
return false; JSONObject json = new JSONObject(responseBody);
return json.optString("status").equalsIgnoreCase("success");
} else {
return false;
}
} else { } else {
throw new HttpException(response.code(), response.message()); throw new HttpException(response.code(), response.message());
} }
} }
} }
public boolean checkLoginSuccess() {
return loggedIn;
}
} }

View File

@ -28,7 +28,7 @@ public class ShowupMergedDownload extends MergedFfmpegHlsDownload {
} }
@Override @Override
protected void downloadSegments(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException { protected void startDownloadLoop(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException {
try { try {
SegmentPlaylist lsp = getNextSegments(segmentPlaylistUri); SegmentPlaylist lsp = getNextSegments(segmentPlaylistUri);
emptyPlaylistCheck(lsp); emptyPlaylistCheck(lsp);
@ -48,8 +48,8 @@ public class ShowupMergedDownload extends MergedFfmpegHlsDownload {
BandwidthMeter.add(length); BandwidthMeter.add(length);
writeSegment(buffer, 0, length); writeSegment(buffer, 0, length);
keepGoing = running && !Thread.interrupted() && model.isOnline(true); keepGoing = running && !Thread.interrupted() && model.isOnline(true);
if (livestreamDownload && splitRecording()) { if (livestreamDownload) {
break; splitRecordingIfNecessary();
} }
} }
} else { } else {

View File

@ -179,7 +179,7 @@ public class Streamate extends AbstractSite {
@Override @Override
public boolean credentialsAvailable() { public boolean credentialsAvailable() {
String username = Config.getInstance().getSettings().username; String username = Config.getInstance().getSettings().streamateUsername;
return StringUtil.isNotBlank(username); return StringUtil.isNotBlank(username);
} }

View File

@ -7,14 +7,16 @@ import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.xml.bind.JAXBException; import javax.xml.bind.JAXBException;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.iheartradio.m3u8.ParseException; import com.iheartradio.m3u8.ParseException;
import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistException;
@ -29,6 +31,7 @@ import okhttp3.RequestBody;
import okhttp3.Response; import okhttp3.Response;
public class StripchatModel extends AbstractModel { public class StripchatModel extends AbstractModel {
private static final transient Logger LOG = LoggerFactory.getLogger(StripchatModel.class);
private String status = null; private String status = null;
private int[] resolution = new int[] {0, 0}; private int[] resolution = new int[] {0, 0};
@ -39,13 +42,33 @@ public class StripchatModel extends AbstractModel {
if (jsonResponse.has("user")) { if (jsonResponse.has("user")) {
JSONObject user = jsonResponse.getJSONObject("user"); JSONObject user = jsonResponse.getJSONObject("user");
status = user.optString("status"); status = user.optString("status");
mapOnlineState(status);
} }
} }
boolean online = Objects.equals(status, "public"); return onlineState == ONLINE;
if (online) { }
private void mapOnlineState(String status) {
switch (status) {
case "public":
setOnlineState(ONLINE); 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 { private JSONObject loadModelInfo() throws IOException {
@ -84,7 +107,7 @@ public class StripchatModel extends AbstractModel {
try (Response response = site.getHttpClient().execute(req)) { try (Response response = site.getHttpClient().execute(req)) {
if (response.isSuccessful()) { if (response.isSuccessful()) {
JSONObject jsonResponse = new JSONObject(response.body().string()); JSONObject jsonResponse = new JSONObject(response.body().string());
String streamName = jsonResponse.optString("streamName"); String streamName = jsonResponse.optString("streamName", jsonResponse.optString(""));
JSONObject viewServers = jsonResponse.getJSONObject("viewServers"); JSONObject viewServers = jsonResponse.getJSONObject("viewServers");
String serverName = viewServers.optString("flashphoner-hls"); String serverName = viewServers.optString("flashphoner-hls");
JSONObject broadcastSettings = jsonResponse.getJSONObject("broadcastSettings"); JSONObject broadcastSettings = jsonResponse.getJSONObject("broadcastSettings");
@ -92,18 +115,20 @@ public class StripchatModel extends AbstractModel {
StreamSource best = new StreamSource(); StreamSource best = new StreamSource();
best.height = broadcastSettings.optInt("height"); best.height = broadcastSettings.optInt("height");
best.width = broadcastSettings.optInt("width"); 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); sources.add(best);
JSONObject resolutions = broadcastSettings.optJSONObject("resolutions"); JSONObject presets = broadcastSettings.optJSONObject("presets");
if (resolutions instanceof JSONObject) { Object defaultObject = presets.get("testing");
JSONArray heights = resolutions.names(); if (defaultObject instanceof JSONObject) {
JSONObject defaults = (JSONObject) defaultObject;
JSONArray heights = defaults.names();
for (int i = 0; i < heights.length(); i++) { for (int i = 0; i < heights.length(); i++) {
String h = heights.getString(i); String h = heights.getString(i);
StreamSource streamSource = new StreamSource(); 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(); streamSource.width = streamSource.height * best.getWidth() / best.getHeight();
String source = streamName + "-" + streamSource.height + "p"; String source = streamName + '_' + streamSource.height + 'p';
streamSource.mediaPlaylistUrl = "https://b-" + serverName + ".stripst.com/hls/" + source + "/" + source + ".m3u8"; streamSource.mediaPlaylistUrl = "https://b-" + serverName + ".stripst.com/hls/" + source + '/' + source + ".m3u8";
sources.add(streamSource); sources.add(streamSource);
} }
} }
@ -111,9 +136,13 @@ public class StripchatModel extends AbstractModel {
} else { } else {
throw new HttpException(response.code(), response.message()); throw new HttpException(response.code(), response.message());
} }
} catch(JSONException e) {
System.err.println(getName());
throw e;
} }
} }
@Override @Override
public void invalidateCacheEntries() { public void invalidateCacheEntries() {
status = null; status = null;

View File

@ -54,6 +54,7 @@ public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest {
@Test @Test
public void testUtcTimeReplacement() { public void testUtcTimeReplacement() {
// without user defined pattern
String date = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") String date = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")
.withLocale(Locale.US) .withLocale(Locale.US)
.withZone(ZoneOffset.UTC) .withZone(ZoneOffset.UTC)
@ -61,12 +62,21 @@ public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest {
String input = "asdf_${utcDateTime}_asdf"; String input = "asdf_${utcDateTime}_asdf";
assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config));
// with user defined pattern
date = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") date = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
.withLocale(Locale.US) .withLocale(Locale.US)
.withZone(ZoneOffset.UTC) .withZone(ZoneOffset.UTC)
.format(rec.getStartDate()); .format(rec.getStartDate());
input = "asdf_${utcDateTime(yyyyMMdd-HHmmss)}_asdf"; input = "asdf_${utcDateTime(yyyyMMdd-HHmmss)}_asdf";
assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); 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 @Test

2
master/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.idea
master.iml

View File

@ -1,17 +1,17 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" <project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 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"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>ctbrec</groupId> <groupId>ctbrec</groupId>
<artifactId>master</artifactId> <artifactId>master</artifactId>
<packaging>pom</packaging> <packaging>pom</packaging>
<version>3.10.3</version> <version>3.10.10</version>
<modules> <modules>
<module>../common</module> <module>../common</module>
<module>../client</module>
<module>../server</module> <module>../server</module>
<module>../client</module>
</modules> </modules>
<properties> <properties>
@ -63,7 +63,7 @@
<dependency> <dependency>
<groupId>com.squareup.moshi</groupId> <groupId>com.squareup.moshi</groupId>
<artifactId>moshi</artifactId> <artifactId>moshi</artifactId>
<version>1.5.0</version> <version>1.6.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.json</groupId> <groupId>org.json</groupId>
@ -73,7 +73,7 @@
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
<version>1.7.25</version> <version>1.7.30</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>ch.qos.logback</groupId> <groupId>ch.qos.logback</groupId>

2
server/.gitignore vendored
View File

@ -7,3 +7,5 @@
/jre/ /jre/
/server-local.sh /server-local.sh
ctbrec.pid ctbrec.pid
/.idea/
*.iml

View File

@ -6,11 +6,11 @@
<artifactId>server</artifactId> <artifactId>server</artifactId>
<parent> <parent>
<groupId>ctbrec</groupId> <groupId>ctbrec</groupId>
<artifactId>master</artifactId> <artifactId>master</artifactId>
<version>3.10.3</version> <version>3.10.10</version>
<relativePath>../master</relativePath> <relativePath>../master</relativePath>
</parent> </parent>
<properties> <properties>
<maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.source>1.8</maven.compiler.source>

View File

@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.Settings; import ctbrec.Settings;
import ctbrec.Settings.SplitStrategy;
public class ConfigServlet extends AbstractCtbrecServlet { public class ConfigServlet extends AbstractCtbrecServlet {
@ -27,7 +28,7 @@ public class ConfigServlet extends AbstractCtbrecServlet {
private Settings settings; private Settings settings;
public enum DataType { public enum DataType {
STRING, BOOLEAN, INTEGER, LONG, DOUBLE STRING, BOOLEAN, INTEGER, LONG, DOUBLE, SPLIT_STRATEGY
} }
public ConfigServlet(Config config) { public ConfigServlet(Config config) {
@ -62,7 +63,9 @@ public class ConfigServlet extends AbstractCtbrecServlet {
addParameter("postProcessingThreads", "Post-Processing Threads", DataType.INTEGER, settings.postProcessingThreads, json); addParameter("postProcessingThreads", "Post-Processing Threads", DataType.INTEGER, settings.postProcessingThreads, json);
addParameter("recordingsDir", "Recordings Directory", DataType.STRING, settings.recordingsDir, json); addParameter("recordingsDir", "Recordings Directory", DataType.STRING, settings.recordingsDir, json);
addParameter("recordSingleFile", "Record Single File", DataType.BOOLEAN, settings.recordSingleFile, 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("transportLayerSecurity", "Transport Layer Security (TLS)", DataType.BOOLEAN, settings.transportLayerSecurity, json);
addParameter("webinterface", "Web-Interface", DataType.BOOLEAN, settings.webinterface, json); addParameter("webinterface", "Web-Interface", DataType.BOOLEAN, settings.webinterface, json);
addParameter("webinterfaceUsername", "Web-Interface User", DataType.STRING, settings.webinterfaceUsername, json); addParameter("webinterfaceUsername", "Web-Interface User", DataType.STRING, settings.webinterfaceUsername, json);
@ -153,6 +156,8 @@ public class ConfigServlet extends AbstractCtbrecServlet {
case DOUBLE: case DOUBLE:
corrected = Double.parseDouble(value.toString()); corrected = Double.parseDouble(value.toString());
break; break;
case SPLIT_STRATEGY:
corrected = SplitStrategy.valueOf(value.toString());
default: default:
break; break;
} }

View File

@ -50,7 +50,8 @@ public class HlsServlet extends AbstractCtbrecServlet {
boolean idOnly = request.indexOf('/') < 0; boolean idOnly = request.indexOf('/') < 0;
if (idOnly) { if (idOnly) {
requestFile = rec.get().getPostProcessedFile(); requestFile = rec.get().getPostProcessedFile();
requestedFilePath = requestFile.getCanonicalPath(); serveSegment(req, resp, requestFile);
return;
} else { } else {
requestedFilePath = request.substring(request.indexOf('/')); requestedFilePath = request.substring(request.indexOf('/'));
requestFile = new File(requestedFilePath); requestFile = new File(requestedFilePath);
@ -73,7 +74,6 @@ public class HlsServlet extends AbstractCtbrecServlet {
} }
} catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
writeResponse(resp, SC_UNAUTHORIZED, "{\"status\": \"error\", \"msg\": \"Authentication failed\"}"); 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 { private void serveSegment(HttpServletRequest req, HttpServletResponse resp, File requestedFile) throws IOException {
MimetypesFileTypeMap map = new MimetypesFileTypeMap(); MimetypesFileTypeMap map = new MimetypesFileTypeMap();
//String mimetype = requestedFile.getName().endsWith(".mp4") ? "video/mp4" : "application/octet-stream";
String mimetype = map.getContentType(requestedFile); String mimetype = map.getContentType(requestedFile);
LOG.debug("Serving {} as {}", requestedFile.getName(), mimetype); LOG.debug("Serving {} as {}", requestedFile.getName(), mimetype);
serveFile(req, resp, requestedFile, mimetype); serveFile(req, resp, requestedFile, mimetype);

View File

@ -273,7 +273,7 @@
}); });
} else { } else {
$('#addModelByUrl').autocomplete({ $('#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:"]
}); });
} }
} }

Some files were not shown because too many files have changed in this diff Show More