diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff02c63e..c956f655 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+1.12.0
+========================
+* Added threshold setting to keep free space on the recording device.
+ This is useful, if you don't want to use up all of your storage.
+ The free space is also shown on the recordings tab
+* Tweaked the download internals a lot. Downloads should not hang
+ in RECORDING state without actually recording. Downloads should
+ be more robust in general.
+* Fixed and improved split recordings
+* Improved detection of online state for Cam4 models
+* Accelerated the initial loading of the "Recording" tab for many
+ Chaturbate models
+* Recordings tab now shows smaller size units (Bytes, KiB, MiB, GiB)
+
1.11.0
========================
* Added model search function
diff --git a/client/.gitignore b/client/.gitignore
index fc247909..2c4e8e27 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -2,7 +2,7 @@
/target/
*~
*.bak
-/ctbrec.log
+/*.log
/ctbrec-tunnel.sh
/jre/
/server-local.sh
diff --git a/client/pom.xml b/client/pom.xml
index c0e4bb57..021c748b 100644
--- a/client/pom.xml
+++ b/client/pom.xml
@@ -8,7 +8,7 @@
ctbrec
master
- 1.11.0
+ 1.12.0
../master
diff --git a/client/src/main/java/ctbrec/ui/JavaFxRecording.java b/client/src/main/java/ctbrec/ui/JavaFxRecording.java
index 313a7e75..e44f36c0 100644
--- a/client/src/main/java/ctbrec/ui/JavaFxRecording.java
+++ b/client/src/main/java/ctbrec/ui/JavaFxRecording.java
@@ -1,10 +1,11 @@
package ctbrec.ui;
-import java.text.DecimalFormat;
import java.time.Instant;
import ctbrec.Config;
import ctbrec.Recording;
+import javafx.beans.property.LongProperty;
+import javafx.beans.property.SimpleLongProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
@@ -12,9 +13,10 @@ public class JavaFxRecording extends Recording {
private transient StringProperty statusProperty = new SimpleStringProperty();
private transient StringProperty progressProperty = new SimpleStringProperty();
- private transient StringProperty sizeProperty = new SimpleStringProperty();
+ private transient LongProperty sizeProperty = new SimpleLongProperty();
private Recording delegate;
+ private long lastValue = 0;
public JavaFxRecording(Recording recording) {
this.delegate = recording;
@@ -89,9 +91,7 @@ public class JavaFxRecording extends Recording {
@Override
public void setSizeInByte(long sizeInByte) {
delegate.setSizeInByte(sizeInByte);
- double sizeInGiB = sizeInByte / 1024.0 / 1024 / 1024;
- DecimalFormat df = new DecimalFormat("0.00");
- sizeProperty.setValue(df.format(sizeInGiB) + " GiB");
+ sizeProperty.set(sizeInByte);
}
public StringProperty getProgressProperty() {
@@ -151,8 +151,13 @@ public class JavaFxRecording extends Recording {
return delegate.getSizeInByte();
}
- public StringProperty getSizeProperty() {
+ public LongProperty getSizeProperty() {
return sizeProperty;
}
+ public boolean valueChanged() {
+ boolean changed = getSizeInByte() != lastValue;
+ lastValue = getSizeInByte();
+ return changed;
+ }
}
diff --git a/client/src/main/java/ctbrec/ui/RecordingsTab.java b/client/src/main/java/ctbrec/ui/RecordingsTab.java
index 2c7f4fb2..f8fef9dd 100644
--- a/client/src/main/java/ctbrec/ui/RecordingsTab.java
+++ b/client/src/main/java/ctbrec/ui/RecordingsTab.java
@@ -8,6 +8,7 @@ import java.io.IOException;
import java.net.URL;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
+import java.text.DecimalFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@@ -47,13 +48,16 @@ import javafx.scene.Cursor;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ContextMenu;
+import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
+import javafx.scene.control.ProgressBar;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableColumn.SortType;
import javafx.scene.control.TableView;
+import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.input.KeyCode;
@@ -62,6 +66,9 @@ 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.StackPane;
+import javafx.scene.text.Font;
import javafx.stage.FileChooser;
import javafx.util.Callback;
import javafx.util.Duration;
@@ -74,12 +81,16 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
private Recorder recorder;
@SuppressWarnings("unused")
private List sites;
+ private long spaceTotal = -1;
+ private long spaceFree = -1;
FlowPane grid = new FlowPane();
ScrollPane scrollPane = new ScrollPane();
TableView table = new TableView();
ObservableList observableRecordings = FXCollections.observableArrayList();
ContextMenu popup;
+ ProgressBar spaceLeft;
+ Label spaceLabel;
public RecordingsTab(String title, Recorder recorder, Config config, List sites) {
super(title);
@@ -136,9 +147,37 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
TableColumn progress = new TableColumn<>("Progress");
progress.setCellValueFactory((cdf) -> cdf.getValue().getProgressProperty());
progress.setPrefWidth(100);
- TableColumn size = new TableColumn<>("Size");
- size.setCellValueFactory((cdf) -> cdf.getValue().getSizeProperty());
+ TableColumn size = new TableColumn<>("Size");
+ size.setStyle("-fx-alignment: CENTER-RIGHT;");
size.setPrefWidth(100);
+ size.setCellValueFactory(cdf -> cdf.getValue().getSizeProperty());
+ size.setCellFactory(new Callback, TableCell>() {
+ @Override
+ public TableCell call(TableColumn param) {
+ TableCell cell = new TableCell() {
+ @Override
+ protected void updateItem(Number sizeInByte, boolean empty) {
+ if(empty || sizeInByte == null) {
+ setText(null);
+ setStyle(null);
+ } else {
+ setText(StringUtil.formatSize(sizeInByte));
+ 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() == STATUS.RECORDING) {
+ setStyle("-fx-alignment: CENTER-RIGHT; -fx-background-color: red");
+ } else {
+ setStyle("-fx-alignment: CENTER-RIGHT;");
+ //setStyle(null);
+ }
+ }
+ }
+ }
+ };
+ return cell;
+ }
+ });
table.getColumns().addAll(name, date, status, progress, size);
table.setItems(observableRecordings);
@@ -179,8 +218,21 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
});
scrollPane.setContent(table);
+ HBox spaceBox = new HBox(5);
+ Label l = new Label("Space left on device");
+ HBox.setMargin(l, new Insets(2, 0, 0, 0));
+ spaceBox.getChildren().add(l);
+ spaceLeft = new ProgressBar(0);
+ spaceLeft.setPrefSize(200, 22);
+ spaceLabel = new Label();
+ spaceLabel.setFont(Font.font(11));
+ StackPane stack = new StackPane(spaceLeft, spaceLabel);
+ spaceBox.getChildren().add(stack);
+ BorderPane.setMargin(spaceBox, new Insets(5));
+
BorderPane root = new BorderPane();
root.setPadding(new Insets(5));
+ root.setTop(spaceBox);
root.setCenter(scrollPane);
setContent(root);
@@ -191,30 +243,8 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
updateService = createUpdateService();
updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2)));
updateService.setOnSucceeded((event) -> {
- List recordings = updateService.getValue();
- if (recordings == null) {
- return;
- }
-
- for (Iterator iterator = observableRecordings.iterator(); iterator.hasNext();) {
- JavaFxRecording old = iterator.next();
- if (!recordings.contains(old)) {
- // remove deleted recordings
- iterator.remove();
- }
- }
- for (JavaFxRecording recording : recordings) {
- if (!observableRecordings.contains(recording)) {
- // add new recordings
- observableRecordings.add(recording);
- } else {
- // update existing ones
- int index = observableRecordings.indexOf(recording);
- JavaFxRecording old = observableRecordings.get(index);
- old.update(recording);
- }
- }
- table.sort();
+ updateRecordingsTable();
+ updateFreeSpaceDisplay();
});
updateService.setOnFailed((event) -> {
LOG.info("Couldn't get list of recordings from recorder", event.getSource().getException());
@@ -226,6 +256,46 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
});
}
+ private void updateFreeSpaceDisplay() {
+ 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;
+ DecimalFormat df = new DecimalFormat("0.00");
+ String tt = df.format(freeGiB) + " / " + df.format(totalGiB) + " GiB";
+ spaceLeft.setTooltip(new Tooltip(tt));
+ spaceLabel.setText(tt);
+ }
+ }
+
+ private void updateRecordingsTable() {
+ List recordings = updateService.getValue();
+ if (recordings == null) {
+ return;
+ }
+
+ for (Iterator iterator = observableRecordings.iterator(); iterator.hasNext();) {
+ JavaFxRecording old = iterator.next();
+ if (!recordings.contains(old)) {
+ // remove deleted recordings
+ iterator.remove();
+ }
+ }
+ for (JavaFxRecording recording : recordings) {
+ if (!observableRecordings.contains(recording)) {
+ // add new recordings
+ observableRecordings.add(recording);
+ } else {
+ // update existing ones
+ int index = observableRecordings.indexOf(recording);
+ JavaFxRecording old = observableRecordings.get(index);
+ old.update(recording);
+ }
+ }
+ table.sort();
+ }
+
private ScheduledService> createUpdateService() {
ScheduledService> updateService = new ScheduledService>() {
@Override
@@ -233,12 +303,23 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
return new Task>() {
@Override
public List call() throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
+ updateSpace();
+
List recordings = new ArrayList<>();
for (Recording rec : recorder.getRecordings()) {
recordings.add(new JavaFxRecording(rec));
}
return recordings;
}
+
+ private void updateSpace() {
+ try {
+ spaceTotal = recorder.getTotalSpaceBytes();
+ spaceFree = recorder.getFreeSpaceBytes();
+ } catch (IOException e) {
+ LOG.error("Couldn't update free space", e);
+ }
+ }
};
}
};
diff --git a/client/src/main/java/ctbrec/ui/SettingsTab.java b/client/src/main/java/ctbrec/ui/SettingsTab.java
index 54091aa9..d1f816cc 100644
--- a/client/src/main/java/ctbrec/ui/SettingsTab.java
+++ b/client/src/main/java/ctbrec/ui/SettingsTab.java
@@ -55,6 +55,7 @@ import javafx.stage.FileChooser;;
public class SettingsTab extends Tab implements TabSelectionListener {
private static final transient Logger LOG = LoggerFactory.getLogger(SettingsTab.class);
+ private static final int ONE_GiB_IN_BYTES = 1024 * 1024 * 1024;
public static final int CHECKBOX_MARGIN = 6;
private TextField recordingsDirectory;
@@ -65,6 +66,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private TextField server;
private TextField port;
private TextField onlineCheckIntervalInSecs;
+ private TextField leaveSpaceOnDevice;
private CheckBox loadResolution;
private CheckBox secureCommunication = new CheckBox();
private CheckBox chooseStreamQuality = new CheckBox();
@@ -120,7 +122,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
// left side
leftSide.getChildren().add(createGeneralPanel());
- leftSide.getChildren().add(createLocationsPanel());
+ leftSide.getChildren().add(createRecorderPanel());
leftSide.getChildren().add(createRecordLocationPanel());
//right side
@@ -253,9 +255,20 @@ public class SettingsTab extends Tab implements TabSelectionListener {
return recordLocation;
}
- private Node createLocationsPanel() {
+ private Node createRecorderPanel() {
int row = 0;
GridPane layout = createGridLayout();
+ layout.add(new Label("Post-Processing"), 0, row);
+ postProcessing = new TextField(Config.getInstance().getSettings().postProcessing);
+ postProcessing.focusedProperty().addListener(createPostProcessingFocusListener());
+ GridPane.setFillWidth(postProcessing, true);
+ GridPane.setHgrow(postProcessing, Priority.ALWAYS);
+ GridPane.setColumnSpan(postProcessing, 2);
+ GridPane.setMargin(postProcessing, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(postProcessing, 1, row);
+ postProcessingDirectoryButton = createPostProcessingBrowseButton();
+ layout.add(postProcessingDirectoryButton, 3, row++);
+
layout.add(new Label("Recordings Directory"), 0, row);
recordingsDirectory = new TextField(Config.getInstance().getSettings().recordingsDir);
recordingsDirectory.focusedProperty().addListener(createRecordingsDirectoryFocusListener());
@@ -283,16 +296,97 @@ public class SettingsTab extends Tab implements TabSelectionListener {
GridPane.setMargin(directoryStructure, new Insets(0, 0, 0, CHECKBOX_MARGIN));
layout.add(directoryStructure, 1, row++);
- layout.add(new Label("Post-Processing"), 0, row);
- postProcessing = new TextField(Config.getInstance().getSettings().postProcessing);
- postProcessing.focusedProperty().addListener(createPostProcessingFocusListener());
- GridPane.setFillWidth(postProcessing, true);
- GridPane.setHgrow(postProcessing, Priority.ALWAYS);
- GridPane.setColumnSpan(postProcessing, 2);
- GridPane.setMargin(postProcessing, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- layout.add(postProcessing, 1, row);
- postProcessingDirectoryButton = createPostProcessingBrowseButton();
- layout.add(postProcessingDirectoryButton, 3, row++);
+ Label l = new Label("Maximum resolution (0 = unlimited)");
+ layout.add(l, 0, row);
+ List resolutionOptions = new ArrayList<>();
+ resolutionOptions.add(1080);
+ resolutionOptions.add(720);
+ resolutionOptions.add(600);
+ resolutionOptions.add(480);
+ resolutionOptions.add(0);
+ maxResolution = new ComboBox<>(FXCollections.observableList(resolutionOptions));
+ setMaxResolutionValue();
+ maxResolution.setOnAction((e) -> {
+ Config.getInstance().getSettings().maximumResolution = maxResolution.getSelectionModel().getSelectedItem();
+ saveConfig();
+ });
+ maxResolution.prefWidthProperty().bind(directoryStructure.widthProperty());
+ layout.add(maxResolution, 1, row++);
+ GridPane.setMargin(l, new Insets(0, 0, 0, 0));
+ GridPane.setMargin(maxResolution, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+
+ l = new Label("Split recordings after (minutes)");
+ layout.add(l, 0, row);
+ List splitOptions = new ArrayList<>();
+ splitOptions.add(new SplitAfterOption("disabled", 0));
+ if(Config.isDevMode()) {
+ splitOptions.add(new SplitAfterOption( "1 min", 1 * 60));
+ splitOptions.add(new SplitAfterOption( "3 min", 3 * 60));
+ splitOptions.add(new SplitAfterOption( "5 min", 5 * 60));
+ }
+ splitOptions.add(new SplitAfterOption("10 min", 10 * 60));
+ splitOptions.add(new SplitAfterOption("15 min", 15 * 60));
+ splitOptions.add(new SplitAfterOption("20 min", 20 * 60));
+ splitOptions.add(new SplitAfterOption("30 min", 30 * 60));
+ splitOptions.add(new SplitAfterOption("60 min", 60 * 60));
+ splitAfter = new ComboBox<>(FXCollections.observableList(splitOptions));
+ layout.add(splitAfter, 1, row++);
+ setSplitAfterValue();
+ splitAfter.setOnAction((e) -> {
+ Config.getInstance().getSettings().splitRecordings = splitAfter.getSelectionModel().getSelectedItem().getValue();
+ saveConfig();
+ });
+ splitAfter.prefWidthProperty().bind(directoryStructure.widthProperty());
+ GridPane.setMargin(l, new Insets(0, 0, 0, 0));
+ GridPane.setMargin(splitAfter, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+
+ Tooltip tt = new Tooltip("Check every x seconds, if a model came online");
+ l = new Label("Check online state every (seconds)");
+ l.setTooltip(tt);
+ layout.add(l, 0, row);
+ onlineCheckIntervalInSecs = new TextField(Integer.toString(Config.getInstance().getSettings().onlineCheckIntervalInSecs));
+ onlineCheckIntervalInSecs.setTooltip(tt);
+ onlineCheckIntervalInSecs.textProperty().addListener((observable, oldValue, newValue) -> {
+ if (!newValue.matches("\\d*")) {
+ onlineCheckIntervalInSecs.setText(newValue.replaceAll("[^\\d]", ""));
+ }
+ if(!onlineCheckIntervalInSecs.getText().isEmpty()) {
+ Config.getInstance().getSettings().onlineCheckIntervalInSecs = Integer.parseInt(onlineCheckIntervalInSecs.getText());
+ saveConfig();
+ }
+ });
+ GridPane.setMargin(onlineCheckIntervalInSecs, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(onlineCheckIntervalInSecs, 1, row++);
+
+ tt = new Tooltip("Stop recording, if the free space on the device gets below this threshold");
+ l = new Label("Leave space on device (GiB)");
+ l.setTooltip(tt);
+ layout.add(l, 0, row);
+ long minimumSpaceLeftInBytes = Config.getInstance().getSettings().minimumSpaceLeftInBytes;
+ int minimumSpaceLeftInGiB = (int) (minimumSpaceLeftInBytes / ONE_GiB_IN_BYTES);
+ leaveSpaceOnDevice = new TextField(Integer.toString(minimumSpaceLeftInGiB));
+ leaveSpaceOnDevice.setTooltip(tt);
+ leaveSpaceOnDevice.textProperty().addListener((observable, oldValue, newValue) -> {
+ if (!newValue.matches("\\d*")) {
+ leaveSpaceOnDevice.setText(newValue.replaceAll("[^\\d]", ""));
+ }
+ if(!leaveSpaceOnDevice.getText().isEmpty()) {
+ long spaceLeftInGiB = Long.parseLong(leaveSpaceOnDevice.getText());
+ Config.getInstance().getSettings().minimumSpaceLeftInBytes = spaceLeftInGiB * ONE_GiB_IN_BYTES;
+ saveConfig();
+ }
+ });
+ GridPane.setMargin(leaveSpaceOnDevice, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(leaveSpaceOnDevice, 1, row++);
+
+ TitledPane locations = new TitledPane("Recorder", layout);
+ locations.setCollapsible(false);
+ return locations;
+ }
+
+ private Node createGeneralPanel() {
+ GridPane layout = createGridLayout();
+ int row = 0;
layout.add(new Label("Player"), 0, row);
mediaPlayer = new TextField(Config.getInstance().getSettings().mediaPlayer);
@@ -304,15 +398,18 @@ public class SettingsTab extends Tab implements TabSelectionListener {
layout.add(mediaPlayer, 1, row);
layout.add(createMpvBrowseButton(), 3, row++);
- TitledPane locations = new TitledPane("Locations", layout);
- locations.setCollapsible(false);
- return locations;
- }
+ Label l = new Label("Allow multiple players");
+ layout.add(l, 0, row);
+ multiplePlayers.setSelected(!Config.getInstance().getSettings().singlePlayer);
+ multiplePlayers.setOnAction((e) -> {
+ Config.getInstance().getSettings().singlePlayer = !multiplePlayers.isSelected();
+ saveConfig();
+ });
+ GridPane.setMargin(l, new Insets(3, 0, 0, 0));
+ GridPane.setMargin(multiplePlayers, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ layout.add(multiplePlayers, 1, row++);
- private Node createGeneralPanel() {
- GridPane layout = createGridLayout();
- int row = 0;
- Label l = new Label("Display stream resolution in overview");
+ l = new Label("Display stream resolution in overview");
layout.add(l, 0, row);
loadResolution = new CheckBox();
loadResolution.setSelected(Config.getInstance().getSettings().determineResolution);
@@ -323,20 +420,10 @@ public class SettingsTab extends Tab implements TabSelectionListener {
ThumbOverviewTab.queue.clear();
}
});
- //GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
- GridPane.setMargin(loadResolution, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
+ GridPane.setMargin(loadResolution, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
layout.add(loadResolution, 1, row++);
- l = new Label("Allow multiple players");
- layout.add(l, 0, row);
- multiplePlayers.setSelected(!Config.getInstance().getSettings().singlePlayer);
- multiplePlayers.setOnAction((e) -> {
- Config.getInstance().getSettings().singlePlayer = !multiplePlayers.isSelected();
- saveConfig();
- });
- GridPane.setMargin(l, new Insets(3, 0, 0, 0));
- GridPane.setMargin(multiplePlayers, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
- layout.add(multiplePlayers, 1, row++);
l = new Label("Manually select stream quality");
layout.add(l, 0, row);
@@ -357,60 +444,9 @@ public class SettingsTab extends Tab implements TabSelectionListener {
saveConfig();
});
GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
- GridPane.setMargin(updateThumbnails, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ GridPane.setMargin(updateThumbnails, new Insets(CHECKBOX_MARGIN, 0, CHECKBOX_MARGIN, CHECKBOX_MARGIN));
layout.add(updateThumbnails, 1, row++);
- l = new Label("Maximum resolution (0 = unlimited)");
- layout.add(l, 0, row);
- List resolutionOptions = new ArrayList<>();
- resolutionOptions.add(1080);
- resolutionOptions.add(720);
- resolutionOptions.add(600);
- resolutionOptions.add(480);
- resolutionOptions.add(0);
- maxResolution = new ComboBox<>(FXCollections.observableList(resolutionOptions));
- setMaxResolutionValue();
- maxResolution.setOnAction((e) -> {
- Config.getInstance().getSettings().maximumResolution = maxResolution.getSelectionModel().getSelectedItem();
- saveConfig();
- });
- layout.add(maxResolution, 1, row++);
- GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
- GridPane.setMargin(maxResolution, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
-
- l = new Label("Split recordings after (minutes)");
- layout.add(l, 0, row);
- List options = new ArrayList<>();
- options.add(new SplitAfterOption("disabled", 0));
- options.add(new SplitAfterOption("10 min", 10 * 60));
- options.add(new SplitAfterOption("15 min", 15 * 60));
- options.add(new SplitAfterOption("20 min", 20 * 60));
- options.add(new SplitAfterOption("30 min", 30 * 60));
- options.add(new SplitAfterOption("60 min", 60 * 60));
- splitAfter = new ComboBox<>(FXCollections.observableList(options));
- layout.add(splitAfter, 1, row++);
- setSplitAfterValue();
- splitAfter.setOnAction((e) -> {
- Config.getInstance().getSettings().splitRecordings = splitAfter.getSelectionModel().getSelectedItem().getValue();
- saveConfig();
- });
- GridPane.setMargin(l, new Insets(0, 0, 0, 0));
- GridPane.setMargin(splitAfter, new Insets(0, 0, 0, CHECKBOX_MARGIN));
-
- layout.add(new Label("Check online state every (seconds)"), 0, row);
- onlineCheckIntervalInSecs = new TextField(Integer.toString(Config.getInstance().getSettings().onlineCheckIntervalInSecs));
- onlineCheckIntervalInSecs.textProperty().addListener((observable, oldValue, newValue) -> {
- if (!newValue.matches("\\d*")) {
- onlineCheckIntervalInSecs.setText(newValue.replaceAll("[^\\d]", ""));
- }
- if(!onlineCheckIntervalInSecs.getText().isEmpty()) {
- Config.getInstance().getSettings().onlineCheckIntervalInSecs = Integer.parseInt(onlineCheckIntervalInSecs.getText());
- saveConfig();
- }
- });
- GridPane.setMargin(onlineCheckIntervalInSecs, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- layout.add(onlineCheckIntervalInSecs, 1, row++);
-
l = new Label("Start Tab");
layout.add(l, 0, row);
startTab = new ComboBox<>();
@@ -429,12 +465,6 @@ public class SettingsTab extends Tab implements TabSelectionListener {
GridPane.setMargin(l, new Insets(0, 0, 0, 0));
GridPane.setMargin(colorSettingsPane, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
-
- splitAfter.prefWidthProperty().bind(startTab.widthProperty());
- maxResolution.prefWidthProperty().bind(startTab.widthProperty());
- onlineCheckIntervalInSecs.prefWidthProperty().bind(startTab.widthProperty());
- onlineCheckIntervalInSecs.maxWidthProperty().bind(startTab.widthProperty());
-
TitledPane general = new TitledPane("General", layout);
general.setCollapsible(false);
return general;
@@ -481,6 +511,8 @@ public class SettingsTab extends Tab implements TabSelectionListener {
postProcessing.setDisable(!local);
postProcessingDirectoryButton.setDisable(!local);
directoryStructure.setDisable(!local);
+ onlineCheckIntervalInSecs.setDisable(!local);
+ leaveSpaceOnDevice.setDisable(!local);
}
private ChangeListener super Boolean> createRecordingsDirectoryFocusListener() {
@@ -653,9 +685,10 @@ public class SettingsTab extends Tab implements TabSelectionListener {
@Override
public void selected() {
- startTab.getItems().clear();
- for(Tab tab : getTabPane().getTabs()) {
- startTab.getItems().add(tab.getText());
+ if(startTab.getItems().isEmpty()) {
+ for(Tab tab : getTabPane().getTabs()) {
+ startTab.getItems().add(tab.getText());
+ }
}
String startTabName = Config.getInstance().getSettings().startTab;
if(StringUtil.isNotBlank(startTabName)) {
diff --git a/common/pom.xml b/common/pom.xml
index 54db7db8..cb1e5152 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -8,7 +8,7 @@
ctbrec
master
- 1.11.0
+ 1.12.0
../master
diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java
index 865f6bc1..871c36ff 100644
--- a/common/src/main/java/ctbrec/Config.java
+++ b/common/src/main/java/ctbrec/Config.java
@@ -100,10 +100,14 @@ public class Config {
Files.write(configFile.toPath(), json.getBytes("utf-8"), CREATE, WRITE, TRUNCATE_EXISTING);
}
- public boolean isServerMode() {
+ public static boolean isServerMode() {
return Objects.equals(System.getProperty("ctbrec.server.mode"), "1");
}
+ public static boolean isDevMode() {
+ return Objects.equals(System.getenv("CTBREC_DEV"), "1");
+ }
+
public File getConfigDir() {
return configDir;
}
@@ -113,10 +117,6 @@ public class Config {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm");
String startTime = sdf.format(new Date());
File targetFile = new File(dirForRecording, model.getName() + '_' + startTime + ".ts");
- if(getSettings().splitRecordings > 0) {
- LOG.debug("Splitting recordings every {} seconds", getSettings().splitRecordings);
- targetFile = new File(targetFile.getAbsolutePath().replaceAll("\\.ts", "-00000.ts"));
- }
return targetFile;
}
diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java
index a34e24e9..3b613845 100644
--- a/common/src/main/java/ctbrec/Settings.java
+++ b/common/src/main/java/ctbrec/Settings.java
@@ -37,6 +37,7 @@ public class Settings {
public String httpServer = "localhost";
public String recordingsDir = System.getProperty("user.home") + File.separator + "ctbrec";
public DirectoryStructure recordingsDirStructure = DirectoryStructure.FLAT;
+ public long minimumSpaceLeftInBytes = 0;
public String mediaPlayer = "/usr/bin/mpv";
public String postProcessing = "";
public String username = ""; // chaturbate username TODO maybe rename this onetime
diff --git a/common/src/main/java/ctbrec/StringUtil.java b/common/src/main/java/ctbrec/StringUtil.java
index 229e44b1..d9ae9796 100644
--- a/common/src/main/java/ctbrec/StringUtil.java
+++ b/common/src/main/java/ctbrec/StringUtil.java
@@ -1,5 +1,7 @@
package ctbrec;
+import java.text.DecimalFormat;
+
public class StringUtil {
public static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
@@ -8,4 +10,21 @@ public class StringUtil {
public static boolean isNotBlank(String s) {
return !isBlank(s);
}
+
+ public static String formatSize(Number sizeInByte) {
+ DecimalFormat df = new DecimalFormat("0.00");
+ String unit = "Bytes";
+ double size = sizeInByte.doubleValue();
+ if(size > 1024.0 * 1024 * 1024) {
+ size = size / 1024.0 / 1024 / 1024;
+ unit = "GiB";
+ } else if(size > 1024.0 * 1024) {
+ size = size / 1024.0 / 1024;
+ unit = "MiB";
+ } else if(size > 1024.0) {
+ size = size / 1024.0;
+ unit = "KiB";
+ }
+ return df.format(size) + ' ' + unit;
+ }
}
diff --git a/common/src/main/java/ctbrec/recorder/LocalRecorder.java b/common/src/main/java/ctbrec/recorder/LocalRecorder.java
index 3afb3ee4..82c5219c 100644
--- a/common/src/main/java/ctbrec/recorder/LocalRecorder.java
+++ b/common/src/main/java/ctbrec/recorder/LocalRecorder.java
@@ -5,6 +5,7 @@ import static ctbrec.Recording.STATUS.*;
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
+import java.nio.file.FileStore;
import java.nio.file.Files;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
@@ -63,6 +64,7 @@ public class LocalRecorder implements Recorder {
private List deleteInProgress = Collections.synchronizedList(new ArrayList<>());
private RecorderHttpClient client = new RecorderHttpClient();
private ReentrantLock lock = new ReentrantLock();
+ private long lastSpaceMessage = 0;
public LocalRecorder(Config config) {
this.config = config;
@@ -81,7 +83,7 @@ public class LocalRecorder implements Recorder {
onlineMonitor.start();
postProcessingTrigger = new PostProcessingTrigger();
- if(Config.getInstance().isServerMode()) {
+ if(Config.isServerMode()) {
postProcessingTrigger.start();
}
@@ -133,7 +135,6 @@ public class LocalRecorder implements Recorder {
return;
}
- LOG.debug("Starting recording for model {}", model.getName());
if (recordingProcesses.containsKey(model)) {
LOG.error("A recording for model {} is already running", model);
return;
@@ -149,8 +150,18 @@ public class LocalRecorder implements Recorder {
lock.unlock();
}
+ if(!enoughSpaceForRecording()) {
+ long now = System.currentTimeMillis();
+ if( (now - lastSpaceMessage) > TimeUnit.MINUTES.toMillis(1)) {
+ LOG.info("Not enough space for recording, not starting recording for {}", model);
+ lastSpaceMessage = now;
+ }
+ return;
+ }
+
+ LOG.debug("Starting recording for model {}", model.getName());
Download download;
- if (Config.getInstance().isServerMode()) {
+ if (Config.isServerMode()) {
download = new HlsDownload(client);
} else {
download = new MergedHlsDownload(client);
@@ -173,7 +184,7 @@ public class LocalRecorder implements Recorder {
Download download = recordingProcesses.get(model);
download.stop();
recordingProcesses.remove(model);
- if(!Config.getInstance().isServerMode()) {
+ if(!Config.isServerMode()) {
postprocess(download);
}
}
@@ -329,6 +340,15 @@ public class LocalRecorder implements Recorder {
public void run() {
running = true;
while (running) {
+ try {
+ if(!enoughSpaceForRecording() && !recordingProcesses.isEmpty()) {
+ LOG.info("No space left -> Stopping all recordings");
+ stopRecordingProcesses();
+ }
+ } catch (IOException e1) {
+ LOG.warn("Couldn't check free space left", e1);
+ }
+
List restart = new ArrayList<>();
for (Iterator> iterator = recordingProcesses.entrySet().iterator(); iterator.hasNext();) {
Entry entry = iterator.next();
@@ -338,7 +358,7 @@ public class LocalRecorder implements Recorder {
LOG.debug("Recording terminated for model {}", m.getName());
iterator.remove();
restart.add(m);
- if(config.isServerMode()) {
+ if(Config.isServerMode()) {
try {
finishRecording(d.getTarget());
} catch(Exception e) {
@@ -365,7 +385,7 @@ public class LocalRecorder implements Recorder {
}
private void finishRecording(File directory) {
- if(Config.getInstance().isServerMode()) {
+ if(Config.isServerMode()) {
Thread t = new Thread() {
@Override
public void run() {
@@ -415,7 +435,7 @@ public class LocalRecorder implements Recorder {
boolean isOnline = model.isOnline(IGNORE_CACHE);
LOG.trace("Checking online state for {}: {}", model, (isOnline ? "online" : "offline"));
if (isOnline && !isSuspended(model) && !recordingProcesses.containsKey(model)) {
- LOG.info("Model {}'s room back to public. Starting recording", model);
+ LOG.info("Model {}'s room back to public", model);
startRecordingProcess(model);
}
} catch (HttpException e) {
@@ -493,7 +513,7 @@ public class LocalRecorder implements Recorder {
@Override
public List getRecordings() {
- if(Config.getInstance().isServerMode()) {
+ if(Config.isServerMode()) {
return listSegmentedRecordings();
} else {
return listMergedRecordings();
@@ -538,7 +558,7 @@ public class LocalRecorder implements Recorder {
return GENERATING_PLAYLIST;
}
- if (config.isServerMode()) {
+ if (Config.isServerMode()) {
if (recording.hasPlaylist()) {
return FINISHED;
} else {
@@ -745,4 +765,25 @@ public class LocalRecorder implements Recorder {
public HttpClient getHttpClient() {
return client;
}
+
+ @Override
+ public long getTotalSpaceBytes() throws IOException {
+ return getRecordingsFileStore().getTotalSpace();
+ }
+
+ @Override
+ public long getFreeSpaceBytes() throws IOException {
+ return getRecordingsFileStore().getUsableSpace();
+ }
+
+ private FileStore getRecordingsFileStore() throws IOException {
+ File recordingsDir = new File(config.getSettings().recordingsDir);
+ FileStore store = Files.getFileStore(recordingsDir.toPath());
+ return store;
+ }
+
+ private boolean enoughSpaceForRecording() throws IOException {
+ long minimum = config.getSettings().minimumSpaceLeftInBytes;
+ return getFreeSpaceBytes() > minimum;
+ }
}
diff --git a/common/src/main/java/ctbrec/recorder/Recorder.java b/common/src/main/java/ctbrec/recorder/Recorder.java
index bc4e60cf..1a9bf682 100644
--- a/common/src/main/java/ctbrec/recorder/Recorder.java
+++ b/common/src/main/java/ctbrec/recorder/Recorder.java
@@ -42,4 +42,18 @@ public interface Recorder {
public List getOnlineModels();
public HttpClient getHttpClient();
+
+ /**
+ * Get the total size of the filesystem we are recording to
+ * @return the total size in bytes
+ * @throws IOException
+ */
+ public long getTotalSpaceBytes() throws IOException;
+
+ /**
+ * Get the free space left on the filesystem we are recording to
+ * @return the free space in bytes
+ * @throws IOException
+ */
+ public long getFreeSpaceBytes() throws IOException;
}
diff --git a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java
index dd648301..7c3ced0a 100644
--- a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java
+++ b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java
@@ -8,6 +8,7 @@ import java.time.Instant;
import java.util.Collections;
import java.util.List;
+import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -45,6 +46,8 @@ public class RemoteRecorder implements Recorder {
private List models = Collections.emptyList();
private List onlineModels = Collections.emptyList();
private List sites;
+ private long spaceTotal = -1;
+ private long spaceFree = -1;
private Config config;
private HttpClient client;
@@ -150,10 +153,35 @@ public class RemoteRecorder implements Recorder {
while(running) {
syncModels();
syncOnlineModels();
+ syncSpace();
sleep();
}
}
+ private void syncSpace() {
+ try {
+ String msg = "{\"action\": \"space\"}";
+ RequestBody body = RequestBody.create(JSON, msg);
+ Request.Builder builder = new Request.Builder()
+ .url("http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/rec")
+ .post(body);
+ addHmacIfNeeded(msg, builder);
+ Request request = builder.build();
+ try(Response response = client.execute(request)) {
+ String json = response.body().string();
+ if(response.isSuccessful()) {
+ JSONObject resp = new JSONObject(json);
+ spaceTotal = resp.getLong("spaceTotal");
+ spaceFree = resp.getLong("spaceFree");
+ } else {
+ LOG.error("Couldn't synchronize with server. HTTP status: {} - {}", response.code(), json);
+ }
+ }
+ } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e) {
+ LOG.error("Couldn't synchronize with server", e);
+ }
+ }
+
private void syncModels() {
try {
String msg = "{\"action\": \"list\"}";
@@ -362,4 +390,14 @@ public class RemoteRecorder implements Recorder {
public HttpClient getHttpClient() {
return client;
}
+
+ @Override
+ public long getTotalSpaceBytes() throws IOException {
+ return spaceTotal;
+ }
+
+ @Override
+ public long getFreeSpaceBytes() {
+ return spaceFree;
+ }
}
diff --git a/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java
index 84c4c7d6..c9cd0010 100644
--- a/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java
+++ b/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java
@@ -15,11 +15,19 @@ import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
-import java.text.DecimalFormat;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
+import java.util.LinkedList;
+import java.util.Queue;
+import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -48,11 +56,12 @@ public class MergedHlsDownload extends AbstractHlsDownload {
private BlockingMultiMTSSource multiSource;
private Thread mergeThread;
private Streamer streamer;
- private ZonedDateTime startTime;
+ private ZonedDateTime splitRecStartTime;
private Config config;
private File targetFile;
- private DecimalFormat df = new DecimalFormat("00000");
- private int splitCounter = 0;
+ private BlockingQueue downloadQueue = new LinkedBlockingQueue<>(50);
+ private ExecutorService downloadThreadPool = new ThreadPoolExecutor(5, 5, 2, TimeUnit.MINUTES, downloadQueue);
+ private FileChannel fileChannel = null;
public MergedHlsDownload(HttpClient client) {
super(client);
@@ -67,6 +76,7 @@ public class MergedHlsDownload extends AbstractHlsDownload {
try {
running = true;
super.startTime = Instant.now();
+ splitRecStartTime = ZonedDateTime.now();
mergeThread = createMergeThread(targetFile, progressListener, false);
LOG.debug("Merge thread started");
mergeThread.start();
@@ -81,7 +91,7 @@ public class MergedHlsDownload extends AbstractHlsDownload {
downloadSegments(segmentPlaylistUri, false);
LOG.debug("Waiting for merge thread to finish");
mergeThread.join();
- LOG.debug("Merge thread to finished");
+ LOG.debug("Merge thread finished");
} catch(ParseException e) {
throw new IOException("Couldn't parse stream information", e);
} catch(PlaylistException e) {
@@ -92,7 +102,12 @@ public class MergedHlsDownload extends AbstractHlsDownload {
throw new IOException("Couldn't add HMAC to playlist url", e);
} finally {
alive = false;
- streamer.stop();
+ try {
+ streamer.stop();
+ } catch(Exception e) {
+ LOG.error("Couldn't stop streamer", e);
+ }
+ downloadThreadPool.shutdown();
LOG.debug("Download terminated for {}", segmentPlaylistUri);
}
}
@@ -107,6 +122,7 @@ public class MergedHlsDownload extends AbstractHlsDownload {
running = true;
super.startTime = Instant.now();
+ splitRecStartTime = ZonedDateTime.now();
super.model = model;
targetFile = Config.getInstance().getFileForRecording(model);
String segments = getSegmentPlaylistUrl(model);
@@ -114,6 +130,9 @@ public class MergedHlsDownload extends AbstractHlsDownload {
mergeThread.start();
if(segments != null) {
downloadSegments(segments, true);
+ if(config.getSettings().splitRecordings > 0) {
+ LOG.debug("Splitting recordings every {} seconds", config.getSettings().splitRecordings);
+ }
} else {
throw new IOException("Couldn't determine segments uri");
}
@@ -129,7 +148,11 @@ public class MergedHlsDownload extends AbstractHlsDownload {
} finally {
alive = false;
if(streamer != null) {
- streamer.stop();
+ try {
+ streamer.stop();
+ } catch(Exception e) {
+ LOG.error("Couldn't stop streamer", e);
+ }
}
LOG.debug("Download for {} terminated", model);
}
@@ -138,36 +161,110 @@ public class MergedHlsDownload extends AbstractHlsDownload {
private void downloadSegments(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException {
int lastSegment = 0;
int nextSegment = 0;
+ long playlistNotFoundFirstEncounter = -1;
while(running) {
try {
+ if(playlistNotFoundFirstEncounter != -1) {
+ LOG.debug("Downloading playlist {}", segmentPlaylistUri);
+ }
SegmentPlaylist lsp = getNextSegments(segmentPlaylistUri);
+ playlistNotFoundFirstEncounter = -1;
if(!livestreamDownload) {
multiSource.setTotalSegments(lsp.segments.size());
}
- // download segments, which might have been skipped
- downloadMissedSegments(lsp, nextSegment);
-
// download new segments
+ long downloadStart = System.currentTimeMillis();
downloadNewSegments(lsp, nextSegment);
+ long downloadTookMillis = System.currentTimeMillis() - downloadStart;
+
+ // download segments, which might have been skipped
+ //downloadMissedSegments(lsp, nextSegment);
+ 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
splitRecording();
// wait some time until requesting the segment playlist again to not hammer the server
- waitForNewSegments(lsp, lastSegment);
+ waitForNewSegments(lsp, lastSegment, downloadTookMillis);
lastSegment = lsp.seq;
nextSegment = lastSegment + lsp.segments.size();
} else {
break;
}
- } catch(HttpException e) {
- if(e.getResponseCode() == 404) {
- // playlist is gone -> model probably logged out
- LOG.debug("Playlist not found. Assuming model went offline");
- running = false;
+ } catch(Exception e) {
+ LOG.info("Unexpected error while downloading {}", model.getName(), e);
+ running = false;
+ }
+ }
+ }
+
+ private void downloadNewSegments(SegmentPlaylist lsp, int nextSegment) throws MalformedURLException, MissingSegmentException, ExecutionException, HttpException {
+ int skip = nextSegment - lsp.seq;
+ if(lsp.segments.isEmpty()) {
+ LOG.debug("Empty playlist: {}", lsp.url);
+ }
+
+ // add segments to download threadpool
+ Queue> downloads = new LinkedList<>();
+ if(downloadQueue.remainingCapacity() == 0) {
+ LOG.warn("Download to slow for this stream. Download queue is full. Skipping segment");
+ } else {
+ for (String segment : lsp.segments) {
+ if(!running) {
+ break;
+ }
+ if(skip > 0) {
+ skip--;
+ } else {
+ URL segmentUrl = new URL(segment);
+ Future download = downloadThreadPool.submit(new SegmentDownload(segmentUrl, client));
+ downloads.add(download);
+ }
+ }
+ }
+
+ // get completed downloads and write them to the file
+ // TODO it might be a good idea to do this in a separate thread, so that the main download loop isn't blocked
+ writeFinishedSegments(downloads);
+ }
+
+ private void writeFinishedSegments(Queue> downloads) throws ExecutionException, HttpException {
+ for (Future downloadFuture : downloads) {
+ try {
+ byte[] segmentData = downloadFuture.get();
+ writeSegment(segmentData);
+ } catch (InterruptedException e) {
+ LOG.error("Error while downloading segment", e);
+ } 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", model.getName());
+ }
+ } 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", model.getName());
+ running = false;
+ } else if(he.getResponseCode() == 403) {
+ LOG.info("Playlist for {} not accessible [HTTP 403]. Stopping now", model.getName());
+ running = false;
+ } else {
+ throw he;
+ }
+ }
} else {
throw e;
}
@@ -175,43 +272,6 @@ public class MergedHlsDownload extends AbstractHlsDownload {
}
}
- private void downloadMissedSegments(SegmentPlaylist lsp, int nextSegment) throws MalformedURLException {
- if(nextSegment > 0 && lsp.seq > nextSegment) {
- LOG.warn("Missed segments {} < {} in download for {}", nextSegment, lsp.seq, lsp.url);
- String first = lsp.segments.get(0);
- int seq = lsp.seq;
- for (int i = nextSegment; i < lsp.seq; i++) {
- URL segmentUrl = new URL(first.replaceAll(Integer.toString(seq), Integer.toString(i)));
- LOG.debug("Loading missed segment {} for model {}", i, lsp.url);
- byte[] segmentData;
- try {
- segmentData = new SegmentDownload(segmentUrl, client).call();
- writeSegment(segmentData);
- } catch (Exception e) {
- LOG.error("Error while downloading segment {}", segmentUrl, e);
- }
- }
- // TODO switch to a lower bitrate/resolution ?!?
- }
- }
-
- private void downloadNewSegments(SegmentPlaylist lsp, int nextSegment) throws MalformedURLException {
- int skip = nextSegment - lsp.seq;
- for (String segment : lsp.segments) {
- if(skip > 0) {
- skip--;
- } else {
- URL segmentUrl = new URL(segment);
- try {
- byte[] segmentData = new SegmentDownload(segmentUrl, client).call();
- writeSegment(segmentData);
- } catch (Exception e) {
- LOG.error("Error while downloading segment {}", segmentUrl, e);
- }
- }
- }
- }
-
private void writeSegment(byte[] segmentData) throws InterruptedException {
InputStream in = new ByteArrayInputStream(segmentData);
InputStreamMTSSource source = InputStreamMTSSource.builder().setInputStream(in).build();
@@ -220,24 +280,36 @@ public class MergedHlsDownload extends AbstractHlsDownload {
private void splitRecording() {
if(config.getSettings().splitRecordings > 0) {
- Duration recordingDuration = Duration.between(startTime, ZonedDateTime.now());
+ Duration recordingDuration = Duration.between(splitRecStartTime, ZonedDateTime.now());
long seconds = recordingDuration.getSeconds();
if(seconds >= config.getSettings().splitRecordings) {
- streamer.stop();
- File target = new File(targetFile.getAbsolutePath().replaceAll("\\.ts", "-"+df.format(++splitCounter)+".ts"));
- mergeThread = createMergeThread(target, null, true);
- mergeThread.start();
- startTime = ZonedDateTime.now();
+ try {
+ targetFile = Config.getInstance().getFileForRecording(model);
+ LOG.debug("Switching to file {}", targetFile.getAbsolutePath());
+ fileChannel = FileChannel.open(targetFile.toPath(), CREATE, WRITE);
+ MTSSink sink = ByteChannelSink.builder().setByteChannel(fileChannel).build();
+ streamer.switchSink(sink);
+ splitRecStartTime = ZonedDateTime.now();
+ } catch (IOException e) {
+ LOG.error("Error while splitting recording", e);
+ running = false;
+ }
}
}
}
- private void waitForNewSegments(SegmentPlaylist lsp, int lastSegment) {
+ private void waitForNewSegments(SegmentPlaylist lsp, int lastSegment, long downloadTookMillis) {
try {
long wait = 0;
if (lastSegment == lsp.seq) {
- // playlist didn't change -> wait for at least half the target duration
- wait = (long) lsp.targetDuration * 1000 / 2;
+ int timeLeftMillis = (int)(lsp.totalDuration * 1000 - downloadTookMillis);
+ if(timeLeftMillis < 3000) { // we have less than 3 seconds to get the new playlist and start downloading it
+ wait = 1;
+ } else {
+ // wait a second to be nice to the server (don't hammer it with requests)
+ // 1 second seems to be a good compromise. every other calculation resulted in more missing segments
+ wait = 1000;
+ }
LOG.trace("Playlist didn't change... waiting for {}ms", wait);
} else {
// playlist did change -> wait for at least last segment duration
@@ -256,7 +328,9 @@ public class MergedHlsDownload extends AbstractHlsDownload {
public void stop() {
running = false;
alive = false;
- streamer.stop();
+ if(streamer != null) {
+ streamer.stop();
+ }
LOG.debug("Download stopped");
}
@@ -267,20 +341,20 @@ public class MergedHlsDownload extends AbstractHlsDownload {
.setProgressListener(listener)
.build();
- FileChannel channel = null;
try {
Path downloadDir = targetFile.getParentFile().toPath();
if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(downloadDir);
}
- channel = FileChannel.open(targetFile.toPath(), CREATE, WRITE);
- MTSSink sink = ByteChannelSink.builder().setByteChannel(channel).build();
+ fileChannel = FileChannel.open(targetFile.toPath(), CREATE, WRITE);
+ MTSSink sink = ByteChannelSink.builder().setByteChannel(fileChannel).build();
streamer = Streamer.builder()
.setSource(multiSource)
.setSink(sink)
.setSleepingEnabled(liveStream)
.setBufferSize(10)
+ .setName(model.getName())
.build();
// Start streaming
@@ -293,11 +367,12 @@ public class MergedHlsDownload extends AbstractHlsDownload {
} catch(Exception e) {
LOG.error("Error while saving stream to file", e);
} finally {
- closeFile(channel);
deleteEmptyRecording(targetFile);
+ running = false;
+ closeFile(fileChannel);
}
});
- t.setName("Segment Merger Thread");
+ t.setName("Segment Merger Thread [" + model.getName() + "]");
t.setDaemon(true);
return t;
}
@@ -308,22 +383,22 @@ public class MergedHlsDownload extends AbstractHlsDownload {
Files.delete(targetFile.toPath());
Files.delete(targetFile.getParentFile().toPath());
}
- } catch (IOException e) {
+ } catch (Exception e) {
LOG.error("Error while deleting empty recording {}", targetFile);
}
}
private void closeFile(FileChannel channel) {
try {
- if (channel != null) {
+ if (channel != null && channel.isOpen()) {
channel.close();
}
- } catch (IOException e) {
+ } catch (Exception e) {
LOG.error("Error while closing file channel", e);
}
}
- private static class SegmentDownload implements Callable {
+ private class SegmentDownload implements Callable {
private URL url;
private HttpClient client;
@@ -333,24 +408,38 @@ public class MergedHlsDownload extends AbstractHlsDownload {
}
@Override
- public byte[] call() throws Exception {
+ public byte[] call() throws IOException {
LOG.trace("Downloading segment " + url.getFile());
int maxTries = 3;
- for (int i = 1; i <= maxTries; i++) {
- try {
- Request request = new Request.Builder().url(url).addHeader("connection", "keep-alive").build();
- Response response = client.execute(request);
- byte[] segment = response.body().bytes();
- return segment;
+ for (int i = 1; i <= maxTries && running; i++) {
+ Request request = new Request.Builder().url(url).addHeader("connection", "keep-alive").build();
+ try (Response response = client.execute(request)) {
+ if(response.isSuccessful()) {
+ byte[] segment = response.body().bytes();
+ return segment;
+ } else {
+ throw new HttpException(response.code(), response.message());
+ }
} catch(Exception e) {
if (i == maxTries) {
LOG.warn("Error while downloading segment. Segment {} finally failed", url.getFile());
} else {
- LOG.warn("Error while downloading segment {} on try {}", url.getFile(), i);
+ LOG.warn("Error while downloading segment {} on try {}", url.getFile(), i, e);
+ }
+ if(model != null && !isModelOnline()) {
+ break;
}
}
}
- throw new IOException("Unable to download segment " + url.getFile() + " after " + maxTries + " tries");
+ throw new MissingSegmentException("Unable to download segment " + url.getFile() + " after " + maxTries + " tries");
+ }
+ }
+
+ public boolean isModelOnline() {
+ try {
+ return model.isOnline(IGNORE_CACHE);
+ } catch (IOException | ExecutionException | InterruptedException e) {
+ return false;
}
}
}
diff --git a/common/src/main/java/ctbrec/recorder/download/MissingSegmentException.java b/common/src/main/java/ctbrec/recorder/download/MissingSegmentException.java
new file mode 100644
index 00000000..d6971aab
--- /dev/null
+++ b/common/src/main/java/ctbrec/recorder/download/MissingSegmentException.java
@@ -0,0 +1,11 @@
+package ctbrec.recorder.download;
+
+import java.io.IOException;
+
+public class MissingSegmentException extends IOException {
+
+ public MissingSegmentException(String msg) {
+ super(msg);
+ }
+
+}
diff --git a/common/src/main/java/ctbrec/recorder/download/StreamSource.java b/common/src/main/java/ctbrec/recorder/download/StreamSource.java
index 1968df81..dbb86f03 100644
--- a/common/src/main/java/ctbrec/recorder/download/StreamSource.java
+++ b/common/src/main/java/ctbrec/recorder/download/StreamSource.java
@@ -54,7 +54,7 @@ public class StreamSource implements Comparable {
@Override
public int compareTo(StreamSource o) {
int heightDiff = height - o.height;
- if(heightDiff != 0) {
+ if(heightDiff != 0 && height != Integer.MAX_VALUE && o.height != Integer.MAX_VALUE) {
return heightDiff;
} else {
return bandwidth - o.bandwidth;
diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java
index 30b87e9a..68b24354 100644
--- a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java
+++ b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java
@@ -5,6 +5,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
+import java.util.Optional;
import java.util.concurrent.ExecutionException;
import org.json.JSONArray;
@@ -24,6 +25,7 @@ import com.iheartradio.m3u8.data.PlaylistData;
import ctbrec.AbstractModel;
import ctbrec.Config;
+import ctbrec.StringUtil;
import ctbrec.io.HtmlParser;
import ctbrec.io.HttpException;
import ctbrec.recorder.download.StreamSource;
@@ -38,6 +40,7 @@ public class Cam4Model extends AbstractModel {
private String playlistUrl;
private String onlineState = "offline";
private int[] resolution = null;
+ private boolean privateRoom = false;
@Override
public boolean isOnline() throws IOException, ExecutionException, InterruptedException {
@@ -53,7 +56,9 @@ public class Cam4Model extends AbstractModel {
return false;
}
}
- return Objects.equals("NORMAL", onlineState);
+ return (Objects.equals("NORMAL", onlineState) || Objects.equals("GROUP_SHOW_SELLING_TICKETS", onlineState))
+ && StringUtil.isNotBlank(playlistUrl)
+ && !privateRoom;
}
private void loadModelDetails() throws IOException, ModelDetailsEmptyException {
@@ -64,11 +69,13 @@ public class Cam4Model extends AbstractModel {
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);
onlineState = details.getString("showType");
playlistUrl = details.getString("hlsPreviewUrl");
+ privateRoom = details.getBoolean("privateRoom");
if(details.has("resolution")) {
String res = details.getString("resolution");
String[] tokens = res.split(":");
@@ -104,7 +111,7 @@ public class Cam4Model extends AbstractModel {
if (playlist.hasStreamInfo()) {
StreamSource src = new StreamSource();
src.bandwidth = playlist.getStreamInfo().getBandwidth();
- src.height = playlist.getStreamInfo().getResolution().height;
+ src.height = Optional.ofNullable(playlist.getStreamInfo()).map(si -> si.getResolution()).map(res -> res.height).orElse(0);
String masterUrl = getPlaylistUrl();
String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1);
String segmentUri = baseUrl + playlist.getUri();
diff --git a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java
index 36125f85..534a39bb 100644
--- a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java
+++ b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java
@@ -219,7 +219,15 @@ public class Chaturbate extends AbstractSite {
}
StreamInfo getStreamInfo(String modelName) throws IOException, ExecutionException {
- return streamInfoCache.get(modelName);
+ return getStreamInfo(modelName, false);
+ }
+
+ StreamInfo getStreamInfo(String modelName, boolean failFast) throws IOException, ExecutionException {
+ if(failFast) {
+ return streamInfoCache.getIfPresent(modelName);
+ } else {
+ return streamInfoCache.get(modelName);
+ }
}
StreamInfo loadStreamInfo(String modelName) throws HttpException, IOException, InterruptedException {
diff --git a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java
index 3095c4be..bd17cd23 100644
--- a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java
+++ b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java
@@ -6,6 +6,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
+import java.util.Optional;
import java.util.concurrent.ExecutionException;
import org.slf4j.Logger;
@@ -39,14 +40,16 @@ public class ChaturbateModel extends AbstractModel {
@Override
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
- StreamInfo info;
+ String roomStatus;
if(ignoreCache) {
- info = getChaturbate().loadStreamInfo(getName());
+ StreamInfo info = getChaturbate().loadStreamInfo(getName());
+ roomStatus = Optional.ofNullable(info).map(i -> i.room_status).orElse("");
LOG.trace("Model {} room status: {}", getName(), info.room_status);
} else {
- info = getChaturbate().getStreamInfo(getName());
+ StreamInfo info = getChaturbate().getStreamInfo(getName(), true);
+ roomStatus = Optional.ofNullable(info).map(i -> i.room_status).orElse("");
}
- return Objects.equals("public", info.room_status);
+ return Objects.equals("public", roomStatus);
}
@Override
diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java
index f0776843..45f13c08 100644
--- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java
+++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java
@@ -221,7 +221,7 @@ public class MyFreeCamsModel extends AbstractModel {
setName(state.getNm());
setState(State.of(state.getVs()));
setStreamUrl(streamUrl);
- Optional camScore = Optional.of(state.getM()).map(m -> m.getCamscore());
+ Optional camScore = Optional.ofNullable(state.getM()).map(m -> m.getCamscore());
setCamScore(camScore.orElse(0.0));
// preview
diff --git a/common/src/main/java/org/taktik/mpegts/Streamer.java b/common/src/main/java/org/taktik/mpegts/Streamer.java
index d844da92..560bbfc8 100644
--- a/common/src/main/java/org/taktik/mpegts/Streamer.java
+++ b/common/src/main/java/org/taktik/mpegts/Streamer.java
@@ -30,12 +30,14 @@ public class Streamer {
private Thread streamingThread;
private boolean sleepingEnabled;
+ private String name;
- private Streamer(MTSSource source, MTSSink sink, int bufferSize, boolean sleepingEnabled) {
+ private Streamer(MTSSource source, MTSSink sink, int bufferSize, boolean sleepingEnabled, String name) {
this.source = source;
this.sink = sink;
this.bufferSize = bufferSize;
this.sleepingEnabled = sleepingEnabled;
+ this.name = name;
}
public void stream() throws InterruptedException {
@@ -48,20 +50,26 @@ public class Streamer {
try {
preBuffer();
} catch (Exception e) {
- throw new IllegalStateException("Error while bufering", e);
+ throw new IllegalStateException("Error while buffering", e);
}
log.info("Done PreBuffering");
- bufferingThread = new Thread(this::fillBuffer, "buffering");
+ bufferingThread = new Thread(this::fillBuffer, "Buffering ["+name+"]");
bufferingThread.setDaemon(true);
bufferingThread.start();
- streamingThread = new Thread(this::internalStream, "streaming");
+ streamingThread = new Thread(this::internalStream, "Streaming ["+name+"]");
streamingThread.setDaemon(true);
streamingThread.start();
bufferingThread.join();
streamingThread.join();
+
+ try {
+ sink.close();
+ } catch(Exception e) {
+ log.error("Couldn't close sink", e);
+ }
}
public void stop() {
@@ -85,6 +93,16 @@ public class Streamer {
}
}
+ public void switchSink(MTSSink sink) {
+ MTSSink old = this.sink;
+ this.sink = sink;
+ try {
+ old.close();
+ } catch (Exception e) {
+ log.error("Couldn't close old sink while switching sinks", e);
+ }
+ }
+
private void internalStream() {
boolean resetState = false;
MTSPacket packet = null;
@@ -123,7 +141,7 @@ public class Streamer {
}
}
} catch (InterruptedException e1) {
- if(!endOfSourceReached) {
+ if(!endOfSourceReached && !streamingShouldStop) {
log.error("Interrupted while waiting for packet");
continue;
} else {
@@ -240,7 +258,7 @@ public class Streamer {
// Stream packet
// System.out.println("Streaming packet #" + packetCount + ", PID=" + mtsPacket.getPid() + ", pcrCount=" + pcrCount + ", continuityCounter=" + mtsPacket.getContinuityCounter());
- if(!streamingShouldStop) {
+ if(!streamingShouldStop && !Thread.interrupted()) {
try {
sink.send(packet);
} catch (Exception e) {
@@ -275,7 +293,7 @@ public class Streamer {
buffer.put(packet);
put = true;
} catch (InterruptedException ignored) {
-
+ log.error("Error adding packet to buffer", ignored);
}
}
}
@@ -287,7 +305,11 @@ public class Streamer {
log.error("Error reading from source", e);
} finally {
endOfSourceReached = true;
- streamingThread.interrupt();
+ try {
+ streamingThread.interrupt();
+ } catch(Exception e) {
+ log.error("Couldn't interrupt streaming thread", e);
+ }
}
}
@@ -308,6 +330,7 @@ public class Streamer {
private MTSSource source;
private int bufferSize = 1000;
private boolean sleepingEnabled = false;
+ private String name;
public StreamerBuilder setSink(MTSSink sink) {
this.sink = sink;
@@ -329,10 +352,16 @@ public class Streamer {
return this;
}
+ public StreamerBuilder setName(String name) {
+ this.name = name;
+ return this;
+ }
+
public Streamer build() {
Preconditions.checkNotNull(sink);
Preconditions.checkNotNull(source);
- return new Streamer(source, sink, bufferSize, sleepingEnabled);
+ return new Streamer(source, sink, bufferSize, sleepingEnabled, name);
}
+
}
}
\ No newline at end of file
diff --git a/master/pom.xml b/master/pom.xml
index 8d905ac5..3c43cdae 100644
--- a/master/pom.xml
+++ b/master/pom.xml
@@ -6,7 +6,7 @@
ctbrec
master
pom
- 1.11.0
+ 1.12.0
../common
diff --git a/server/pom.xml b/server/pom.xml
index 873084e8..69969b61 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -8,7 +8,7 @@
ctbrec
master
- 1.11.0
+ 1.12.0
../master
diff --git a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java
index ad6f81dd..c213ce2f 100644
--- a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java
+++ b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java
@@ -137,9 +137,13 @@ public class RecorderServlet extends AbstractCtbrecServlet {
response = "{\"status\": \"success\", \"msg\": \"Recording resumed\"}";
resp.getWriter().write(response);
break;
+ case "space":
+ response = "{\"status\": \"success\", \"spaceTotal\": "+recorder.getTotalSpaceBytes()+", \"spaceFree\": "+recorder.getFreeSpaceBytes()+"}";
+ resp.getWriter().write(response);
+ break;
default:
resp.setStatus(SC_BAD_REQUEST);
- response = "{\"status\": \"error\", \"msg\": \"Unknown action\"}";
+ response = "{\"status\": \"error\", \"msg\": \"Unknown action ["+request.action+"]\"}";
resp.getWriter().write(response);
break;
}