diff --git a/CHANGELOG.md b/CHANGELOG.md index b791eb9b..1b160520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +1.12.2 +======================== +* Fix: Player not starting when path contains spaces +* Added setting to toggle "Player Starting" message +* Added possibility to add models by their URL +* Added pause / resume all buttons +* Implemented multi-selection for Recording and Recordings tab +* Fix: Don't do space check, if minimum is set to 0 + 1.12.1 ======================== * Fixed downloads in client / server mode diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 7e59df7e..2b06e7d1 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -288,9 +288,8 @@ public class CamrecApplication extends Application { LOG.error("Couldn't load settings", e); Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); alert.setTitle("Whoopsie"); - alert.setContentText("Couldn't load settings."); + alert.setContentText("Couldn't load settings. Falling back to defaults. A backup of your settings has been created."); alert.showAndWait(); - System.exit(1); } config = Config.getInstance(); } diff --git a/client/src/main/java/ctbrec/ui/JavaFxModel.java b/client/src/main/java/ctbrec/ui/JavaFxModel.java index fccdd9e3..99c4fcb6 100644 --- a/client/src/main/java/ctbrec/ui/JavaFxModel.java +++ b/client/src/main/java/ctbrec/ui/JavaFxModel.java @@ -197,4 +197,14 @@ public class JavaFxModel implements Model { delegate.setSuspended(suspended); pausedProperty.set(suspended); } + + @Override + public String getDisplayName() { + return delegate.getDisplayName(); + } + + @Override + public void setDisplayName(String name) { + delegate.setDisplayName(name); + } } diff --git a/client/src/main/java/ctbrec/ui/Player.java b/client/src/main/java/ctbrec/ui/Player.java index f9c0d9fd..bace7e78 100644 --- a/client/src/main/java/ctbrec/ui/Player.java +++ b/client/src/main/java/ctbrec/ui/Player.java @@ -120,7 +120,11 @@ public class Player { try { if (Config.getInstance().getSettings().localRecording && rec != null) { File file = new File(Config.getInstance().getSettings().recordingsDir, rec.getPath()); - playerProcess = rt.exec(Config.getInstance().getSettings().mediaPlayer + " " + file, OS.getEnvironment(), file.getParentFile()); + String[] args = new String[] { + Config.getInstance().getSettings().mediaPlayer, + file.getName() + }; + playerProcess = rt.exec(args, OS.getEnvironment(), file.getParentFile()); } else { if(Config.getInstance().getSettings().requireAuthentication) { URL u = new URL(url); @@ -136,10 +140,12 @@ public class Player { // create threads, which read stdout and stderr of the player process. these are needed, // because otherwise the internal buffer for these streams fill up and block the process Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), new DevNull())); + //Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), System.out)); std.setName("Player stdout pipe"); std.setDaemon(true); std.start(); Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), new DevNull())); + //Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), System.err)); err.setName("Player stderr pipe"); err.setDaemon(true); err.start(); diff --git a/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java b/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java new file mode 100644 index 00000000..2305a73a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java @@ -0,0 +1,332 @@ +package ctbrec.ui; + +import java.io.InterruptedIOException; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.io.HttpException; +import ctbrec.recorder.download.StreamSource; +import javafx.application.Platform; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableRow; +import javafx.scene.control.TableView; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.media.Media; +import javafx.scene.media.MediaPlayer; +import javafx.scene.media.MediaView; +import javafx.stage.Popup; + +public class PreviewPopupHandler implements EventHandler { + private static final transient Logger LOG = LoggerFactory.getLogger(PreviewPopupHandler.class); + + private static final int offset = 10; + private long timeForPopupOpen = TimeUnit.SECONDS.toMillis(1); + private long timeForPopupClose = 400; + private Popup popup = new Popup(); + private Node parent; + private ImageView preview = new ImageView(); + private MediaView videoPreview; + private MediaPlayer videoPlayer; + private Media video; + private JavaFxModel model; + private volatile long openCountdown = -1; + private volatile long closeCountdown = -1; + private volatile long lastModelChange = -1; + private volatile boolean changeModel = false; + private ExecutorService executor = Executors.newSingleThreadExecutor(); + private Future future; + private ProgressIndicator progressIndicator; + private StackPane pane; + + public PreviewPopupHandler(Node parent) { + this.parent = parent; + + videoPreview = new MediaView(); + videoPreview.setFitWidth(Config.getInstance().getSettings().thumbWidth); + videoPreview.setFitHeight(videoPreview.getFitWidth() * 9 / 16); + videoPreview.setPreserveRatio(true); + StackPane.setMargin(videoPreview, new Insets(5)); + + preview.setFitWidth(Config.getInstance().getSettings().thumbWidth); + preview.setPreserveRatio(true); + preview.setSmooth(true); + preview.setStyle("-fx-background-radius: 10px, 10px, 10px, 10px;"); + preview.visibleProperty().bind(videoPreview.visibleProperty().not()); + StackPane.setMargin(preview, new Insets(5)); + + progressIndicator = new ProgressIndicator(); + progressIndicator.setVisible(false); + progressIndicator.prefWidthProperty().bind(videoPreview.fitWidthProperty()); + + Region veil = new Region(); + veil.setStyle("-fx-background-color: rgba(0, 0, 0, 0.8)"); + veil.visibleProperty().bind(progressIndicator.visibleProperty()); + StackPane.setMargin(veil, new Insets(5)); + + pane = new StackPane(); + pane.getChildren().addAll(preview, videoPreview, veil, progressIndicator); + pane.setStyle("-fx-background-color: -fx-outer-border, -fx-inner-border, -fx-base;"+ + "-fx-background-insets: 0 0 -1 0, 0, 1, 2;" + + "-fx-background-radius: 10px, 10px, 10px, 10px;" + + "-fx-padding: 1;" + + "-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.8), 20, 0, 0, 0);"); + popup.getContent().add(pane); + + createTimerThread(); + } + + @Override + public void handle(MouseEvent event) { + if(!isInPreviewColumn(event)) { + closeCountdown = timeForPopupClose; + return; + } + + if(event.getEventType() == MouseEvent.MOUSE_CLICKED && event.getButton() == MouseButton.PRIMARY) { + model = getModel(event); + popup.setX(event.getScreenX()+ offset); + popup.setY(event.getScreenY()+ offset); + showPopup(); + openCountdown = -1; + } else if(event.getEventType() == MouseEvent.MOUSE_ENTERED) { + popup.setX(event.getScreenX()+ offset); + popup.setY(event.getScreenY()+ offset); + JavaFxModel model = getModel(event); + if(model != null) { + closeCountdown = -1; + boolean modelChanged = model != this.model; + this.model = model; + if(popup.isShowing()) { + openCountdown = -1; + if(modelChanged) { + lastModelChange = System.currentTimeMillis(); + changeModel = true; + future.cancel(true); + progressIndicator.setVisible(true); + } + } else { + openCountdown = timeForPopupOpen; + } + } + } else if(event.getEventType() == MouseEvent.MOUSE_EXITED) { + openCountdown = -1; + closeCountdown = timeForPopupClose; + model = null; + } else if(event.getEventType() == MouseEvent.MOUSE_MOVED) { + popup.setX(event.getScreenX() + offset); + popup.setY(event.getScreenY() + offset); + } + } + + private boolean isInPreviewColumn(MouseEvent event) { + @SuppressWarnings("unchecked") + TableRow row = (TableRow) event.getSource(); + TableView table = row.getTableView(); + double offset = 0; + double width = 0; + for (TableColumn col : table.getColumns()) { + offset += width; + width = col.getWidth(); + if(Objects.equals(col.getId(), "preview")) { + Point2D screenToLocal = table.screenToLocal(event.getScreenX(), event.getScreenY()); + double x = screenToLocal.getX(); + return x >= offset && x <= offset + width; + } + } + return false; + } + + private JavaFxModel getModel(MouseEvent event) { + @SuppressWarnings("unchecked") + TableRow row = (TableRow) event.getSource(); + TableView table = row.getTableView(); + int rowIndex = row.getIndex(); + if(rowIndex < table.getItems().size()) { + return table.getItems().get(rowIndex); + } else { + return null; + } + } + + private void showPopup() { + startStream(model); + } + + private void startStream(JavaFxModel model) { + if(future != null && !future.isDone()) { + future.cancel(true); + } + future = executor.submit(() -> { + try { + Platform.runLater(() -> { + progressIndicator.setVisible(true); + popup.show(parent.getScene().getWindow()); + }); + List sources = model.getStreamSources(); + Collections.sort(sources); + StreamSource best = sources.get(0); + checkInterrupt(); + LOG.debug("Preview url for {} is {}", model.getName(), best.getMediaPlaylistUrl()); + video = new Media(best.getMediaPlaylistUrl()); + if(videoPlayer != null) { + videoPlayer.dispose(); + } + videoPlayer = new MediaPlayer(video); + videoPlayer.setMute(true); + checkInterrupt(); + videoPlayer.setOnReady(() -> { + if(!future.isCancelled()) { + Platform.runLater(() -> { + double aspect = (double)video.getWidth() / video.getHeight(); + double w = Config.getInstance().getSettings().thumbWidth; + double h = w / aspect; + resize(w, h); + progressIndicator.setVisible(false); + videoPreview.setVisible(true); + videoPreview.setMediaPlayer(videoPlayer); + videoPlayer.play(); + }); + } + }); + videoPlayer.setOnError(() -> onError(videoPlayer)); + } catch (IllegalStateException e) { + if(e.getMessage().equals("Stream url unknown")) { + // fine hls url for mfc not known yet + } else { + LOG.warn("Couldn't start preview video: {}", e.getMessage()); + } + showTestImage(); + } catch (HttpException e) { + if(e.getResponseCode() != 404) { + LOG.warn("Couldn't start preview video: {}", e.getMessage()); + } + showTestImage(); + } catch (InterruptedException | InterruptedIOException e) { + // future has been canceled, that's fine + } catch (ExecutionException e) { + if(e.getCause() instanceof InterruptedException || e.getCause() instanceof InterruptedIOException) { + // future has been canceled, that's fine + } else { + LOG.warn("Couldn't start preview video: {}", e.getMessage()); + showTestImage(); + } + } catch (Exception e) { + LOG.warn("Couldn't start preview video: {}", e.getMessage()); + showTestImage(); + } + }); + } + + private void onError(MediaPlayer videoPlayer) { + LOG.error("Error while starting preview stream", videoPlayer.getError()); + if(videoPlayer.getError().getCause() != null) { + LOG.error("Error while starting preview stream root cause:", videoPlayer.getError().getCause()); + } + Platform.runLater(() -> { + showTestImage(); + }); + } + + private void resize(double w, double h) { + preview.setFitWidth(w); + preview.setFitHeight(h); + videoPreview.setFitWidth(w); + videoPreview.setFitHeight(h); + pane.setPrefSize(w, h); + popup.setWidth(w); + popup.setHeight(h); + } + + private void checkInterrupt() throws InterruptedException { + if(Thread.interrupted()) { + throw new InterruptedException(); + } + } + + private void showTestImage() { + Platform.runLater(() -> { + videoPreview.setVisible(false); + Image img = new Image(getClass().getResource("/image_not_found.png").toString(), true); + preview.setImage(img); + double aspect = img.getWidth() / img.getHeight(); + double w = Config.getInstance().getSettings().thumbWidth; + double h = w / aspect; + resize(w, h); + progressIndicator.setVisible(false); + }); + } + + private void hidePopup() { + Platform.runLater(() -> { + popup.setX(-1000); + popup.setY(-1000); + popup.hide(); + if(videoPlayer != null) { + videoPlayer.dispose(); + } + }); + } + + private void createTimerThread() { + Thread timerThread = new Thread(() -> { + while(true) { + openCountdown--; + if(openCountdown == 0) { + openCountdown = -1; + if(model != null) { + showPopup(); + } + } + + closeCountdown--; + if(closeCountdown == 0) { + hidePopup(); + closeCountdown = -1; + } + + openCountdown = Math.max(openCountdown, -1); + closeCountdown = Math.max(closeCountdown, -1); + + long now = System.currentTimeMillis(); + long diff = (now - lastModelChange); + if(changeModel && diff > 400) { + changeModel = false; + if(model != null) { + startStream(model); + } + } + + try { + Thread.sleep(1); + } catch (InterruptedException e) { + LOG.error("PreviewPopupTimer interrupted"); + break; + } + } + }); + timerThread.setDaemon(true); + timerThread.setPriority(Thread.MIN_PRIORITY); + timerThread.setName("PreviewPopupTimer"); + timerThread.start(); + } +} diff --git a/client/src/main/java/ctbrec/ui/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/RecordedModelsTab.java index 2fc735fd..f636b424 100644 --- a/client/src/main/java/ctbrec/ui/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/RecordedModelsTab.java @@ -3,6 +3,7 @@ package ctbrec.ui; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Objects; @@ -14,6 +15,7 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; @@ -29,6 +31,7 @@ import ctbrec.sites.Site; import ctbrec.ui.controls.AutoFillTextField; import ctbrec.ui.controls.Toast; import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.concurrent.ScheduledService; @@ -42,9 +45,11 @@ import javafx.scene.control.ContextMenu; import javafx.scene.control.Label; import javafx.scene.control.MenuItem; import javafx.scene.control.ScrollPane; +import javafx.scene.control.SelectionMode; import javafx.scene.control.Tab; import javafx.scene.control.TableColumn; import javafx.scene.control.TableColumn.SortType; +import javafx.scene.control.TableRow; import javafx.scene.control.TableView; import javafx.scene.control.Tooltip; import javafx.scene.control.cell.CheckBoxTableCell; @@ -79,6 +84,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { Label modelLabel = new Label("Model"); AutoFillTextField model; Button addModelButton = new Button("Record"); + Button pauseAll = new Button("Pause All"); + Button resumeAll = new Button("Resume All"); public RecordedModelsTab(String title, Recorder recorder, List sites) { super(title); @@ -100,54 +107,68 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { scrollPane.setFitToWidth(true); BorderPane.setMargin(scrollPane, new Insets(5)); - table.setEditable(false); + + table.setEditable(true); + table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + PreviewPopupHandler previewPopupHandler = new PreviewPopupHandler(table); + table.setRowFactory((tableview) -> { + TableRow row = new TableRow<>(); + row.addEventHandler(MouseEvent.ANY, previewPopupHandler); + return row; + }); + TableColumn preview = new TableColumn<>("🎥"); + preview.setPrefWidth(35); + preview.setCellValueFactory(cdf -> new SimpleStringProperty(" ▶ ")); + preview.setEditable(false); + preview.setId("preview"); TableColumn name = new TableColumn<>("Model"); name.setPrefWidth(200); - name.setCellValueFactory(new PropertyValueFactory("name")); + name.setCellValueFactory(new PropertyValueFactory("displayName")); + name.setEditable(false); TableColumn url = new TableColumn<>("URL"); url.setCellValueFactory(new PropertyValueFactory("url")); url.setPrefWidth(400); + url.setEditable(false); TableColumn online = new TableColumn<>("Online"); - online.setCellValueFactory((cdf) -> cdf.getValue().getOnlineProperty()); + online.setCellValueFactory(cdf -> cdf.getValue().getOnlineProperty()); online.setCellFactory(CheckBoxTableCell.forTableColumn(online)); online.setPrefWidth(100); + online.setEditable(false); TableColumn recording = new TableColumn<>("Recording"); - recording.setCellValueFactory((cdf) -> cdf.getValue().getRecordingProperty()); + recording.setCellValueFactory(cdf -> cdf.getValue().getRecordingProperty()); recording.setCellFactory(CheckBoxTableCell.forTableColumn(recording)); recording.setPrefWidth(100); + recording.setEditable(false); TableColumn paused = new TableColumn<>("Paused"); - paused.setCellValueFactory((cdf) -> cdf.getValue().getPausedProperty()); + paused.setCellValueFactory(cdf -> cdf.getValue().getPausedProperty()); paused.setCellFactory(CheckBoxTableCell.forTableColumn(paused)); paused.setPrefWidth(100); - table.getColumns().addAll(name, url, online, recording, paused); + paused.setEditable(true); + table.getColumns().addAll(preview, name, url, online, recording, paused); table.setItems(observableModels); table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { popup = createContextMenu(); - if(popup != null) { + if (popup != null) { popup.show(table, event.getScreenX(), event.getScreenY()); } event.consume(); }); table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { - if(popup != null) { + if (popup != null) { popup.hide(); } }); - table.addEventFilter(KeyEvent.KEY_PRESSED, event -> { - if(event.getCode() == KeyCode.DELETE) { - stopAction(); + table.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + List selectedModels = table.getSelectionModel().getSelectedItems(); + if (event.getCode() == KeyCode.DELETE) { + stopAction(selectedModels); + } else if (event.getCode() == KeyCode.P) { + List pausedModels = selectedModels.stream().filter(m -> m.isSuspended()).collect(Collectors.toList()); + List runningModels = selectedModels.stream().filter(m -> !m.isSuspended()).collect(Collectors.toList()); + resumeRecording(pausedModels); + pauseRecording(runningModels); } }); - table.addEventFilter(KeyEvent.KEY_PRESSED, event -> { - if(event.getCode() == KeyCode.S) { - for (TableColumn col : table.getSortOrder()) { - System.out.println(col.getText()); - System.out.println(col.getSortType()); - System.out.println(col.getComparator()); - } - } - }); - scrollPane.setContent(table); HBox addModelBox = new HBox(5); @@ -155,14 +176,17 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { ObservableList suggestions = FXCollections.observableArrayList(); sites.forEach(site -> suggestions.add(site.getName())); model = new AutoFillTextField(suggestions); - model.setPrefWidth(300); - model.setPromptText("e.g. MyFreeCams:ModelName"); - model.onActionHandler(e -> addModel(e)); + model.setPrefWidth(600); + model.setPromptText("e.g. MyFreeCams:ModelName or an URL like https://chaturbate.com/modelname/"); + model.onActionHandler(this::addModel); model.setTooltip(new Tooltip("To add a model enter SiteName:ModelName\n" + "press ENTER to confirm a suggested site name")); BorderPane.setMargin(addModelBox, new Insets(5)); - addModelButton.setOnAction((e) -> addModel(e)); - addModelBox.getChildren().addAll(modelLabel, model, addModelButton); + addModelButton.setOnAction(this::addModel); + addModelBox.getChildren().addAll(modelLabel, model, addModelButton, pauseAll, resumeAll); + HBox.setMargin(pauseAll, new Insets(0, 0, 0, 20)); + pauseAll.setOnAction(this::pauseAll); + resumeAll.setOnAction(this::resumeAll); BorderPane root = new BorderPane(); root.setPadding(new Insets(5)); @@ -174,6 +198,43 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } private void addModel(ActionEvent e) { + String input = model.getText(); + if (StringUtil.isBlank(input)) { + return; + } + + if (input.startsWith("http")) { + addModelByUrl(input); + } else { + addModelByName(input); + } + }; + + private void addModelByUrl(String url) { + for (Site site : sites) { + Model model = site.createModelFromUrl(url); + if (model != null) { + try { + recorder.startRecording(model); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Couldn't add model"); + alert.setContentText("The model " + model.getName() + " could not be added: " + e1.getLocalizedMessage()); + alert.showAndWait(); + } + return; + } + } + + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle("Unknown URL format"); + alert.setHeaderText("Couldn't add model"); + alert.setContentText("The URL you entered has an unknown format or the function does not support this site, yet"); + alert.showAndWait(); + } + + private void addModelByName(String siteModelCombo) { String[] parts = model.getText().trim().split(":"); if (parts.length != 2) { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); @@ -207,15 +268,50 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { alert.setHeaderText("Couldn't add model"); alert.setContentText("The site you entered is unknown"); alert.showAndWait(); - }; + } + private void pauseAll(ActionEvent evt) { + List models = recorder.getModelsRecording(); + Consumer action = (m) -> { + try { + recorder.suspendRecording(m); + } catch(Exception e) { + Platform.runLater(() -> + showErrorDialog(e, "Couldn't suspend recording of model", "Suspending recording of " + m.getName() + " failed")); + } + }; + massEdit(models, action); + } + + private void resumeAll(ActionEvent evt) { + List models = recorder.getModelsRecording(); + Consumer action = (m) -> { + try { + recorder.resumeRecording(m); + } catch(Exception e) { + Platform.runLater(() -> + showErrorDialog(e, "Couldn't resume recording of model", "Resuming recording of " + m.getName() + " failed")); + } + }; + massEdit(models, action); + } + + private void massEdit(List models, Consumer action) { + getTabPane().setCursor(Cursor.WAIT); + threadPool.submit(() -> { + for (Model model : models) { + action.accept(model); + } + Platform.runLater(() -> getTabPane().setCursor(Cursor.DEFAULT)); + }); + } void initializeUpdateService() { updateService = createUpdateService(); updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2))); updateService.setOnSucceeded((event) -> { List models = updateService.getValue(); - if(models == null) { + if (models == null) { return; } @@ -223,6 +319,17 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { int index = observableModels.indexOf(updatedModel); if (index == -1) { observableModels.add(updatedModel); + updatedModel.getPausedProperty().addListener((obs, oldV, newV) -> { + if (newV) { + if(!recorder.isSuspended(updatedModel)) { + pauseRecording(Collections.singletonList(updatedModel)); + } + } else { + if(recorder.isSuspended(updatedModel)) { + resumeRecording(Collections.singletonList(updatedModel)); + } + } + }); } else { // make sure to update the JavaFX online property, so that the table cell is updated JavaFxModel oldModel = observableModels.get(index); @@ -310,16 +417,16 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } private ContextMenu createContextMenu() { - JavaFxModel selectedModel = table.getSelectionModel().getSelectedItem(); - if(selectedModel == null) { + ObservableList selectedModels = table.getSelectionModel().getSelectedItems(); + if (selectedModels.isEmpty()) { return null; } MenuItem stop = new MenuItem("Remove Model"); - stop.setOnAction((e) -> stopAction()); + stop.setOnAction((e) -> stopAction(selectedModels)); MenuItem copyUrl = new MenuItem("Copy URL"); copyUrl.setOnAction((e) -> { - Model selected = selectedModel; + Model selected = selectedModels.get(0); final Clipboard clipboard = Clipboard.getSystemClipboard(); final ClipboardContent content = new ClipboardContent(); content.putString(selected.getUrl()); @@ -327,19 +434,31 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { }); MenuItem pauseRecording = new MenuItem("Pause Recording"); - pauseRecording.setOnAction((e) -> pauseRecording()); + pauseRecording.setOnAction((e) -> pauseRecording(selectedModels)); MenuItem resumeRecording = new MenuItem("Resume Recording"); - resumeRecording.setOnAction((e) -> resumeRecording()); + resumeRecording.setOnAction((e) -> resumeRecording(selectedModels)); MenuItem openInBrowser = new MenuItem("Open in Browser"); - openInBrowser.setOnAction((e) -> DesktopIntegration.open(selectedModel.getUrl())); + openInBrowser.setOnAction((e) -> DesktopIntegration.open(selectedModels.get(0).getUrl())); MenuItem openInPlayer = new MenuItem("Open in Player"); - openInPlayer.setOnAction((e) -> openInPlayer(selectedModel)); + openInPlayer.setOnAction((e) -> openInPlayer(selectedModels.get(0))); MenuItem switchStreamSource = new MenuItem("Switch resolution"); - switchStreamSource.setOnAction((e) -> switchStreamSource(selectedModel)); + switchStreamSource.setOnAction((e) -> switchStreamSource(selectedModels.get(0))); ContextMenu menu = new ContextMenu(stop); - menu.getItems().add(selectedModel.isSuspended() ? resumeRecording : pauseRecording); + if (selectedModels.size() == 1) { + menu.getItems().add(selectedModels.get(0).isSuspended() ? resumeRecording : pauseRecording); + } else { + menu.getItems().addAll(resumeRecording, pauseRecording); + } menu.getItems().addAll(copyUrl, openInPlayer, openInBrowser, switchStreamSource); + + if (selectedModels.size() > 1) { + copyUrl.setDisable(true); + openInPlayer.setDisable(true); + openInBrowser.setDisable(true); + switchStreamSource.setDisable(true); + } + return menu; } @@ -348,7 +467,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { new Thread(() -> { boolean started = Player.play(selectedModel); Platform.runLater(() -> { - if(started) { + if (started && Config.getInstance().getSettings().showPlayerStarting) { Toast.makeText(getTabPane().getScene(), "Starting Player", 2000, 500, 500); } table.setCursor(Cursor.DEFAULT); @@ -358,7 +477,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { private void switchStreamSource(JavaFxModel fxModel) { try { - if(!fxModel.isOnline()) { + if (!fxModel.isOnline()) { Alert alert = new AutosizeAlert(Alert.AlertType.INFORMATION); alert.setTitle("Switch resolution"); alert.setHeaderText("Couldn't switch stream resolution"); @@ -393,98 +512,61 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } private void showStreamSwitchErrorDialog(Throwable throwable) { + showErrorDialog(throwable, "Couldn't switch stream resolution", "Error while switching stream resolution"); + } + + private void showErrorDialog(Throwable throwable, String header, String msg) { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); alert.setTitle("Error"); - alert.setHeaderText("Couldn't switch stream resolution"); - alert.setContentText("Error while switching stream resolution: " + throwable.getLocalizedMessage()); + alert.setHeaderText(header); + alert.setContentText(msg + ": " + throwable.getLocalizedMessage()); alert.showAndWait(); } - private void stopAction() { - Model selected = table.getSelectionModel().getSelectedItem().getDelegate(); - if (selected != null) { - table.setCursor(Cursor.WAIT); - new Thread() { - @Override - public void run() { - try { - recorder.stopRecording(selected); - observableModels.remove(selected); - } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { - LOG.error("Couldn't stop recording", e1); - Platform.runLater(() -> { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); - alert.setTitle("Error"); - alert.setHeaderText("Couldn't stop recording"); - alert.setContentText("Error while stopping the recording: " + e1.getLocalizedMessage()); - alert.showAndWait(); - }); - } finally { - table.setCursor(Cursor.DEFAULT); - } - } - }.start(); - } + private void stopAction(List selectedModels) { + Consumer action = (m) -> { + try { + recorder.stopRecording(m); + observableModels.remove(m); + } catch(Exception e) { + Platform.runLater(() -> + showErrorDialog(e, "Couldn't stop recording", "Stopping recording of " + m.getName() + " failed")); + } + }; + List models = selectedModels.stream().map(jfxm -> jfxm.getDelegate()).collect(Collectors.toList()); + massEdit(models, action); }; - private void pauseRecording() { - JavaFxModel model = table.getSelectionModel().getSelectedItem(); - Model delegate = table.getSelectionModel().getSelectedItem().getDelegate(); - if (delegate != null) { - table.setCursor(Cursor.WAIT); - new Thread() { - @Override - public void run() { - try { - recorder.suspendRecording(delegate); - Platform.runLater(() -> model.setSuspended(true)); - } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { - LOG.error("Couldn't pause recording", e1); - Platform.runLater(() -> { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); - alert.setTitle("Error"); - alert.setHeaderText("Couldn't pause recording"); - alert.setContentText("Error while pausing the recording: " + e1.getLocalizedMessage()); - alert.showAndWait(); - }); - } finally { - table.setCursor(Cursor.DEFAULT); - } - } - }.start(); - } + private void pauseRecording(List selectedModels) { + Consumer action = (m) -> { + try { + recorder.suspendRecording(m); + m.setSuspended(true); + } catch(Exception e) { + Platform.runLater(() -> + showErrorDialog(e, "Couldn't pause recording of model", "Pausing recording of " + m.getName() + " failed")); + } + }; + List models = selectedModels.stream().map(jfxm -> jfxm.getDelegate()).collect(Collectors.toList()); + massEdit(models, action); }; - private void resumeRecording() { - JavaFxModel model = table.getSelectionModel().getSelectedItem(); - Model delegate = table.getSelectionModel().getSelectedItem().getDelegate(); - if (delegate != null) { - table.setCursor(Cursor.WAIT); - new Thread() { - @Override - public void run() { - try { - recorder.resumeRecording(delegate); - Platform.runLater(() -> model.setSuspended(false)); - } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { - LOG.error("Couldn't resume recording", e1); - Platform.runLater(() -> { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); - alert.setTitle("Error"); - alert.setHeaderText("Couldn't resume recording"); - alert.setContentText("Error while resuming the recording: " + e1.getLocalizedMessage()); - alert.showAndWait(); - }); - } finally { - table.setCursor(Cursor.DEFAULT); - } - } - }.start(); - } + private void resumeRecording(List selectedModels) { + Consumer action = (m) -> { + try { + recorder.resumeRecording(m); + m.setSuspended(false); + } catch(Exception e) { + Platform.runLater(() -> + showErrorDialog(e, "Couldn't resume recording of model", "Resuming recording of " + m.getName() + " failed")); + } + }; + List models = selectedModels.stream().map(jfxm -> jfxm.getDelegate()).collect(Collectors.toList()); + massEdit(models, action); } public void saveState() { - if(!table.getSortOrder().isEmpty()) { + if (!table.getSortOrder().isEmpty()) { TableColumn col = table.getSortOrder().get(0); Config.getInstance().getSettings().recordedModelsSortColumn = col.getText(); Config.getInstance().getSettings().recordedModelsSortType = col.getSortType().toString(); @@ -498,9 +580,9 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { private void restoreState() { String sortCol = Config.getInstance().getSettings().recordedModelsSortColumn; - if(StringUtil.isNotBlank(sortCol)) { + if (StringUtil.isNotBlank(sortCol)) { for (TableColumn col : table.getColumns()) { - if(Objects.equals(sortCol, col.getText())) { + if (Objects.equals(sortCol, col.getText())) { col.setSortType(SortType.valueOf(Config.getInstance().getSettings().recordedModelsSortType)); table.getSortOrder().clear(); table.getSortOrder().add(col); @@ -510,7 +592,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } double[] columnWidths = Config.getInstance().getSettings().recordedModelsColumnWidths; - if(columnWidths != null && columnWidths.length == table.getColumns().size()) { + if (columnWidths != null && columnWidths.length == table.getColumns().size()) { for (int i = 0; i < columnWidths.length; i++) { table.getColumns().get(i).setPrefWidth(columnWidths[i]); } diff --git a/client/src/main/java/ctbrec/ui/RecordingsTab.java b/client/src/main/java/ctbrec/ui/RecordingsTab.java index f8fef9dd..da238bc3 100644 --- a/client/src/main/java/ctbrec/ui/RecordingsTab.java +++ b/client/src/main/java/ctbrec/ui/RecordingsTab.java @@ -6,6 +6,7 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URL; +import java.nio.file.NoSuchFileException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.text.DecimalFormat; @@ -22,6 +23,8 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,6 +55,7 @@ import javafx.scene.control.Label; import javafx.scene.control.MenuItem; import javafx.scene.control.ProgressBar; import javafx.scene.control.ScrollPane; +import javafx.scene.control.SelectionMode; import javafx.scene.control.Tab; import javafx.scene.control.TableCell; import javafx.scene.control.TableColumn; @@ -91,6 +95,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { ContextMenu popup; ProgressBar spaceLeft; Label spaceLabel; + Lock recordingsLock = new ReentrantLock(); public RecordingsTab(String title, Recorder recorder, Config config, List sites) { super(title); @@ -114,6 +119,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { BorderPane.setMargin(scrollPane, new Insets(5)); table.setEditable(false); + table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); TableColumn name = new TableColumn<>("Model"); name.setPrefWidth(200); name.setCellValueFactory(new PropertyValueFactory("modelName")); @@ -162,14 +168,12 @@ public class RecordingsTab extends Tab implements TabSelectionListener { setStyle(null); } else { setText(StringUtil.formatSize(sizeInByte)); + setStyle("-fx-alignment: CENTER-RIGHT;"); 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); } } } @@ -182,9 +186,9 @@ public class RecordingsTab extends Tab implements TabSelectionListener { table.getColumns().addAll(name, date, status, progress, size); table.setItems(observableRecordings); table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { - Recording recording = table.getSelectionModel().getSelectedItem(); - if(recording != null) { - popup = createContextMenu(recording); + List recordings = table.getSelectionModel().getSelectedItems(); + if(recordings != null && !recordings.isEmpty()) { + popup = createContextMenu(recordings); if(!popup.getItems().isEmpty()) { popup.show(table, event.getScreenX(), event.getScreenY()); } @@ -205,13 +209,15 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } }); table.addEventFilter(KeyEvent.KEY_PRESSED, event -> { - JavaFxRecording recording = table.getSelectionModel().getSelectedItem(); - if (recording != null) { + List recordings = table.getSelectionModel().getSelectedItems(); + if (recordings != null && !recordings.isEmpty()) { if (event.getCode() == KeyCode.DELETE) { - delete(recording); + if(recordings.size() > 1 || recordings.get(0).getStatus() == STATUS.FINISHED) { + delete(recordings); + } } else if (event.getCode() == KeyCode.ENTER) { - if(recording.getStatus() == STATUS.FINISHED) { - play(recording); + if(recordings.get(0).getStatus() == STATUS.FINISHED) { + play(recordings.get(0)); } } } @@ -275,23 +281,28 @@ public class RecordingsTab extends Tab implements TabSelectionListener { return; } - for (Iterator iterator = observableRecordings.iterator(); iterator.hasNext();) { - JavaFxRecording old = iterator.next(); - if (!recordings.contains(old)) { - // remove deleted recordings - iterator.remove(); + recordingsLock.lock(); + try { + 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); + 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); + } } + } finally { + recordingsLock.unlock(); } table.sort(); } @@ -316,6 +327,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener { try { spaceTotal = recorder.getTotalSpaceBytes(); spaceFree = recorder.getFreeSpaceBytes(); + Platform.runLater(() -> spaceLeft.setTooltip(new Tooltip())); + } catch (NoSuchFileException e) { + // recordings dir does not exist + Platform.runLater(() -> spaceLeft.setTooltip(new Tooltip("Recordings directory does not exist"))); } catch (IOException e) { LOG.error("Couldn't update free space", e); } @@ -351,7 +366,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } } - private ContextMenu createContextMenu(Recording recording) { + private ContextMenu createContextMenu(List recordings) { ContextMenu contextMenu = new ContextMenu(); contextMenu.setHideOnEscape(true); contextMenu.setAutoHide(true); @@ -359,9 +374,9 @@ public class RecordingsTab extends Tab implements TabSelectionListener { MenuItem openInPlayer = new MenuItem("Open in Player"); openInPlayer.setOnAction((e) -> { - play(recording); + play(recordings.get(0)); }); - if(recording.getStatus() == STATUS.FINISHED || Config.getInstance().getSettings().localRecording) { + if(recordings.get(0).getStatus() == STATUS.FINISHED || Config.getInstance().getSettings().localRecording) { contextMenu.getItems().add(openInPlayer); } @@ -381,16 +396,16 @@ public class RecordingsTab extends Tab implements TabSelectionListener { MenuItem deleteRecording = new MenuItem("Delete"); deleteRecording.setOnAction((e) -> { - delete(recording); + delete(recordings); }); - if(recording.getStatus() == STATUS.FINISHED) { + if(recordings.get(0).getStatus() == STATUS.FINISHED || recordings.size() > 1) { contextMenu.getItems().add(deleteRecording); } MenuItem openDir = new MenuItem("Open directory"); openDir.setOnAction((e) -> { String recordingsDir = Config.getInstance().getSettings().recordingsDir; - String path = recording.getPath(); + String path = recordings.get(0).getPath(); File tsFile = new File(recordingsDir, path); new Thread(() -> { DesktopIntegration.open(tsFile.getParent()); @@ -403,16 +418,22 @@ public class RecordingsTab extends Tab implements TabSelectionListener { MenuItem downloadRecording = new MenuItem("Download"); downloadRecording.setOnAction((e) -> { try { - download(recording); + download(recordings.get(0)); } catch (IOException | ParseException | PlaylistException e1) { showErrorDialog("Error while downloading recording", "The recording could not be downloaded", e1); LOG.error("Error while downloading recording", e1); } }); - if (!Config.getInstance().getSettings().localRecording && recording.getStatus() == STATUS.FINISHED) { + if (!Config.getInstance().getSettings().localRecording && recordings.get(0).getStatus() == STATUS.FINISHED) { contextMenu.getItems().add(downloadRecording); } + if(recordings.size() > 1) { + openInPlayer.setDisable(true); + openDir.setDisable(true); + downloadRecording.setDisable(true); + } + return contextMenu; } @@ -477,80 +498,6 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } } - // private void download(Recording recording) throws IOException, ParseException, PlaylistException { - // String filename = recording.getPath().replaceAll("/", "-") + ".ts"; - // FileChooser chooser = new FileChooser(); - // chooser.setInitialFileName(filename); - // if(config.getSettings().lastDownloadDir != null && !config.getSettings().lastDownloadDir.equals("")) { - // File dir = new File(config.getSettings().lastDownloadDir); - // while(!dir.exists()) { - // dir = dir.getParentFile(); - // } - // chooser.setInitialDirectory(dir); - // } - // File target = chooser.showSaveDialog(null); - // if(target != null) { - // config.getSettings().lastDownloadDir = target.getParent(); - // String hlsBase = "http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/hls"; - // URL url = new URL(hlsBase + "/" + recording.getPath() + "/playlist.m3u8"); - // LOG.info("Downloading {}", recording.getPath()); - // - // PlaylistParser parser = new PlaylistParser(url.openStream(), Format.EXT_M3U, Encoding.UTF_8); - // Playlist playlist = parser.parse(); - // MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist(); - // List tracks = mediaPlaylist.getTracks(); - // List segmentUris = new ArrayList<>(); - // for (TrackData trackData : tracks) { - // String segmentUri = hlsBase + "/" + recording.getPath() + "/" + trackData.getUri(); - // segmentUris.add(segmentUri); - // } - // - // Thread t = new Thread() { - // @Override - // public void run() { - // try(FileOutputStream fos = new FileOutputStream(target)) { - // for (int i = 0; i < segmentUris.size(); i++) { - // URL segment = new URL(segmentUris.get(i)); - // InputStream in = segment.openStream(); - // byte[] b = new byte[1024]; - // int length = -1; - // while( (length = in.read(b)) >= 0 ) { - // fos.write(b, 0, length); - // } - // in.close(); - // int progress = (int) (i * 100.0 / segmentUris.size()); - // Platform.runLater(new Runnable() { - // @Override - // public void run() { - // recording.setStatus(STATUS.DOWNLOADING); - // recording.setProgress(progress); - // } - // }); - // } - // - // } catch (FileNotFoundException e) { - // showErrorDialog("Error while downloading recording", "The target file couldn't be created", e); - // LOG.error("Error while downloading recording", e); - // } catch (IOException e) { - // showErrorDialog("Error while downloading recording", "The recording could not be downloaded", e); - // LOG.error("Error while downloading recording", e); - // } finally { - // Platform.runLater(new Runnable() { - // @Override - // public void run() { - // recording.setStatus(STATUS.FINISHED); - // recording.setProgress(-1); - // } - // }); - // } - // } - // }; - // t.setDaemon(true); - // t.setName("Download Thread " + recording.getPath()); - // t.start(); - // } - // } - private void showErrorDialog(final String title, final String msg, final Exception e) { Platform.runLater(new Runnable() { @Override @@ -571,7 +518,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { @Override public void run() { boolean started = Player.play(recording); - if(started) { + if(started && Config.getInstance().getSettings().showPlayerStarting) { Platform.runLater(() -> Toast.makeText(getTabPane().getScene(), "Starting Player", 2000, 500, 500)); } } @@ -583,7 +530,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { @Override public void run() { boolean started = Player.play(url); - if(started) { + if(started && Config.getInstance().getSettings().showPlayerStarting) { Platform.runLater(() -> Toast.makeText(getTabPane().getScene(), "Starting Player", 2000, 500, 500)); } } @@ -592,12 +539,16 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } - private void delete(Recording r) { - if(r.getStatus() != STATUS.FINISHED) { - return; - } + private void delete(List recordings) { table.setCursor(Cursor.WAIT); - String msg = "Delete " + r.getModelName() + "/" + r.getStartDate() + " for good?"; + + String msg; + if(recordings.size() > 1) { + msg = "Delete " + recordings.size() + " recordings for good?"; + } else { + Recording r = recordings.get(0); + msg = "Delete " + r.getModelName() + "/" + r.getStartDate() + " for good?"; + } AutosizeAlert confirm = new AutosizeAlert(AlertType.CONFIRMATION, msg, YES, NO); confirm.setTitle("Delete recording?"); confirm.setHeaderText(msg); @@ -607,14 +558,26 @@ public class RecordingsTab extends Tab implements TabSelectionListener { Thread deleteThread = new Thread() { @Override public void run() { + recordingsLock.lock(); try { - recorder.delete(r); - Platform.runLater(() -> observableRecordings.remove(r)); - } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { - LOG.error("Error while deleting recording", e1); - showErrorDialog("Error while deleting recording", "Recording not deleted", e1); + List deleted = new ArrayList<>(); + for (Iterator iterator = recordings.iterator(); iterator.hasNext();) { + JavaFxRecording r = iterator.next(); + if(r.getStatus() != STATUS.FINISHED) { + continue; + } + try { + recorder.delete(r); + deleted.add(r); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + LOG.error("Error while deleting recording", e1); + showErrorDialog("Error while deleting recording", "Recording not deleted", e1); + } + } + observableRecordings.removeAll(deleted); } finally { - table.setCursor(Cursor.DEFAULT); + recordingsLock.unlock(); + Platform.runLater(() -> table.setCursor(Cursor.DEFAULT)); } } }; diff --git a/client/src/main/java/ctbrec/ui/SettingsTab.java b/client/src/main/java/ctbrec/ui/SettingsTab.java index d1f816cc..a5182481 100644 --- a/client/src/main/java/ctbrec/ui/SettingsTab.java +++ b/client/src/main/java/ctbrec/ui/SettingsTab.java @@ -72,6 +72,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { private CheckBox chooseStreamQuality = new CheckBox(); private CheckBox multiplePlayers = new CheckBox(); private CheckBox updateThumbnails = new CheckBox(); + private CheckBox showPlayerStarting = new CheckBox(); private RadioButton recordLocal; private RadioButton recordRemote; private ToggleGroup recordLocation; @@ -409,6 +410,18 @@ public class SettingsTab extends Tab implements TabSelectionListener { GridPane.setMargin(multiplePlayers, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); layout.add(multiplePlayers, 1, row++); + l = new Label("Show \"Player Starting\" Message"); + layout.add(l, 0, row); + showPlayerStarting.setSelected(Config.getInstance().getSettings().showPlayerStarting); + showPlayerStarting.setOnAction((e) -> { + Config.getInstance().getSettings().showPlayerStarting = showPlayerStarting.isSelected(); + saveConfig(); + }); + GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); + GridPane.setMargin(showPlayerStarting, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); + layout.add(showPlayerStarting, 1, row++); + + l = new Label("Display stream resolution in overview"); layout.add(l, 0, row); loadResolution = new CheckBox(); diff --git a/client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java b/client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java index 76a90059..2347493b 100644 --- a/client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java +++ b/client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java @@ -1,5 +1,6 @@ package ctbrec.ui; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; @@ -15,7 +16,9 @@ public class StreamSourceSelectionDialog { Task> selectStreamSource = new Task>() { @Override protected List call() throws Exception { - return model.getStreamSources(); + List sources = model.getStreamSources(); + Collections.sort(sources); + return sources; } }; selectStreamSource.setOnSucceeded((e) -> { diff --git a/client/src/main/java/ctbrec/ui/ThumbCell.java b/client/src/main/java/ctbrec/ui/ThumbCell.java index eb316656..ece9efce 100644 --- a/client/src/main/java/ctbrec/ui/ThumbCell.java +++ b/client/src/main/java/ctbrec/ui/ThumbCell.java @@ -5,6 +5,8 @@ import java.io.IOException; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Function; import org.slf4j.Logger; @@ -14,6 +16,7 @@ import com.iheartradio.m3u8.ParseException; import ctbrec.Config; import ctbrec.Model; +import ctbrec.io.HttpException; import ctbrec.recorder.Recorder; import ctbrec.ui.controls.Toast; import javafx.animation.FadeTransition; @@ -43,6 +46,8 @@ import javafx.scene.text.Font; import javafx.scene.text.Text; import javafx.scene.text.TextAlignment; import javafx.util.Duration; +import okhttp3.Request; +import okhttp3.Response; public class ThumbCell extends StackPane { @@ -74,6 +79,7 @@ public class ThumbCell extends StackPane { private ObservableList thumbCellList; private boolean mouseHovering = false; private boolean recording = false; + private static ExecutorService imageLoadingThreadPool = Executors.newFixedThreadPool(30); public ThumbCell(ThumbOverviewTab parent, Model model, Recorder recorder) { this.thumbCellList = parent.grid.getChildren(); @@ -110,7 +116,7 @@ public class ThumbCell extends StackPane { StackPane.setMargin(resolutionBackground, new Insets(2)); getChildren().add(resolutionBackground); - name = new Text(model.getName()); + name = new Text(model.getDisplayName()); name.setFill(Color.WHITE); name.setFont(new Font("Sansserif", 16)); name.setTextAlignment(TextAlignment.CENTER); @@ -267,18 +273,35 @@ public class ThumbCell extends StackPane { if(!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { boolean updateThumbs = Config.getInstance().getSettings().updateThumbnails; if(updateThumbs || iv.getImage() == null) { - Image img = new Image(url, true); - - // wait for the image to load, otherwise the ImageView replaces the current image with an "empty" image, - // which causes to show the grey background until the image is loaded - img.progressProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Number oldValue, Number newValue) { - if(newValue.doubleValue() == 1.0) { - //imgAspectRatio = img.getHeight() / img.getWidth(); - iv.setImage(img); - setThumbWidth(Config.getInstance().getSettings().thumbWidth); + imageLoadingThreadPool.submit(() -> { + Request req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .build(); + try(Response resp = CamrecApplication.httpClient.execute(req)) { + if(resp.isSuccessful()) { + Image img = new Image(resp.body().byteStream()); + if(img.progressProperty().get() == 1.0) { + Platform.runLater(() -> { + iv.setImage(img); + setThumbWidth(Config.getInstance().getSettings().thumbWidth); + }); + } else { + img.progressProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue observable, Number oldValue, Number newValue) { + if(newValue.doubleValue() == 1.0) { + iv.setImage(img); + setThumbWidth(Config.getInstance().getSettings().thumbWidth); + } + } + }); + } + } else { + throw new HttpException(resp.code(), resp.message()); } + } catch (IOException e) { + LOG.error("Error loading image", e); } }); } @@ -308,7 +331,7 @@ public class ThumbCell extends StackPane { boolean started = Player.play(model); Platform.runLater(() -> { setCursor(Cursor.DEFAULT); - if (started) { + if (started && Config.getInstance().getSettings().showPlayerStarting) { Toast.makeText(getScene(), "Starting Player", 2000, 500, 500); } }); diff --git a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java index 4c6fb8f9..c12bc189 100644 --- a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java +++ b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java @@ -753,6 +753,8 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { String[] tokens = filter.split(" "); StringBuilder searchTextBuilder = new StringBuilder(m.getName()); searchTextBuilder.append(' '); + searchTextBuilder.append(m.getDisplayName()); + searchTextBuilder.append(' '); for (String tag : m.getTags()) { searchTextBuilder.append(tag).append(' '); } diff --git a/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java index 8b949ece..5b58e3a4 100644 --- a/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java +++ b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java @@ -38,6 +38,7 @@ import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import ctbrec.Config; import ctbrec.Model; import ctbrec.recorder.Recorder; import ctbrec.ui.Player; @@ -86,7 +87,7 @@ public class SearchPopoverTreeList extends PopoverTreeList implements Pop new Thread(() -> { Platform.runLater(() -> { boolean started = Player.play(model); - if(started) { + if(started && Config.getInstance().getSettings().showPlayerStarting) { Toast.makeText(getScene(), "Starting Player", 2000, 500, 500); } setCursor(Cursor.DEFAULT); @@ -230,7 +231,7 @@ public class SearchPopoverTreeList extends PopoverTreeList implements Pop } else { follow.setVisible(model.getSite().supportsFollow()); title.setVisible(true); - title.setText(model.getName()); + title.setText(model.getDisplayName()); this.model = model; URL anonymousPng = getClass().getResource("/anonymous.png"); String previewUrl = Optional.ofNullable(model.getPreview()).orElse(anonymousPng.toString()); diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java index 80f979fe..cd52462a 100644 --- a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java @@ -70,6 +70,9 @@ public class BongaCamsUpdateService extends PaginatedScheduledService { model.setOnlineState("offline"); } model.setPreview("https:" + m.getString("thumb_image")); + if(m.has("display_name")) { + model.setDisplayName(m.getString("display_name")); + } models.add(model); } } diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java index f7b6e321..4b035962 100644 --- a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java @@ -62,6 +62,7 @@ public class CamsodaUpdateService extends PaginatedScheduledService { if(result.has("tpl")) { JSONArray tpl = result.getJSONArray("tpl"); String name = tpl.getString(0); + String displayName = tpl.getString(1); // int connections = tpl.getInt(2); String streamName = tpl.getString(5); String tsize = tpl.getString(6); @@ -77,11 +78,11 @@ public class CamsodaUpdateService extends PaginatedScheduledService { JSONArray edgeServers = result.getJSONArray("edge_servers"); model.setStreamUrl("https://" + edgeServers.getString(0) + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8"); } + model.setDisplayName(displayName); models.add(model); } else { String name = result.getString("username"); CamsodaModel model = (CamsodaModel) camsoda.createModel(name); - if(result.has("server_prefix")) { String serverPrefix = result.getString("server_prefix"); String streamName = result.getString("stream_name"); @@ -91,6 +92,10 @@ public class CamsodaUpdateService extends PaginatedScheduledService { model.setOnlineState(result.getString("status")); } + if(result.has("display_name")) { + model.setDisplayName(result.getString("display_name")); + } + if(result.has("edge_servers")) { JSONArray edgeServers = result.getJSONArray("edge_servers"); model.setStreamUrl("https://" + edgeServers.getString(0) + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8"); diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java index e48a7892..706ff33c 100644 --- a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java @@ -42,14 +42,29 @@ public class ChaturbateConfigUi extends AbstractConfigUI { GridPane.setColumnSpan(password, 2); layout.add(password, 1, 1); + layout.add(new Label("Chaturbate Base URL"), 0, 2); + TextField baseUrl = new TextField(); + baseUrl.setText(Config.getInstance().getSettings().chaturbateBaseUrl); + baseUrl.textProperty().addListener((ob, o, n) -> { + Config.getInstance().getSettings().chaturbateBaseUrl = baseUrl.getText(); + save(); + }); + GridPane.setFillWidth(baseUrl, true); + GridPane.setHgrow(baseUrl, Priority.ALWAYS); + GridPane.setColumnSpan(baseUrl, 2); + layout.add(baseUrl, 1, 2); + Button createAccount = new Button("Create new Account"); createAccount.setOnAction((e) -> DesktopIntegration.open(Chaturbate.REGISTRATION_LINK)); - layout.add(createAccount, 1, 2); + layout.add(createAccount, 1, 3); GridPane.setColumnSpan(createAccount, 2); GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(baseUrl, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + username.setPrefWidth(300); + return layout; } } diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java index b9864907..f425a23b 100644 --- a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java @@ -1,7 +1,5 @@ package ctbrec.ui.sites.chaturbate; -import static ctbrec.sites.chaturbate.Chaturbate.*; - import java.util.ArrayList; import java.util.List; @@ -21,17 +19,17 @@ public class ChaturbateTabProvider extends TabProvider { public ChaturbateTabProvider(Chaturbate chaturbate) { this.chaturbate = chaturbate; this.recorder = chaturbate.getRecorder(); - this.followedTab = new ChaturbateFollowedTab("Followed", BASE_URI + "/followed-cams/", chaturbate); + this.followedTab = new ChaturbateFollowedTab("Followed", chaturbate.getBaseUrl() + "/followed-cams/", chaturbate); } @Override public List getTabs(Scene scene) { List tabs = new ArrayList<>(); - tabs.add(createTab("Featured", BASE_URI + "/")); - tabs.add(createTab("Female", BASE_URI + "/female-cams/")); - tabs.add(createTab("Male", BASE_URI + "/male-cams/")); - tabs.add(createTab("Couples", BASE_URI + "/couple-cams/")); - tabs.add(createTab("Trans", BASE_URI + "/trans-cams/")); + tabs.add(createTab("Featured", chaturbate.getBaseUrl() + "/")); + tabs.add(createTab("Female", chaturbate.getBaseUrl() + "/female-cams/")); + tabs.add(createTab("Male", chaturbate.getBaseUrl() + "/male-cams/")); + tabs.add(createTab("Couples", chaturbate.getBaseUrl() + "/couple-cams/")); + tabs.add(createTab("Trans", chaturbate.getBaseUrl() + "/trans-cams/")); followedTab.setScene(scene); followedTab.setRecorder(recorder); tabs.add(followedTab); diff --git a/common/src/main/java/ctbrec/AbstractModel.java b/common/src/main/java/ctbrec/AbstractModel.java index ce8b7ef1..61238759 100644 --- a/common/src/main/java/ctbrec/AbstractModel.java +++ b/common/src/main/java/ctbrec/AbstractModel.java @@ -14,6 +14,7 @@ public abstract class AbstractModel implements Model { private String url; private String name; + private String displayName; private String preview; private String description; private List tags = new ArrayList<>(); @@ -46,6 +47,20 @@ public abstract class AbstractModel implements Model { this.name = name; } + @Override + public String getDisplayName() { + if(displayName != null) { + return displayName; + } else { + return getName(); + } + } + + @Override + public void setDisplayName(String name) { + this.displayName = name; + } + @Override public String getPreview() { return preview; diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java index 871c36ff..9170a5f1 100644 --- a/common/src/main/java/ctbrec/Config.java +++ b/common/src/main/java/ctbrec/Config.java @@ -7,6 +7,7 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; @@ -46,7 +47,6 @@ public class Config { } else { filename = "settings.json"; } - load(); } private void load() throws FileNotFoundException, IOException { @@ -61,6 +61,13 @@ public class Config { BufferedSource source = buffer.readFrom(fin); settings = adapter.fromJson(source); settings.httpTimeout = Math.max(settings.httpTimeout, 10_000); + } catch(Throwable e) { + settings = OS.getDefaultSettings(); + for (Site site : sites) { + site.setEnabled(!settings.disabledSites.contains(site.getName())); + } + makeBackup(configFile); + throw e; } } else { LOG.error("Config file does not exist. Falling back to default values."); @@ -71,9 +78,22 @@ public class Config { } } + private void makeBackup(File source) { + try { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss"); + String timestamp = sdf.format(new Date()); + String backup = source.getName() + '.' + timestamp; + File target = new File(source.getParentFile(), backup); + Files.copy(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch(Throwable e) { + LOG.error("Couldn't create backup of settings file", e); + } + } + public static synchronized void init(List sites) throws FileNotFoundException, IOException { if(instance == null) { instance = new Config(sites); + instance.load(); } } diff --git a/common/src/main/java/ctbrec/Model.java b/common/src/main/java/ctbrec/Model.java index 3144f777..e13f2fcd 100644 --- a/common/src/main/java/ctbrec/Model.java +++ b/common/src/main/java/ctbrec/Model.java @@ -15,6 +15,8 @@ import ctbrec.sites.Site; public interface Model { public String getUrl(); public void setUrl(String url); + public String getDisplayName(); + public void setDisplayName(String name); public String getName(); public void setName(String name); public String getPreview(); diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 3b613845..8a155a19 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -30,6 +30,7 @@ public class Settings { } public boolean singlePlayer = true; + public boolean showPlayerStarting = false; public boolean localRecording = true; public int httpPort = 8080; public int httpTimeout = 10000; @@ -42,6 +43,7 @@ public class Settings { public String postProcessing = ""; public String username = ""; // chaturbate username TODO maybe rename this onetime public String password = ""; // chaturbate password TODO maybe rename this onetime + public String chaturbateBaseUrl = "https://chaturbate.com"; public String bongaUsername = ""; public String bongaPassword = ""; public String mfcUsername = ""; diff --git a/common/src/main/java/ctbrec/recorder/LocalRecorder.java b/common/src/main/java/ctbrec/recorder/LocalRecorder.java index e6df866b..4d71e58e 100644 --- a/common/src/main/java/ctbrec/recorder/LocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/LocalRecorder.java @@ -431,7 +431,8 @@ public class LocalRecorder implements Recorder { running = true; while (running) { Instant begin = Instant.now(); - for (Model model : getModelsRecording()) { + List models = getModelsRecording(); + for (Model model : models) { try { boolean isOnline = model.isOnline(IGNORE_CACHE); LOG.trace("Checking online state for {}: {}", model, (isOnline ? "online" : "offline")); @@ -450,6 +451,7 @@ public class LocalRecorder implements Recorder { } Instant end = Instant.now(); Duration timeCheckTook = Duration.between(begin, end); + LOG.trace("Online check for {} models took {} seconds", models.size(), timeCheckTook.getSeconds()); long sleepTime = Config.getInstance().getSettings().onlineCheckIntervalInSecs; if(timeCheckTook.getSeconds() < sleepTime) { @@ -710,13 +712,20 @@ public class LocalRecorder implements Recorder { @Override public void switchStreamSource(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException { - LOG.debug("Switching stream source to index {} for model {}", model.getStreamUrlIndex(), model.getName()); - Download download = recordingProcesses.get(model); - if(download != null) { - stopRecordingProcess(model); + if (models.contains(model)) { + int index = models.indexOf(model); + models.get(index).setStreamUrlIndex(model.getStreamUrlIndex()); + config.save(); + LOG.debug("Switching stream source to index {} for model {}", model.getStreamUrlIndex(), model.getName()); + Download download = recordingProcesses.get(model); + if(download != null) { + stopRecordingProcess(model); + } + tryRestartRecording(model); + } else { + LOG.warn("Couldn't switch stream source for model {}. Not found in list", model.getName()); + return; } - tryRestartRecording(model); - config.save(); } @Override @@ -752,13 +761,17 @@ public class LocalRecorder implements Recorder { int index = models.indexOf(model); Model m = models.get(index); m.setSuspended(false); - startRecordingProcess(m); + if(m.isOnline()) { + startRecordingProcess(m); + } model.setSuspended(false); config.save(); } else { LOG.warn("Couldn't resume model {}. Not found in list", model.getName()); return; } + } catch (ExecutionException | InterruptedException e) { + LOG.error("Couldn't check, if model {} is online", model.getName()); } finally { lock.unlock(); } @@ -787,6 +800,10 @@ public class LocalRecorder implements Recorder { private boolean enoughSpaceForRecording() throws IOException { long minimum = config.getSettings().minimumSpaceLeftInBytes; - return getFreeSpaceBytes() > minimum; + if(minimum == 0) { // 0 means don't check + return true; + } else { + return getFreeSpaceBytes() > minimum; + } } } diff --git a/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java b/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java index e615f63f..2fec113c 100644 --- a/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java +++ b/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java @@ -30,6 +30,7 @@ import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.PlaylistWriter; @@ -190,7 +191,7 @@ public class PlaylistGenerator { public void validate(File recDir) throws IOException, ParseException, PlaylistException { File playlist = new File(recDir, "playlist.m3u8"); if(playlist.exists()) { - PlaylistParser playlistParser = new PlaylistParser(new FileInputStream(playlist), Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser playlistParser = new PlaylistParser(new FileInputStream(playlist), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist m3u = playlistParser.parse(); MediaPlaylist mediaPlaylist = m3u.getMediaPlaylist(); int playlistSize = mediaPlaylist.getTracks().size(); diff --git a/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java index 04b11402..1fb6333d 100644 --- a/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java @@ -83,12 +83,18 @@ public abstract class AbstractHlsDownload implements Download { String getSegmentPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException { + LOG.debug("{} stream idx: {}", model.getName(), model.getStreamUrlIndex()); List streamSources = model.getStreamSources(); + Collections.sort(streamSources); + for (StreamSource streamSource : streamSources) { + LOG.debug("{} src {}", model.getName(), streamSource); + } String url = null; if(model.getStreamUrlIndex() >= 0 && model.getStreamUrlIndex() < streamSources.size()) { + // TODO don't use the index, but the bandwidth. if the bandwidth does not match, take the closest one + LOG.debug("{} selected {}", model.getName(), streamSources.get(model.getStreamUrlIndex())); url = streamSources.get(model.getStreamUrlIndex()).getMediaPlaylistUrl(); } else { - Collections.sort(streamSources); // filter out stream resolutions, which are too high int maxRes = Config.getInstance().getSettings().maximumResolution; if(maxRes > 0) { @@ -103,9 +109,11 @@ public abstract class AbstractHlsDownload implements Download { if(streamSources.isEmpty()) { throw new ExecutionException(new RuntimeException("No stream left in playlist")); } else { + LOG.debug("{} selected {}", model.getName(), streamSources.get(streamSources.size()-1)); url = streamSources.get(streamSources.size()-1).getMediaPlaylistUrl(); } } + LOG.debug("Segment playlist url {}", url); return url; } diff --git a/common/src/main/java/ctbrec/sites/AbstractSite.java b/common/src/main/java/ctbrec/sites/AbstractSite.java index 96d67005..4ecd6465 100644 --- a/common/src/main/java/ctbrec/sites/AbstractSite.java +++ b/common/src/main/java/ctbrec/sites/AbstractSite.java @@ -46,4 +46,9 @@ public abstract class AbstractSite implements Site { public boolean searchRequiresLogin() { return false; } + + @Override + public Model createModelFromUrl(String url) { + return null; + } } diff --git a/common/src/main/java/ctbrec/sites/Site.java b/common/src/main/java/ctbrec/sites/Site.java index cf6f3119..9225b52c 100644 --- a/common/src/main/java/ctbrec/sites/Site.java +++ b/common/src/main/java/ctbrec/sites/Site.java @@ -29,4 +29,5 @@ public interface Site { public boolean isEnabled(); public List search(String q) throws IOException, InterruptedException; public boolean searchRequiresLogin(); + public Model createModelFromUrl(String url); } diff --git a/common/src/main/java/ctbrec/sites/bonga/BongaCams.java b/common/src/main/java/ctbrec/sites/bonga/BongaCams.java index 6b2670d8..fc847912 100644 --- a/common/src/main/java/ctbrec/sites/bonga/BongaCams.java +++ b/common/src/main/java/ctbrec/sites/bonga/BongaCams.java @@ -5,6 +5,8 @@ import java.net.URLEncoder; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.json.JSONArray; import org.json.JSONObject; @@ -160,6 +162,9 @@ public class BongaCams extends AbstractSite { if(thumb != null) { model.setPreview("https:" + thumb); } + if(result.has("display_name")) { + model.setDisplayName(result.getString("display_name")); + } models.add(model); } return models; @@ -184,4 +189,14 @@ public class BongaCams extends AbstractSite { return username != null && !username.trim().isEmpty(); } + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://.*?bongacams.com(?:/profile)?/([^/]*?)/?").matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } } diff --git a/common/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java b/common/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java index a6fd377f..eaad5853 100644 --- a/common/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java +++ b/common/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java @@ -14,6 +14,7 @@ import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -101,7 +102,7 @@ public class BongaCamsModel extends AbstractModel { try(Response response = site.getHttpClient().execute(req)) { if(response.isSuccessful()) { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); streamSources.clear(); diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4.java b/common/src/main/java/ctbrec/sites/cam4/Cam4.java index 04b032f4..8c3907a0 100644 --- a/common/src/main/java/ctbrec/sites/cam4/Cam4.java +++ b/common/src/main/java/ctbrec/sites/cam4/Cam4.java @@ -4,6 +4,8 @@ import java.io.IOException; import java.net.URLEncoder; import java.util.ArrayList; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.json.JSONArray; import org.json.JSONObject; @@ -154,4 +156,15 @@ public class Cam4 extends AbstractSite { String username = Config.getInstance().getSettings().cam4Username; return username != null && !username.trim().isEmpty(); } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://(?:www\\.)?cam4(?:.*?).com/([^/]*?)/?").matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } } diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java index 68b24354..95686b19 100644 --- a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java +++ b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java @@ -17,6 +17,7 @@ import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -129,7 +130,7 @@ public class Cam4Model extends AbstractModel { Response response = site.getHttpClient().execute(req); try { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); return master; diff --git a/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java b/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java index e79688fa..3008f14b 100644 --- a/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java +++ b/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java @@ -5,6 +5,8 @@ import java.net.URLEncoder; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.json.JSONArray; import org.json.JSONObject; @@ -138,6 +140,9 @@ public class Camsoda extends AbstractSite { if(thumb != null) { model.setPreview("https:" + thumb); } + if(result.has("display_name")) { + model.setDisplayName(result.getString("display_name")); + } models.add(model); } return models; @@ -161,4 +166,15 @@ public class Camsoda extends AbstractSite { String username = Config.getInstance().getSettings().camsodaUsername; return username != null && !username.trim().isEmpty(); } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://(?:www\\.)?camsoda.com/([^/]*?)/?").matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } } diff --git a/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java b/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java index 53af0dad..b3d03d94 100644 --- a/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java +++ b/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java @@ -17,6 +17,7 @@ import com.google.common.cache.CacheBuilder; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -112,7 +113,7 @@ public class CamsodaModel extends AbstractModel { Response response = site.getHttpClient().execute(req); try { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); PlaylistData playlistData = master.getPlaylists().get(0); diff --git a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java index 534a39bb..99128330 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java @@ -10,6 +10,8 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,6 +22,7 @@ import com.google.common.cache.LoadingCache; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -42,14 +45,14 @@ import okhttp3.Response; public class Chaturbate extends AbstractSite { private static final transient Logger LOG = LoggerFactory.getLogger(Chaturbate.class); - public static final String BASE_URI = "https://chaturbate.com"; - public static final String AFFILIATE_LINK = BASE_URI + "/in/?track=default&tour=grq0&campaign=55vTi"; - public static final String REGISTRATION_LINK = BASE_URI + "/in/?track=default&tour=g4pe&campaign=55vTi"; + static String baseUrl = "https://chaturbate.com"; + public static final String AFFILIATE_LINK = "https://chaturbate.com/in/?track=default&tour=grq0&campaign=55vTi"; + public static final String REGISTRATION_LINK = "https://chaturbate.com/in/?track=default&tour=g4pe&campaign=55vTi"; private ChaturbateHttpClient httpClient; @Override public void init() throws IOException { - + baseUrl = Config.getInstance().getSettings().chaturbateBaseUrl; } @Override @@ -59,7 +62,7 @@ public class Chaturbate extends AbstractSite { @Override public String getBaseUrl() { - return "https://chaturbate.com"; + return baseUrl; } @Override @@ -136,7 +139,7 @@ public class Chaturbate extends AbstractSite { @Override public List search(String q) throws IOException, InterruptedException { - String url = BASE_URI + "?keywords=" + URLEncoder.encode(q, "utf-8"); + String url = baseUrl + "?keywords=" + URLEncoder.encode(q, "utf-8"); List result = new ArrayList<>(); // search online models @@ -152,7 +155,7 @@ public class Chaturbate extends AbstractSite { // since chaturbate does not return offline models, we at least try, if the profile page // exists for the search string - url = BASE_URI + '/' + q; + url = baseUrl + '/' + q; req = new Request.Builder() .url(url) .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) @@ -324,7 +327,7 @@ public class Chaturbate extends AbstractSite { try (Response response = getHttpClient().execute(req)) { if(response.isSuccessful()) { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); return master; @@ -339,4 +342,15 @@ public class Chaturbate extends AbstractSite { String username = Config.getInstance().getSettings().username; return username != null && !username.trim().isEmpty(); } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://.*?chaturbate.com(?:/p)?/([^/]*?)/?").matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } } diff --git a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java index 9950bccd..fbfffa70 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java @@ -54,7 +54,7 @@ public class ChaturbateHttpClient extends HttpClient { try { Request login = new Request.Builder() - .url(Chaturbate.BASE_URI + "/auth/login/") + .url(Chaturbate.baseUrl + "/auth/login/") .build(); Response response = client.newCall(login).execute(); String content = response.body().string(); @@ -68,8 +68,8 @@ public class ChaturbateHttpClient extends HttpClient { .add("csrfmiddlewaretoken", token) .build(); login = new Request.Builder() - .url(Chaturbate.BASE_URI + "/auth/login/") - .header("Referer", Chaturbate.BASE_URI + "/auth/login/") + .url(Chaturbate.baseUrl + "/auth/login/") + .header("Referer", Chaturbate.baseUrl + "/auth/login/") .post(body) .build(); diff --git a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java index bd17cd23..5ca806c1 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java @@ -1,7 +1,5 @@ package ctbrec.sites.chaturbate; -import static ctbrec.sites.chaturbate.Chaturbate.*; - import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -113,6 +111,9 @@ public class ChaturbateModel extends AbstractModel { String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1); String segmentUri = baseUrl + playlist.getUri(); src.mediaPlaylistUrl = segmentUri; + if(src.mediaPlaylistUrl.contains("?")) { + src.mediaPlaylistUrl = src.mediaPlaylistUrl.substring(0, src.mediaPlaylistUrl.lastIndexOf('?')); + } LOG.trace("Media playlist {}", src.mediaPlaylistUrl); sources.add(src); } @@ -137,9 +138,9 @@ public class ChaturbateModel extends AbstractModel { String url = null; if(follow) { - url = BASE_URI + "/follow/follow/" + getName() + "/"; + url = getSite().getBaseUrl() + "/follow/follow/" + getName() + "/"; } else { - url = BASE_URI + "/follow/unfollow/" + getName() + "/"; + url = getSite().getBaseUrl() + "/follow/unfollow/" + getName() + "/"; } RequestBody body = RequestBody.create(null, new byte[0]); diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java index 146c834a..a72191e2 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java @@ -2,6 +2,8 @@ package ctbrec.sites.mfc; import java.io.IOException; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.jsoup.select.Elements; @@ -122,4 +124,20 @@ public class MyFreeCams extends AbstractSite { String username = Config.getInstance().getSettings().mfcUsername; return username != null && !username.trim().isEmpty(); } + + @Override + public Model createModelFromUrl(String url) { + String[] patterns = new String[] { + "https?://profiles.myfreecams.com/([^/]*?)", + "https?://(?:www.)?myfreecams.com/#(.*)" + }; + for (String pattern : patterns) { + Matcher m = Pattern.compile(pattern).matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } + } + return super.createModelFromUrl(url); + } } diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java index 45f13c08..e0937cb5 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java @@ -18,6 +18,7 @@ import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -98,7 +99,7 @@ public class MyFreeCamsModel extends AbstractModel { } private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException { - if(hlsUrl == null) { + if(getHlsUrl() == null) { throw new IllegalStateException("Stream url unknown"); } LOG.trace("Loading master playlist {}", hlsUrl); @@ -106,7 +107,7 @@ public class MyFreeCamsModel extends AbstractModel { try(Response response = site.getHttpClient().execute(req)) { if(response.isSuccessful()) { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); return master; @@ -116,6 +117,14 @@ public class MyFreeCamsModel extends AbstractModel { } } + private String getHlsUrl() { + if(hlsUrl == null) { + MyFreeCams mfc = (MyFreeCams) getSite(); + mfc.getClient().update(this); + } + return hlsUrl; + } + @Override public void invalidateCacheEntries() { resolution = null;