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
========================
* 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
/browser/
/ffmpeg/
/client.iml
/.idea/

View File

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

View File

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

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

View File

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

View File

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

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.OS;
import ctbrec.Recording;
import ctbrec.io.StreamRedirectThread;
import ctbrec.io.StreamRedirector;
import ctbrec.io.UrlUtil;
import ctbrec.recorder.download.StreamSource;
import ctbrec.ui.controls.Dialogs;
@ -165,12 +165,12 @@ public class Player {
// create threads, which read stdout and stderr of the player process. these are needed,
// because otherwise the internal buffer for these streams fill up and block the process
Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), OutputStream.nullOutputStream()));
Thread std = new Thread(new StreamRedirector(playerProcess.getInputStream(), OutputStream.nullOutputStream()));
//Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), System.out));
std.setName("Player stdout pipe");
std.setDaemon(true);
std.start();
Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), OutputStream.nullOutputStream()));
Thread err = new Thread(new StreamRedirector(playerProcess.getErrorStream(), OutputStream.nullOutputStream()));
//Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), System.err));
err.setName("Player stderr pipe");
err.setDaemon(true);

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 {
private Dialogs() {}
// TODO reduce calls to this method and use Dialogs.showError(Scene parent, String header, String text, Throwable t) instead
private static Scene scene;
public static void setScene(Scene scene) {
Dialogs.scene = scene;
}
public static void showError(String header, String text, Throwable t) {
showError(null, header, text, t);
showError(scene, header, text, t);
}
public static void showError(Scene parent, String header, String text, Throwable t) {

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;
} else {
follow.setVisible(model.getSite().supportsFollow());
follow.setDisable(!model.getSite().credentialsAvailable());
title.setVisible(true);
title.setText(model.getDisplayName());
this.model = model;

View File

@ -1,15 +1,7 @@
package ctbrec.ui.news;
import static ctbrec.io.HttpConstants.*;
import java.io.IOException;
import java.util.Objects;
import org.json.JSONObject;
import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi;
import ctbrec.io.HttpException;
import ctbrec.ui.CamrecApplication;
import ctbrec.ui.controls.Dialogs;
@ -22,10 +14,15 @@ import javafx.scene.control.Tab;
import javafx.scene.layout.VBox;
import okhttp3.Request;
import okhttp3.Response;
import org.json.JSONObject;
import java.io.IOException;
import java.util.Objects;
import static ctbrec.io.HttpConstants.USER_AGENT;
public class NewsTab extends Tab implements TabSelectionListener {
private static final String ACCESS_TOKEN = "a2804d73a89951a22e0f8483a6fcec8943afd88b7ba17c459c095aa9e6f94fd0";
//private static final String URL = "https://mastodon.cloud/api/v1/timelines/home?limit=50";
private static final String URL = "https://mastodon.cloud/api/v1/accounts/480960/statuses?limit=20&exclude_replies=true";
private VBox layout = new VBox();
@ -64,7 +61,7 @@ public class NewsTab extends Tab implements TabSelectionListener {
}
}
} catch (IOException e) {
Dialogs.showError("News", "Couldn't load news from mastodon", e);
Dialogs.showError(getTabPane().getScene(), "News", "Couldn't load news from mastodon", e);
}
}

View File

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

View File

