forked from j62/ctbrec
1
0
Fork 0

Merge branch 'dev'

This commit is contained in:
0xboobface 2018-11-28 17:53:02 +01:00
commit a45ba8f35e
24 changed files with 641 additions and 244 deletions

View File

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

2
client/.gitignore vendored
View File

@ -2,7 +2,7 @@
/target/
*~
*.bak
/ctbrec.log
/*.log
/ctbrec-tunnel.sh
/jre/
/server-local.sh

View File

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

View File

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

View File

@ -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<Site> sites;
private long spaceTotal = -1;
private long spaceFree = -1;
FlowPane grid = new FlowPane();
ScrollPane scrollPane = new ScrollPane();
TableView<JavaFxRecording> table = new TableView<JavaFxRecording>();
ObservableList<JavaFxRecording> observableRecordings = FXCollections.observableArrayList();
ContextMenu popup;
ProgressBar spaceLeft;
Label spaceLabel;
public RecordingsTab(String title, Recorder recorder, Config config, List<Site> sites) {
super(title);
@ -136,9 +147,37 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
TableColumn<JavaFxRecording, String> progress = new TableColumn<>("Progress");
progress.setCellValueFactory((cdf) -> cdf.getValue().getProgressProperty());
progress.setPrefWidth(100);
TableColumn<JavaFxRecording, String> size = new TableColumn<>("Size");
size.setCellValueFactory((cdf) -> cdf.getValue().getSizeProperty());
TableColumn<JavaFxRecording, Number> size = new TableColumn<>("Size");
size.setStyle("-fx-alignment: CENTER-RIGHT;");
size.setPrefWidth(100);
size.setCellValueFactory(cdf -> cdf.getValue().getSizeProperty());
size.setCellFactory(new Callback<TableColumn<JavaFxRecording, Number>, TableCell<JavaFxRecording, Number>>() {
@Override
public TableCell<JavaFxRecording, Number> call(TableColumn<JavaFxRecording, Number> param) {
TableCell<JavaFxRecording, Number> cell = new TableCell<JavaFxRecording, Number>() {
@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,6 +243,33 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
updateService = createUpdateService();
updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2)));
updateService.setOnSucceeded((event) -> {
updateRecordingsTable();
updateFreeSpaceDisplay();
});
updateService.setOnFailed((event) -> {
LOG.info("Couldn't get list of recordings from recorder", event.getSource().getException());
AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR);
autosizeAlert.setTitle("Whoopsie!");
autosizeAlert.setHeaderText("Recordings not available");
autosizeAlert.setContentText("An error occured while retrieving the list of recordings");
autosizeAlert.showAndWait();
});
}
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<JavaFxRecording> recordings = updateService.getValue();
if (recordings == null) {
return;
@ -215,15 +294,6 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
}
}
table.sort();
});
updateService.setOnFailed((event) -> {
LOG.info("Couldn't get list of recordings from recorder", event.getSource().getException());
AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR);
autosizeAlert.setTitle("Whoopsie!");
autosizeAlert.setHeaderText("Recordings not available");
autosizeAlert.setContentText("An error occured while retrieving the list of recordings");
autosizeAlert.showAndWait();
});
}
private ScheduledService<List<JavaFxRecording>> createUpdateService() {
@ -233,12 +303,23 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
return new Task<List<JavaFxRecording>>() {
@Override
public List<JavaFxRecording> call() throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
updateSpace();
List<JavaFxRecording> 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);
}
}
};
}
};

View File

@ -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<Integer> 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<SplitAfterOption> 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<Integer> 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<SplitAfterOption> 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,10 +685,11 @@ public class SettingsTab extends Tab implements TabSelectionListener {
@Override
public void selected() {
startTab.getItems().clear();
if(startTab.getItems().isEmpty()) {
for(Tab tab : getTabPane().getTabs()) {
startTab.getItems().add(tab.getText());
}
}
String startTabName = Config.getInstance().getSettings().startTab;
if(StringUtil.isNotBlank(startTabName)) {
startTab.getSelectionModel().select(startTabName);

View File

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

View File

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

View File

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

View File

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

View File

@ -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<File> 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<Model> restart = new ArrayList<>();
for (Iterator<Entry<Model, Download>> iterator = recordingProcesses.entrySet().iterator(); iterator.hasNext();) {
Entry<Model, Download> 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<Recording> 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;
}
}

View File

@ -42,4 +42,18 @@ public interface Recorder {
public List<Model> 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;
}

View File

@ -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<Model> models = Collections.emptyList();
private List<Model> onlineModels = Collections.emptyList();
private List<Site> 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;
}
}

View File

@ -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<Runnable> 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;
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) {
try {
streamer.stop();
} catch(Exception e) {
LOG.error("Couldn't stop streamer", e);
}
}
LOG.debug("Download for {} terminated", model);
}
@ -138,75 +161,112 @@ 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");
} catch(Exception e) {
LOG.info("Unexpected error while downloading {}", model.getName(), e);
running = false;
} else {
throw e;
}
}
}
}
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 {
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<Future<byte[]>> 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<byte[]> 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<Future<byte[]>> downloads) throws ExecutionException, HttpException {
for (Future<byte[]> downloadFuture : downloads) {
try {
byte[] segmentData = new SegmentDownload(segmentUrl, client).call();
byte[] segmentData = downloadFuture.get();
writeSegment(segmentData);
} catch (Exception e) {
LOG.error("Error while downloading segment {}", segmentUrl, e);
} 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;
}
}
}
@ -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;
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<byte[]> {
private class SegmentDownload implements Callable<byte[]> {
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 {
for (int i = 1; i <= maxTries && running; i++) {
Request request = new Request.Builder().url(url).addHeader("connection", "keep-alive").build();
Response response = client.execute(request);
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;
}
}
}

View File

@ -0,0 +1,11 @@
package ctbrec.recorder.download;
import java.io.IOException;
public class MissingSegmentException extends IOException {
public MissingSegmentException(String msg) {
super(msg);
}
}

View File

@ -54,7 +54,7 @@ public class StreamSource implements Comparable<StreamSource> {
@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;

View File

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

View File

@ -219,8 +219,16 @@ public class Chaturbate extends AbstractSite {
}
StreamInfo getStreamInfo(String modelName) throws IOException, ExecutionException {
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 {
throttleRequests();

View File

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

View File

@ -221,7 +221,7 @@ public class MyFreeCamsModel extends AbstractModel {
setName(state.getNm());
setState(State.of(state.getVs()));
setStreamUrl(streamUrl);
Optional<Double> camScore = Optional.of(state.getM()).map(m -> m.getCamscore());
Optional<Double> camScore = Optional.ofNullable(state.getM()).map(m -> m.getCamscore());
setCamScore(camScore.orElse(0.0));
// preview

View File

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

View File

@ -6,7 +6,7 @@
<groupId>ctbrec</groupId>
<artifactId>master</artifactId>
<packaging>pom</packaging>
<version>1.11.0</version>
<version>1.12.0</version>
<modules>
<module>../common</module>

View File

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

View File

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