@ -1,25 +1,18 @@
package ctbrec.ui.settings;
import ctbrec.recorder.postprocessing.*;
import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.settings.api.Preferences;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.layout.Region;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import ctbrec.Config;
import ctbrec.recorder.postprocessing.CreateContactSheet;
import ctbrec.recorder.postprocessing.DeleteTooShort;
import ctbrec.recorder.postprocessing.Move;
import ctbrec.recorder.postprocessing.PostProcessor;
import ctbrec.recorder.postprocessing.Remux;
import ctbrec.recorder.postprocessing.Rename;
import ctbrec.recorder.postprocessing.Script;
import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.settings.api.Preferences;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.layout.Region;
public class PostProcessingDialogFactory {
static Map<Class<?>, Class<?>> ppToDialogMap = new HashMap<>();
@ -35,19 +28,19 @@ public class PostProcessingDialogFactory {
private PostProcessingDialogFactory() {
}
public static void openNewDialog(PostProcessor pp, Config config, Scene scene, ObservableList<PostProcessor> stepList) {
openDialog(pp, config, scene, stepList, true);
public static void openNewDialog(PostProcessor pp, Scene scene, ObservableList<PostProcessor> stepList) {
openDialog(pp, scene, stepList, true);
}
public static void openEditDialog(PostProcessor pp, Config config, Scene scene, ObservableList<PostProcessor> stepList) {
openDialog(pp, config, scene, stepList, false);
public static void openEditDialog(PostProcessor pp, Scene scene, ObservableList<PostProcessor> stepList) {
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;
try {
Optional<Preferences> preferences = createPreferences(pp);
if(preferences.isPresent()) {
if (preferences.isPresent()) {
Region view = preferences.get().getView(false);
view.setMinWidth(600);
ok = Dialogs.showCustomInput(scene, "Configure " + pp.getName(), view);
@ -62,12 +55,12 @@ public class PostProcessingDialogFactory {
}
} catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException
| InstantiationException | IOException e) {
Dialogs.showError("New post-processing step", "Couldn't create dialog for " + pp.getName(), e);
Dialogs.showError(scene, "New post-processing step", "Couldn't create dialog for " + pp.getName(), e);
}
}
private static Optional<Preferences> createPreferences(PostProcessor pp) throws InstantiationException, IllegalAccessException, IllegalArgumentException,
InvocationTargetException, NoSuchMethodException, SecurityException {
private static Optional<Preferences> createPreferences(PostProcessor pp) throws InstantiationException, IllegalAccessException,
InvocationTargetException, NoSuchMethodException {
Class<?> paneFactoryClass = ppToDialogMap.get(pp.getClass());
if (paneFactoryClass != null) {
AbstractPostProcessingPaneFactory factory = (AbstractPostProcessingPaneFactory) paneFactoryClass.getDeclaredConstructor().newInstance();

View File

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

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.ProxyType.*;
import static ctbrec.Settings.SplitStrategy.*;
import static java.util.Optional.*;
import java.io.IOException;
@ -36,6 +37,9 @@ import ctbrec.ui.settings.api.SimpleRangeProperty;
import ctbrec.ui.settings.api.ValueConverter;
import ctbrec.ui.sites.ConfigUI;
import ctbrec.ui.tabs.TabSelectionListener;
import javafx.animation.FadeTransition;
import javafx.animation.PauseTransition;
import javafx.animation.Transition;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
@ -45,18 +49,33 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TextInputDialog;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.layout.BorderWidths;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.util.Duration;
public class SettingsTab extends Tab implements TabSelectionListener {
private static final Logger LOG = LoggerFactory.getLogger(SettingsTab.class);
public static final int CHECKBOX_MARGIN = 6;
private static final long MiB = 1024 * 1024L;
private static final long GiB = 1024 * MiB;
private List<Site> sites;
private Recorder recorder;
@ -71,6 +90,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private SimpleBooleanProperty determineResolution;
private SimpleBooleanProperty chooseStreamQuality;
private SimpleBooleanProperty livePreviews;
private SimpleBooleanProperty monitorClipboard;
private SimpleListProperty<String> startTab;
private SimpleFileProperty mediaPlayer;
private SimpleStringProperty mediaPlayerParams;
@ -85,6 +105,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private SimpleDirectoryProperty recordingsDir;
private SimpleListProperty<DirectoryStructure> directoryStructure;
private SimpleListProperty<SplitAfterOption> splitAfter;
private SimpleListProperty<SplitBiggerThanOption> splitBiggerThan;
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> values = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
@ -94,6 +115,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private SimpleBooleanProperty onlineCheckSkipsPausedModels;
private SimpleLongProperty leaveSpaceOnDevice;
private SimpleStringProperty ffmpegParameters;
private SimpleBooleanProperty logFFmpegOutput;
private SimpleStringProperty fileExtension;
private SimpleStringProperty server;
private SimpleIntegerProperty port;
@ -104,8 +126,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private ExclusiveSelectionProperty recordLocal;
private SimpleIntegerProperty postProcessingThreads;
private IgnoreList ignoreList;
private PostProcessingStepPanel postProcessingStepPanel;
private Button variablesHelpButton;
private Label restartNotification;
public SettingsTab(List<Site> sites, Recorder recorder) {
this.sites = sites;
@ -124,6 +145,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
determineResolution = new SimpleBooleanProperty(null, "determineResolution", settings.determineResolution);
chooseStreamQuality = new SimpleBooleanProperty(null, "chooseStreamQuality", settings.chooseStreamQuality);
livePreviews = new SimpleBooleanProperty(null, "livePreviews", settings.livePreviews);
monitorClipboard = new SimpleBooleanProperty(null, "monitorClipboard", settings.monitorClipboard);
startTab = new SimpleListProperty<>(null, "startTab", FXCollections.observableList(getTabNames()));
mediaPlayer = new SimpleFileProperty(null, "mediaPlayer", settings.mediaPlayer);
mediaPlayerParams = new SimpleStringProperty(null, "mediaPlayerParams", settings.mediaPlayerParams);
@ -137,12 +159,14 @@ public class SettingsTab extends Tab implements TabSelectionListener {
proxyPassword = new SimpleStringProperty(null, "proxyPassword", settings.proxyPassword);
recordingsDir = new SimpleDirectoryProperty(null, "recordingsDir", settings.recordingsDir);
directoryStructure = new SimpleListProperty<>(null, "recordingsDirStructure", FXCollections.observableList(List.of(FLAT, ONE_PER_MODEL, ONE_PER_RECORDING)));
splitAfter = new SimpleListProperty<>(null, "splitRecordings", FXCollections.observableList(getSplitOptions()));
splitAfter = new SimpleListProperty<>(null, "splitRecordingsAfterSecs", FXCollections.observableList(getSplitAfterSecsOptions()));
splitBiggerThan = new SimpleListProperty<>(null, "splitRecordingsBiggerThanBytes", FXCollections.observableList(getSplitBiggerThanOptions()));
resolutionRange = new SimpleRangeProperty<>(rangeValues, "minimumResolution", "maximumResolution", settings.minimumResolution, settings.maximumResolution);
concurrentRecordings = new SimpleIntegerProperty(null, "concurrentRecordings", settings.concurrentRecordings);
onlineCheckIntervalInSecs = new SimpleIntegerProperty(null, "onlineCheckIntervalInSecs", settings.onlineCheckIntervalInSecs);
leaveSpaceOnDevice = new SimpleLongProperty(null, "minimumSpaceLeftInBytes", (long) new GigabytesConverter().convertTo(settings.minimumSpaceLeftInBytes));
ffmpegParameters = new SimpleStringProperty(null, "ffmpegMergedDownloadArgs", settings.ffmpegMergedDownloadArgs);
logFFmpegOutput = new SimpleBooleanProperty(null, "logFFmpegOutput", settings.logFFmpegOutput);
fileExtension = new SimpleStringProperty(null, "ffmpegFileSuffix", settings.ffmpegFileSuffix);
server = new SimpleStringProperty(null, "httpServer", settings.httpServer);
port = new SimpleIntegerProperty(null, "httpPort", settings.httpPort);
@ -157,8 +181,8 @@ public class SettingsTab extends Tab implements TabSelectionListener {
}
private void createGui() {
postProcessingStepPanel = new PostProcessingStepPanel(config);
variablesHelpButton = createHelpButton("Variables", "http://localhost:5689/docs/PostProcessing.md#variables");
PostProcessingStepPanel postProcessingStepPanel = new PostProcessingStepPanel(config);
Button variablesHelpButton = createHelpButton("Variables", "http://localhost:5689/docs/PostProcessing.md#variables");
ignoreList = new IgnoreList(sites);
List<Category> siteCategories = new ArrayList<>();
for (Site site : sites) {
@ -166,18 +190,20 @@ public class SettingsTab extends Tab implements TabSelectionListener {
.ifPresent(configPanel -> siteCategories.add(Category.of(site.getName(), configPanel)));
}
Preferences prefs = Preferences.of(new CtbrecPreferencesStorage(config),
CtbrecPreferencesStorage storage = new CtbrecPreferencesStorage(config);
Preferences prefs = Preferences.of(storage,
Category.of("General",
Group.of("General",
Setting.of("User-Agent", httpUserAgent),
Setting.of("User-Agent mobile", httpUserAgentMobile),
Setting.of("Update overview interval (seconds)", overviewUpdateIntervalInSecs, "Update the thumbnail overviews every x seconds"),
Setting.of("Update overview interval (seconds)", overviewUpdateIntervalInSecs, "Update the thumbnail overviews every x seconds").needsRestart(),
Setting.of("Update thumbnails", updateThumbnails, "The overviews will still be updated, but the thumbnails won't be changed. This is useful for less powerful systems."),
Setting.of("Display stream resolution in overview", determineResolution),
Setting.of("Manually select stream quality", chooseStreamQuality, "Opens a dialog to select the video resolution before recording"),
Setting.of("Enable live previews (experimental)", livePreviews),
Setting.of("Add models from clipboard", monitorClipboard, "Monitor clipboard for model URLs and automatically add them to the recorder").needsRestart(),
Setting.of("Start Tab", startTab),
Setting.of("Colors (Base / Accent)", new ColorSettingsPane(Config.getInstance()))
Setting.of("Colors (Base / Accent)", new ColorSettingsPane(Config.getInstance())).needsRestart()
),
Group.of("Player",
Setting.of("Player", mediaPlayer),
@ -191,7 +217,8 @@ public class SettingsTab extends Tab implements TabSelectionListener {
Group.of("Settings",
Setting.of("Recordings Directory", recordingsDir),
Setting.of("Directory Structure", directoryStructure),
Setting.of("Split recordings after (minutes)", splitAfter).converter(SplitAfterOption.converter()),
Setting.of("Split recordings after", splitAfter).converter(SplitAfterOption.converter()).onChange(this::splitValuesChanged),
Setting.of("Split recordings bigger than", splitBiggerThan).converter(SplitBiggerThanOption.converter()).onChange(this::splitValuesChanged),
Setting.of("Restrict Resolution", resolutionRange, "Only record streams with resolution within the given range"),
Setting.of("Concurrent Recordings (0 = unlimited)", concurrentRecordings),
Setting.of("Leave space on device (GiB)", leaveSpaceOnDevice, "Stop recording, if the free space on the device gets below this threshold").converter(new GigabytesConverter()),
@ -201,7 +228,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
Setting.of("Skip online check for paused models", onlineCheckSkipsPausedModels, "Skip online check for paused models")
),
Group.of("Location",
Setting.of("Record Location", recordLocal),
Setting.of("Record Location", recordLocal).needsRestart(),
Setting.of("Server", server),
Setting.of("Port", port),
Setting.of("Path", path, "Leave empty, if you didn't change the servletContext in the server config"),
@ -223,15 +250,22 @@ public class SettingsTab extends Tab implements TabSelectionListener {
Category.of("Sites", siteCategories.toArray(new Category[0])),
Category.of("Proxy",
Group.of("Proxy",
Setting.of("Type", proxyType),
Setting.of("Host", proxyHost),
Setting.of("Port", proxyPort),
Setting.of("Username", proxyUser),
Setting.of("Password", proxyPassword)
Setting.of("Type", proxyType).needsRestart(),
Setting.of("Host", proxyHost).needsRestart(),
Setting.of("Port", proxyPort).needsRestart(),
Setting.of("Username", proxyUser).needsRestart(),
Setting.of("Password", proxyPassword).needsRestart()
)
),
Category.of("Advanced / Devtools",
Group.of("Logging",
Setting.of("Log FFmpeg output", logFFmpegOutput, "Log FFmpeg output to files in the system's temp directory")
)
)
);
Region preferencesView = prefs.getView();
prefs.onRestartRequired(this::showRestartRequired);
storage.setPreferences(prefs);
preferencesView.setMinSize(800, 400);
preferencesView.setPrefSize(1280, 960);
ScrollPane scrollPane = new ScrollPane(preferencesView);
@ -241,7 +275,20 @@ public class SettingsTab extends Tab implements TabSelectionListener {
GridPane.setFillHeight(scrollPane, true);
GridPane.setHgrow(scrollPane, Priority.ALWAYS);
GridPane.setVgrow(scrollPane, Priority.ALWAYS);
setContent(container);
StackPane stackPane = new StackPane();
stackPane.getChildren().add(container);
restartNotification = new Label("Restart Required");
restartNotification.setVisible(false);
restartNotification.setOpacity(0);
restartNotification.setStyle("-fx-font-size: 28; -fx-padding: .3em");
restartNotification.setBorder(new Border(new BorderStroke(Color.web(settings.colorAccent), BorderStrokeStyle.SOLID, new CornerRadii(5), new BorderWidths(2))));
restartNotification.setBackground(new Background(new BackgroundFill(Color.web(settings.colorBase), new CornerRadii(5), Insets.EMPTY)));
stackPane.getChildren().add(restartNotification);
StackPane.setAlignment(restartNotification, Pos.TOP_RIGHT);
StackPane.setMargin(restartNotification, new Insets(10, 40, 0, 0));
setContent(stackPane);
prefs.expandTree();
@ -251,7 +298,8 @@ public class SettingsTab extends Tab implements TabSelectionListener {
prefs.getSetting("requireAuthentication").ifPresent(s -> bindEnabledProperty(s, recordLocal));
prefs.getSetting("transportLayerSecurity").ifPresent(s -> bindEnabledProperty(s, recordLocal));
prefs.getSetting("recordingsDir").ifPresent(s -> bindEnabledProperty(s, recordLocal.not()));
prefs.getSetting("splitRecordings").ifPresent(s -> bindEnabledProperty(s, recordLocal.not()));
prefs.getSetting("splitRecordingsAfterSecs").ifPresent(s -> bindEnabledProperty(s, recordLocal.not()));
prefs.getSetting("splitRecordingsBiggerThanBytes").ifPresent(s -> bindEnabledProperty(s, recordLocal.not()));
prefs.getSetting("minimumResolution").ifPresent(s -> bindEnabledProperty(s, recordLocal.not()));
prefs.getSetting("recordingsDirStructure").ifPresent(s -> bindEnabledProperty(s, recordLocal.not()));
prefs.getSetting("onlineCheckIntervalInSecs").ifPresent(s -> bindEnabledProperty(s, recordLocal.not()));
@ -266,6 +314,23 @@ public class SettingsTab extends Tab implements TabSelectionListener {
prefs.getSetting("downloadFilename").ifPresent(s -> bindEnabledProperty(s, recordLocal));
postProcessingStepPanel.disableProperty().bind(recordLocal.not());
variablesHelpButton.disableProperty().bind(recordLocal);
}
private void splitValuesChanged(ObservableValue<?> value, Object oldV, Object newV) {
boolean splitAfterSet = settings.splitRecordingsAfterSecs > 0;
boolean splitBiggerThanSet = settings.splitRecordingsBiggerThanBytes > 0;
if (splitAfterSet && splitBiggerThanSet) {
settings.splitStrategy = TIME_OR_SIZE;
} else if (splitAfterSet) {
settings.splitStrategy = TIME;
} else if (splitBiggerThanSet) {
settings.splitStrategy = SIZE;
} else {
settings.splitStrategy = DONT;
}
saveConfig();
}
private Button createHelpButton(String text, String url) {
@ -288,7 +353,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
.collect(Collectors.toList());
}
private List<SplitAfterOption> getSplitOptions() {
private List<SplitAfterOption> getSplitAfterSecsOptions() {
List<SplitAfterOption> splitOptions = new ArrayList<>();
splitOptions.add(new SplitAfterOption("disabled", 0));
if (Config.isDevMode()) {
@ -304,6 +369,29 @@ public class SettingsTab extends Tab implements TabSelectionListener {
return splitOptions;
}
private List<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
boolean requiresAuthentication = newV;
Config.getInstance().getSettings().requireAuthentication = requiresAuthentication;
@ -359,7 +447,26 @@ public class SettingsTab extends Tab implements TabSelectionListener {
}
void showRestartRequired() {
// TODO restartLabel.setVisible(true);
if (!restartNotification.isVisible()) {
restartNotification.setVisible(true);
Transition fadeIn = changeOpacity(restartNotification, 1);
fadeIn.play();
fadeIn.setOnFinished(e -> {
Transition fadeOut = changeOpacity(restartNotification, 0);
fadeOut.setOnFinished(e2 -> restartNotification.setVisible(false));
PauseTransition pauseTransition = new PauseTransition(Duration.seconds(5));
pauseTransition.setOnFinished(evt -> fadeOut.play());
pauseTransition.play();
});
}
}
private static final Duration ANIMATION_DURATION = new Duration(500);
private Transition changeOpacity(Node node, double opacity) {
FadeTransition transition = new FadeTransition(ANIMATION_DURATION, node);
transition.setFromValue(node.getOpacity());
transition.setToValue(opacity);
return transition;
}
public static class SplitAfterOption {
@ -415,4 +522,60 @@ public class SettingsTab extends Tab implements TabSelectionListener {
};
}
}
public static class SplitBiggerThanOption {
private String label;
private long value;
public SplitBiggerThanOption(String label, long value) {
super();
this.label = label;
this.value = value;
}
public long getValue() {
return value;
}
@Override
public String toString() {
return label;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (int) (value ^ (value >>> 32));
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
SplitBiggerThanOption other = (SplitBiggerThanOption) obj;
if (value != other.value)
return false;
return true;
}
public static ValueConverter converter() {
return new ValueConverter() {
@Override
public Long convertFrom(Object splitBiggerThanOption) {
return ((SplitBiggerThanOption) splitBiggerThanOption).getValue();
}
@Override
public SplitBiggerThanOption convertTo(Object value) {
return new SplitBiggerThanOption(value.toString(), (Long) value);
}
};
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -47,7 +47,7 @@ public class BongaCamsTabProvider extends TabProvider {
tabs.add(createTab("Transsexual", updateService));
// new
url = BongaCams.baseUrl + "/tools/listing_v3.php?livetab=new-models&online_only=true&is_mobile=true&offset=";
url = BongaCams.baseUrl + "/tools/listing_v3.php?livetab=new&online_only=true&is_mobile=true&offset=";
updateService = new BongaCamsUpdateService(bongaCams, url);
tabs.add(createTab("New", updateService));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,5 @@
package ctbrec.ui.tabs;
import static ctbrec.ui.controls.Dialogs.*;
import java.io.IOException;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ctbrec.Model;
import ctbrec.sites.Site;
import ctbrec.ui.SiteUiFactory;
@ -15,20 +7,27 @@ import ctbrec.ui.controls.SearchPopover;
import ctbrec.ui.controls.SearchPopoverTreeList;
import javafx.application.Platform;
import javafx.concurrent.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.List;
import static ctbrec.ui.controls.Dialogs.showError;
public class ThumbOverviewTabSearchTask extends Task<List<Model>> {
private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTabSearchTask.class);
private Site site;
private SearchPopover popover;
private SearchPopoverTreeList popoverTreelist;
private String query;
private final Site site;
private final SearchPopover popover;
private final SearchPopoverTreeList popoverTreeList;
private final String query;
public ThumbOverviewTabSearchTask(Site site, SearchPopover popover, SearchPopoverTreeList popoverTreelist, String query) {
public ThumbOverviewTabSearchTask(Site site, SearchPopover popover, SearchPopoverTreeList popoverTreeList, String query) {
this.site = site;
this.popover = popover;
this.popoverTreelist = popoverTreelist;
this.popoverTreeList = popoverTreeList;
this.query = query;
}
@ -39,10 +38,10 @@ public class ThumbOverviewTabSearchTask extends Task<List<Model>> {
try {
loggedin = SiteUiFactory.getUi(site).login();
} catch (IOException e) {
loggedin = false;
// nothing to do
}
if(!loggedin) {
showError("Login failed", "Search won't work correctly without login", null);
showError(popover.getScene(), "Login failed", "Search won't work correctly without login", null);
}
}
return site.search(query);
@ -61,9 +60,9 @@ public class ThumbOverviewTabSearchTask extends Task<List<Model>> {
if(models.isEmpty()) {
popover.hide();
} else {
popoverTreelist.getItems().clear();
popoverTreeList.getItems().clear();
for (Model model : getValue()) {
popoverTreelist.getItems().add(model);
popoverTreeList.getItems().add(model);
}
popover.show();
}

View File

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

View File

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

View File

@ -53,16 +53,21 @@ the port ctbrec tries to connect to, if it is run in remote mode.
- **onlineCheckSkipsPausedModels** - [`true`,`false`] Skip the online check for paused models. If you have many models in the recording list, this can reduce the delay when a recording starts after a model came online.
- **postProcessing** - Absolute path to a script, which is executed once a recording is finished. See [Post-Processing](/docs/PostProcessing.md).
- **postProcessing** - **Deprecated. See [Post-Processing](/docs/PostProcessing.md)** Absolute path to a script, which is executed once a recording is finished.
- **recordingsDir** - Where ctbrec saves the recordings.
- **recordingsDirStructure** (server only) - [`FLAT`, `ONE_PER_MODEL`, `ONE_PER_RECORDING`] How recordings are stored in the file system. `FLAT` - all recordings in one directory; `ONE_PER_MODEL` - one directory per model; `ONE_PER_RECORDING` - each recordings ends up in its own directory. Change this only, if you have `recordSingleFile` set to `true`
- **recordSingleFile** (server only) - [`true`,`false`] - How recordings are stored in the file system. `true` means, each recording is saved in one large file. `false` means, ctbrec just downloads the stream segments.
- **recordSingleFile** (server only) - [`true`,`false`] - How recordings are stored in the file system. `true` means, each recording is saved in one large file. `false` means, ctbrec just downloads the stream segments.
- **splitRecordings** - [0 - 2147483647] in seconds. Split recordings after this amount of seconds. The recordings are split up into several individual recordings,
which have the defined length (roughly). 0 means no splitting. The server does not support splitRecordings.
- **splitStrategy** - [`DONT`, `TIME`, `SIZE`, `TIME_OR_SIZE`] Defines if and how to split recordings. Also see `splitRecordingsAfterSecs` and `splitRecordingsBiggerThanBytes`
- **splitRecordingsAfterSecs** - [0 - 2147483647] in seconds. Split recordings after this amount of seconds. The recordings are split up into several individual recordings,
which have the defined length (roughly). Has to be activated with `splitStrategy`.
- **splitRecordingsBiggerThanBytes** - [0 - 9223372036854775807] in bytes. Split recordings, if the size on disk exceeds this value. The recordings are split up into several individual recordings,
which have the defined size (roughly). Has to be activated with `splitStrategy`.
- **webinterface** (server only) - [`true`,`false`] Enables the webinterface for the server. You can access it with http://host:port/static/index.html Don't activate this on
a machine, which can be accessed from the internet, because this is totally unprotected at the moment.

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">
<!-- encoders are assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder

2
common/.gitignore vendored
View File

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

View File

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

View File

@ -1,5 +1,7 @@
package ctbrec;
import static ctbrec.io.HttpConstants.*;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
@ -17,6 +19,8 @@ import ctbrec.recorder.download.HttpHeaderFactoryImpl;
import ctbrec.recorder.download.hls.HlsDownload;
import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload;
import ctbrec.sites.Site;
import okhttp3.Request;
import okhttp3.Response;
public abstract class AbstractModel implements Model {
@ -270,4 +274,18 @@ public abstract class AbstractModel implements Model {
fac.setSegmentHeaders(new HashMap<>());
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.Optional;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi;
import ctbrec.Settings.SplitStrategy;
import ctbrec.io.FileJsonAdapter;
import ctbrec.io.ModelJsonAdapter;
import ctbrec.io.PostProcessorJsonAdapter;
@ -102,6 +104,7 @@ public class Config {
migrateOldSettings();
}
@SuppressWarnings("deprecation")
private void migrateOldSettings() {
// 3.8.0 from maxResolution only to resolution range
if(settings.minimumResolution == settings.maximumResolution && settings.minimumResolution == 0) {
@ -128,6 +131,36 @@ public class Config {
settings.postProcessors.add(removeKeepFile);
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) {

View File

@ -136,4 +136,11 @@ public interface Model extends Comparable<Model>, Serializable {
public SubsequentAction getRecordUntilSubsequentAction();
public void setRecordUntilSubsequentAction(SubsequentAction action);
/**
* Check, if this model account exists
* @return true, if it exists, false otherwise
* @throws IOException
*/
public boolean exists() throws IOException;
}

View File

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

View File

@ -3,35 +3,23 @@ package ctbrec;
import static ctbrec.Recording.State.*;
import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.nio.file.FileVisitOption;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ctbrec.event.EventBusHolder;
import ctbrec.event.RecordingStateChangedEvent;
import ctbrec.io.IoUtils;
import ctbrec.recorder.download.Download;
import ctbrec.recorder.download.VideoLengthDetector;
public class Recording implements Serializable {
private static final transient Logger LOG = LoggerFactory.getLogger(Recording.class);
private String id;
private Model model;
private transient Download download;
@ -211,7 +199,7 @@ public class Recording implements Serializable {
public Duration getLength() {
File ppFile = getPostProcessedFile();
if (ppFile.isDirectory()) {
File playlist = new File(ppFile, "playlist.m3u8a");
File playlist = new File(ppFile, "playlist.m3u8");
return VideoLengthDetector.getLength(playlist);
} else {
return VideoLengthDetector.getLength(ppFile);
@ -253,11 +241,11 @@ public class Recording implements Serializable {
private long getSize() {
File rec = getAbsoluteFile();
if (rec.isDirectory()) {
return getDirectorySize(rec);
return IoUtils.getDirectorySize(rec);
} else {
if (!rec.exists()) {
if (rec.getName().endsWith(".m3u8")) {
return getDirectorySize(rec.getParentFile());
return IoUtils.getDirectorySize(rec.getParentFile());
} else {
return -1;
}
@ -267,29 +255,6 @@ public class Recording implements Serializable {
}
}
private long getDirectorySize(File dir) {
final long[] size = { 0 };
int maxDepth = 1; // Don't expect subdirs, so don't even try
try {
Files.walkFileTree(dir.toPath(), EnumSet.noneOf(FileVisitOption.class), maxDepth, new SimpleFileVisitor<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() {
sizeInByte = getSize();
}

View File

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

View File

@ -7,7 +7,7 @@ import org.slf4j.LoggerFactory;
import ctbrec.OS;
import ctbrec.event.EventHandlerConfiguration.ActionConfiguration;
import ctbrec.io.StreamRedirectThread;
import ctbrec.io.StreamRedirector;
public class ExecuteProgram extends Action {
@ -38,11 +38,11 @@ public class ExecuteProgram extends Action {
// create threads, which read stdout and stderr of the player process. these are needed,
// because otherwise the internal buffer for these streams fill up and block the process
Thread std = new Thread(new StreamRedirectThread(process.getInputStream(), System.out));
Thread std = new Thread(new StreamRedirector(process.getInputStream(), System.out));
std.setName("Player stdout pipe");
std.setDaemon(true);
std.start();
Thread err = new Thread(new StreamRedirectThread(process.getErrorStream(), System.err));
Thread err = new Thread(new StreamRedirector(process.getErrorStream(), System.err));
err.setName("Player stderr pipe");
err.setDaemon(true);
err.start();

View File

@ -2,7 +2,13 @@ package ctbrec.io;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitOption;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.EnumSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -50,4 +56,27 @@ public class IoUtils {
throw new IOException("Couldn't delete all files in " + directory);
}
}
public static long getDirectorySize(File dir) {
final long[] size = { 0 };
int maxDepth = 1; // Don't expect subdirs, so don't even try
try {
Files.walkFileTree(dir.toPath(), EnumSet.noneOf(FileVisitOption.class), maxDepth, new SimpleFileVisitor<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.LoggerFactory;
public class StreamRedirectThread implements Runnable {
private static final Logger LOG = LoggerFactory.getLogger(StreamRedirectThread.class);
public class StreamRedirector implements Runnable {
private static final Logger LOG = LoggerFactory.getLogger(StreamRedirector.class);
private InputStream in;
private OutputStream out;
public StreamRedirectThread(InputStream in, OutputStream out) {
public StreamRedirector(InputStream in, OutputStream out) {
super();
this.in = in;
this.out = out;

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(() -> {
try {
setRecordingStatus(recording, State.POST_PROCESSING);
recording.refresh();
recordingManager.saveRecording(recording);
recording.postprocess();
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
*/
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.io.DevNull;
import ctbrec.io.StreamRedirectThread;
import ctbrec.io.StreamRedirector;
public class VideoLengthDetector {
private static final Logger LOG = LoggerFactory.getLogger(VideoLengthDetector.class);
@ -39,8 +39,8 @@ public class VideoLengthDetector {
Process ffmpeg = Runtime.getRuntime().exec(cmdline, new String[0], videoFile.getParentFile());
int exitCode = 1;
ByteArrayOutputStream stdErrBuffer = new ByteArrayOutputStream();
Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), new DevNull()));
Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), stdErrBuffer));
Thread stdout = new Thread(new StreamRedirector(ffmpeg.getInputStream(), new DevNull()));
Thread stderr = new Thread(new StreamRedirector(ffmpeg.getErrorStream(), stdErrBuffer));
stdout.start();
stderr.start();
exitCode = ffmpeg.waitFor();

View File

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

View File

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

View File

@ -44,6 +44,7 @@ import com.iheartradio.m3u8.data.TrackData;
import ctbrec.Config;
import ctbrec.Model;
import ctbrec.Recording.State;
import ctbrec.Settings;
import ctbrec.UnknownModel;
import ctbrec.io.BandwidthMeter;
import ctbrec.io.HttpClient;
@ -51,6 +52,7 @@ import ctbrec.io.HttpException;
import ctbrec.recorder.PlaylistGenerator.InvalidPlaylistException;
import ctbrec.recorder.download.AbstractDownload;
import ctbrec.recorder.download.HttpHeaderFactory;
import ctbrec.recorder.download.SplittingStrategy;
import ctbrec.recorder.download.StreamSource;
import ctbrec.sites.Site;
import okhttp3.Request;
@ -67,6 +69,7 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
protected Model model = new UnknownModel();
protected transient LinkedBlockingQueue<Runnable> downloadQueue = new LinkedBlockingQueue<>(50);
protected transient ExecutorService downloadThreadPool = new ThreadPoolExecutor(0, 5, 20, TimeUnit.SECONDS, downloadQueue, createThreadFactory());
protected transient SplittingStrategy splittingStrategy;
protected State state = State.UNKNOWN;
private int playlistEmptyCount = 0;
@ -235,4 +238,27 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
this.url = url;
}
}
protected SplittingStrategy initSplittingStrategy(Settings settings) {
SplittingStrategy strategy;
switch (settings.splitStrategy) {
case TIME:
strategy = new TimeSplittingStrategy();
break;
case SIZE:
strategy = new SizeSplittingStrategy();
break;
case TIME_OR_SIZE:
SplittingStrategy timeSplittingStrategy = new TimeSplittingStrategy();
SplittingStrategy sizeSplittingStrategy = new SizeSplittingStrategy();
strategy = new CombinedSplittingStrategy(timeSplittingStrategy, sizeSplittingStrategy);
break;
case DONT:
default:
strategy = new NoopSplittingStrategy();
break;
}
strategy.init(settings);
return strategy;
}
}

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

View File

@ -13,7 +13,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@ -40,6 +39,7 @@ import ctbrec.Recording.State;
import ctbrec.io.BandwidthMeter;
import ctbrec.io.HttpClient;
import ctbrec.io.HttpException;
import ctbrec.io.IoUtils;
import ctbrec.recorder.PlaylistGenerator;
import ctbrec.recorder.download.HttpHeaderFactory;
import okhttp3.Request;
@ -48,6 +48,8 @@ import okhttp3.Response;
public class HlsDownload extends AbstractHlsDownload {
private static final int TEN_SECONDS = 10_000;
private static final Logger LOG = LoggerFactory.getLogger(HlsDownload.class);
protected transient Path downloadDir;
@ -55,8 +57,8 @@ public class HlsDownload extends AbstractHlsDownload {
private int segmentCounter = 1;
private NumberFormat nf = new DecimalFormat("000000");
private transient AtomicBoolean downloadFinished = new AtomicBoolean(false);
private ZonedDateTime splitRecStartTime;
protected transient Config config;
private transient int waitFactor = 1;
public HlsDownload(HttpClient client) {
super(client);
@ -71,6 +73,7 @@ public class HlsDownload extends AbstractHlsDownload {
String formattedStartTime = formatter.format(ZonedDateTime.ofInstant(this.startTime, ZoneId.systemDefault()));
Path modelDir = FileSystems.getDefault().getPath(config.getSettings().recordingsDir, model.getSanitizedNamed());
downloadDir = FileSystems.getDefault().getPath(modelDir.toString(), formattedStartTime);
splittingStrategy = initSplittingStrategy(config.getSettings());
}
@Override
@ -78,7 +81,6 @@ public class HlsDownload extends AbstractHlsDownload {
try {
running = true;
Thread.currentThread().setName("Download " + model.getName());
splitRecStartTime = ZonedDateTime.now();
String segments = getSegmentPlaylistUrl(model);
if (segments != null) {
if (!downloadDir.toFile().exists()) {
@ -86,45 +88,13 @@ public class HlsDownload extends AbstractHlsDownload {
}
int lastSegmentNumber = 0;
int nextSegmentNumber = 0;
int waitFactor = 1;
while (running) {
SegmentPlaylist playlist = getNextSegments(segments);
emptyPlaylistCheck(playlist);
if (nextSegmentNumber > 0 && playlist.seq > nextSegmentNumber) {
waitFactor *= 2;
LOG.warn("Missed segments {} < {} in download for {} - setting wait factor to 1/{}", nextSegmentNumber, playlist.seq, model,
waitFactor);
}
int skip = nextSegmentNumber - playlist.seq;
for (String segment : playlist.segments) {
if (skip > 0) {
skip--;
} else {
URL segmentUrl = new URL(segment);
String prefix = nf.format(segmentCounter++);
SegmentDownload segmentDownload = new SegmentDownload(playlist, segmentUrl, downloadDir, client, prefix);
enqueueDownload(segmentDownload, prefix, segmentUrl);
}
}
// split recordings
boolean split = splitRecording();
if (split) {
break;
}
long waitForMillis = 0;
if (lastSegmentNumber == playlist.seq) {
// playlist didn't change -> wait for at least half the target duration
waitForMillis = (long) playlist.targetDuration * 1000 / waitFactor;
LOG.trace("Playlist didn't change... waiting for {}ms", waitForMillis);
} else {
// playlist did change -> wait for at least last segment duration
waitForMillis = 1;
LOG.trace("Playlist changed... waiting for {}ms", waitForMillis);
}
waitSomeTime(waitForMillis);
logMissedSegments(playlist, nextSegmentNumber);
enqueueNewSegments(playlist, nextSegmentNumber);
splitRecordingIfNecessary();
waitSomeTime(playlist, lastSegmentNumber, waitFactor);
// this if check makes sure, that we don't decrease nextSegment. for some reason
// streamate playlists sometimes jump back. e.g. max sequence = 79 -> 80 -> 79
@ -137,52 +107,103 @@ public class HlsDownload extends AbstractHlsDownload {
throw new IOException("Couldn't determine segments uri");
}
} catch (ParseException e) {
throw new IOException("Couldn't parse HLS playlist:\n" + e.getInput(), e);
throw new IOException("Couldn't parse HLS playlist for model " + model + "\n" + e.getInput(), e);
} catch (PlaylistException e) {
throw new IOException("Couldn't parse HLS playlist", e);
throw new IOException("Couldn't parse HLS playlist for model " + model, e);
} catch (EOFException e) {
// end of playlist reached
LOG.debug("Reached end of playlist for model {}", model);
} catch (HttpException e) {
if (e.getResponseCode() == 404) {
ctbrec.Model.State modelState;
try {
modelState = model.getOnlineState(false);
} catch (ExecutionException e1) {
modelState = ctbrec.Model.State.UNKNOWN;
}
LOG.info("Playlist not found (404). Model {} probably went offline. Model state: {}", model, modelState);
waitSomeTime(10_000);
} else if (e.getResponseCode() == 403) {
ctbrec.Model.State modelState;
try {
modelState = model.getOnlineState(false);
} catch (ExecutionException e1) {
modelState = ctbrec.Model.State.UNKNOWN;
}
LOG.info("Playlist access forbidden (403). Model {} probably went private or offline. Model state: {}", model, modelState);
waitSomeTime(10_000);
} else {
throw e;
}
handleHttpException(e);
} catch (Exception e) {
throw new IOException("Couldn't download segment", e);
} finally {
downloadThreadPool.shutdown();
try {
LOG.debug("Waiting for last segments for {}", model);
downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
downloadFinished.set(true);
synchronized (downloadFinished) {
downloadFinished.notifyAll();
}
LOG.debug("Download for {} terminated", model);
finalizeDownload();
}
}
private void finalizeDownload() {
downloadThreadPool.shutdown();
try {
LOG.debug("Waiting for last segments for {}", model);
downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
downloadFinished.set(true);
synchronized (downloadFinished) {
downloadFinished.notifyAll();
}
LOG.debug("Download for {} terminated", model);
}
private void handleHttpException(HttpException e) throws IOException {
if (e.getResponseCode() == 404) {
ctbrec.Model.State modelState;
try {
modelState = model.getOnlineState(false);
} catch (ExecutionException e1) {
modelState = ctbrec.Model.State.UNKNOWN;
}
LOG.info("Playlist not found (404). Model {} probably went offline. Model state: {}", model, modelState);
waitSomeTime(TEN_SECONDS);
} else if (e.getResponseCode() == 403) {
ctbrec.Model.State modelState;
try {
modelState = model.getOnlineState(false);
} catch (ExecutionException e1) {
modelState = ctbrec.Model.State.UNKNOWN;
}
LOG.info("Playlist access forbidden (403). Model {} probably went private or offline. Model state: {}", model, modelState);
waitSomeTime(TEN_SECONDS);
} else {
throw e;
}
}
private void splitRecordingIfNecessary() {
if (splittingStrategy.splitNecessary(this)) {
internalStop();
}
}
private void enqueueNewSegments(SegmentPlaylist playlist, int nextSegmentNumber) throws IOException, ExecutionException, InterruptedException {
int skip = nextSegmentNumber - playlist.seq;
for (String segment : playlist.segments) {
if (skip > 0) {
skip--;
} else {
URL segmentUrl = new URL(segment);
String prefix = nf.format(segmentCounter++);
SegmentDownload segmentDownload = new SegmentDownload(playlist, segmentUrl, downloadDir, client, prefix);
enqueueDownload(segmentDownload, prefix, segmentUrl);
}
}
}
private void logMissedSegments(SegmentPlaylist playlist, int nextSegmentNumber) {
if (nextSegmentNumber > 0 && playlist.seq > nextSegmentNumber) {
waitFactor *= 2;
LOG.warn("Missed segments {} < {} in download for {} - setting wait factor to 1/{}", nextSegmentNumber, playlist.seq, model,
waitFactor);
}
}
private void waitSomeTime(SegmentPlaylist playlist, int lastSegmentNumber, int waitFactor) {
long waitForMillis = 0;
if (lastSegmentNumber == playlist.seq) {
// playlist didn't change -> wait for at least half the target duration
waitForMillis = (long) playlist.targetDuration * 1000 / waitFactor;
LOG.trace("Playlist didn't change... waiting for {}ms", waitForMillis);
} else {
// playlist did change -> wait for at least last segment duration
waitForMillis = 1;
LOG.trace("Playlist changed... waiting for {}ms", waitForMillis);
}
waitSomeTime(waitForMillis);
}
private void enqueueDownload(SegmentDownload segmentDownload, String prefix, URL segmentUrl) throws IOException, ExecutionException, InterruptedException {
try {
downloadThreadPool.submit(segmentDownload);
@ -228,18 +249,6 @@ public class HlsDownload extends AbstractHlsDownload {
}
private boolean splitRecording() {
if (config.getSettings().splitRecordings > 0) {
Duration recordingDuration = Duration.between(splitRecStartTime, ZonedDateTime.now());
long seconds = recordingDuration.getSeconds();
if (seconds >= config.getSettings().splitRecordings) {
internalStop();
return true;
}
}
return false;
}
@Override
public void stop() {
if (running) {
@ -334,4 +343,9 @@ public class HlsDownload extends AbstractHlsDownload {
public boolean isSingleFile() {
return false;
}
@Override
public long getSizeInByte() {
return IoUtils.getDirectorySize(getTarget());
}
}

View File

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

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) {
String pattern = "yyyy-MM-dd_HH-mm-ss";
Matcher m = Pattern.compile("\\$\\{" + placeHolder + "(?:\\((.*?)\\))?\\}").matcher(filename);
if (m.find()) {
Pattern regex = Pattern.compile("\\$\\{" + placeHolder + "(?:\\((.*?)\\))?\\}");
Matcher m = regex.matcher(filename);
while (m.find()) {
String p = m.group(1);
if (p != null) {
pattern = p;
}
String formattedDate = getDateTime(rec, pattern, zone);
filename = m.replaceFirst(formattedDate);
m = regex.matcher(filename);
}
String formattedDate = getDateTime(rec, pattern, zone);
return m.replaceAll(formattedDate);
return filename;
}
private String getDateTime(Recording rec, String pattern, ZoneId zone) {

View File

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

View File

@ -6,6 +6,7 @@ import java.io.IOException;
import java.nio.file.Files;
import java.util.Objects;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -15,7 +16,7 @@ import ctbrec.recorder.RecordingManager;
public class Move extends AbstractPlaceholderAwarePostProcessor {
private static final Logger LOG = LoggerFactory.getLogger(Rename.class);
private static final Logger LOG = LoggerFactory.getLogger(Move.class);
public static final String PATH_TEMPLATE = "path.template";
public static final String DEFAULT = "${modelSanitizedName}" + File.separatorChar + "${localDateTime}";
@ -36,7 +37,11 @@ public class Move extends AbstractPlaceholderAwarePostProcessor {
}
LOG.info("Moving {} to {}", src.getName(), target.getParentFile().getCanonicalPath());
Files.createDirectories(target.getParentFile().toPath());
Files.move(rec.getPostProcessedFile().toPath(), target.toPath());
if (rec.getPostProcessedFile().isDirectory()) {
FileUtils.moveDirectory(rec.getPostProcessedFile(), target);
} else {
FileUtils.moveFile(rec.getPostProcessedFile(), target);
}
rec.setPostProcessedFile(target);
if (Objects.equals(src, rec.getAbsoluteFile())) {
rec.setAbsoluteFile(target);

View File

@ -1,7 +1,6 @@
package ctbrec.recorder.postprocessing;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Arrays;
@ -14,9 +13,8 @@ import ctbrec.Config;
import ctbrec.OS;
import ctbrec.Recording;
import ctbrec.io.IoUtils;
import ctbrec.io.StreamRedirectThread;
import ctbrec.recorder.FFmpeg;
import ctbrec.recorder.RecordingManager;
import ctbrec.recorder.download.ProcessExitedUncleanException;
public class Remux extends AbstractPostProcessor {
@ -32,70 +30,64 @@ public class Remux extends AbstractPostProcessor {
@Override
public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException {
final File inputFile;
if (rec.getPostProcessedFile().isDirectory()) {
inputFile = new File(rec.getPostProcessedFile(), "playlist.m3u8");
} else {
inputFile = rec.getPostProcessedFile();
}
String fileExt = getConfig().get(FILE_EXT);
File remuxedFile = new File(rec.getPostProcessedFile().getAbsolutePath() + '.' + fileExt);
String[] cmdline = prepareCommandline(inputFile, remuxedFile);
File executionDir = rec.getPostProcessedFile().isDirectory() ? rec.getPostProcessedFile() : rec.getPostProcessedFile().getParentFile();
LOG.info("Executing {} in working directory {}", Arrays.toString(cmdline), executionDir);
File ffmpegLog = new File(System.getProperty("java.io.tmpdir"), "remux_" + rec.getId() + ".log");
FFmpeg ffmpeg = new FFmpeg.Builder()
.logOutput(config.getSettings().logFFmpegOutput)
.logFile(ffmpegLog)
.onExit(exitCode -> finalizeStep(exitCode, rec, inputFile, remuxedFile))
.build();
ffmpeg.exec(cmdline, new String[0], executionDir);
int exitCode = ffmpeg.waitFor();
return exitCode != 1;
}
private void finalizeStep(int exitCode, Recording rec, File inputFile, File remuxedFile) {
if (exitCode != 1) {
try {
rec.setPostProcessedFile(remuxedFile);
if (inputFile.getName().equals("playlist.m3u8")) {
IoUtils.deleteDirectory(inputFile.getParentFile());
if (Objects.equals(inputFile.getParentFile(), rec.getAbsoluteFile())) {
rec.setAbsoluteFile(remuxedFile);
}
} else {
Files.deleteIfExists(inputFile.toPath());
if (Objects.equals(inputFile, rec.getAbsoluteFile())) {
rec.setAbsoluteFile(remuxedFile);
}
}
rec.setSingleFile(true);
rec.setSizeInByte(remuxedFile.length());
IoUtils.deleteEmptyParents(inputFile.getParentFile());
rec.getAssociatedFiles().remove(inputFile.getCanonicalPath());
rec.getAssociatedFiles().add(remuxedFile.getCanonicalPath());
} catch (IOException e) {
LOG.error("Couldn't finalize remux post-processing step", e);
}
}
}
private String[] prepareCommandline(File inputFile, File remuxedFile) throws IOException {
String[] args = getConfig().get(FFMPEG_ARGS).split(" ");
String[] argsPlusFile = new String[args.length + 3];
File inputFile = rec.getPostProcessedFile();
if (inputFile.isDirectory()) {
inputFile = new File(inputFile, "playlist.m3u8");
}
int i = 0;
argsPlusFile[i++] = "-i";
argsPlusFile[i++] = inputFile.getCanonicalPath();
System.arraycopy(args, 0, argsPlusFile, i, args.length);
File remuxedFile = new File(rec.getPostProcessedFile().getAbsolutePath() + '.' + fileExt);
argsPlusFile[argsPlusFile.length - 1] = remuxedFile.getAbsolutePath();
String[] cmdline = OS.getFFmpegCommand(argsPlusFile);
File executionDir = rec.getPostProcessedFile().isDirectory() ? rec.getPostProcessedFile() : rec.getPostProcessedFile().getParentFile();
LOG.info("Executing {} in working directory {}", Arrays.toString(cmdline), executionDir);
Process ffmpeg = Runtime.getRuntime().exec(cmdline, new String[0], executionDir);
setupLogging(ffmpeg, rec);
rec.setPostProcessedFile(remuxedFile);
if (inputFile.getName().equals("playlist.m3u8")) {
IoUtils.deleteDirectory(inputFile.getParentFile());
if (Objects.equals(inputFile.getParentFile(), rec.getAbsoluteFile())) {
rec.setAbsoluteFile(remuxedFile);
}
} else {
Files.deleteIfExists(inputFile.toPath());
if (Objects.equals(inputFile, rec.getAbsoluteFile())) {
rec.setAbsoluteFile(remuxedFile);
}
}
rec.setSingleFile(true);
rec.setSizeInByte(remuxedFile.length());
IoUtils.deleteEmptyParents(inputFile.getParentFile());
rec.getAssociatedFiles().remove(inputFile.getCanonicalPath());
rec.getAssociatedFiles().add(remuxedFile.getCanonicalPath());
return true;
}
private void setupLogging(Process ffmpeg, Recording rec) throws IOException, InterruptedException {
int exitCode = 1;
File video = rec.getPostProcessedFile();
File ffmpegLog = new File(video.getParentFile(), video.getName() + ".ffmpeg.log");
rec.getAssociatedFiles().add(ffmpegLog.getCanonicalPath());
try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) {
Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream));
Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream));
stdout.start();
stderr.start();
exitCode = ffmpeg.waitFor();
LOG.debug("FFmpeg exited with code {}", exitCode);
stdout.join();
stderr.join();
mergeLogStream.flush();
}
if (exitCode != 1) {
if (ffmpegLog.exists()) {
Files.delete(ffmpegLog.toPath());
rec.getAssociatedFiles().remove(ffmpegLog.getCanonicalPath());
}
} else {
rec.getAssociatedFiles().add(ffmpegLog.getAbsolutePath());
LOG.info("FFmpeg exit code was {}. Logfile: {}", exitCode, ffmpegLog.getAbsolutePath());
throw new ProcessExitedUncleanException("FFmpeg exit code was " + exitCode);
}
return OS.getFFmpegCommand(argsPlusFile);
}
@Override

View File

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

View File

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

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

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 transient List<StreamSource> streamSources = null;
private transient boolean isNew;
private transient String gender;
private float sortOrder = 0;
private Random random = new Random();
@ -344,4 +345,12 @@ public class CamsodaModel extends AbstractModel {
public void setNew(boolean isNew) {
this.isNew = isNew;
}
public String getGender() {
return gender;
}
public void setGender(String gender) {
this.gender = gender;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

2
server/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -273,7 +273,7 @@
});
} else {
$('#addModelByUrl').autocomplete({
source: ["BongaCams:", "Cam4:", "Camsoda:", "Chaturbate:", "Fc2Live:", "Flirt4Free:", "LiveJasmin:", "MyFreeCams:", "Showup:", "Streamate:", "Stripchat:"]
source: ["BongaCams:", "Cam4:", "Camsoda:", "Chaturbate:", "Fc2Live:", "Flirt4Free:", "LiveJasmin:", "MyFreeCams:", "MVLive:", "Showup:", "Streamate:", "Stripchat:"]
});
}
}

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