diff --git a/client/src/main/java/ctbrec/ui/controls/Dialogs.java b/client/src/main/java/ctbrec/ui/controls/Dialogs.java index c96ae2c1..fec5c51b 100644 --- a/client/src/main/java/ctbrec/ui/controls/Dialogs.java +++ b/client/src/main/java/ctbrec/ui/controls/Dialogs.java @@ -16,6 +16,7 @@ import javafx.scene.control.Dialog; import javafx.scene.control.TextArea; import javafx.scene.image.Image; import javafx.scene.layout.GridPane; +import javafx.scene.layout.Region; import javafx.stage.Modality; import javafx.stage.Stage; @@ -87,6 +88,23 @@ public class Dialogs { return dialog.showAndWait(); } + public static Boolean showCustomInput(Scene parent, String title, Region region) { + Dialog dialog = new Dialog<>(); + dialog.setTitle(title); + dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.setResizable(true); + InputStream icon = Dialogs.class.getResourceAsStream("/icon.png"); + Stage stage = (Stage) dialog.getDialogPane().getScene().getWindow(); + stage.getIcons().add(new Image(icon)); + if (parent != null) { + stage.getScene().getStylesheets().addAll(parent.getStylesheets()); + } + dialog.getDialogPane().setContent(region); + dialog.showAndWait(); + return dialog.getResult() == ButtonType.OK; + } + public static boolean showConfirmDialog(String title, String message, String header, Scene parent) { AutosizeAlert confirm = new AutosizeAlert(AlertType.CONFIRMATION, message, parent, YES, NO); confirm.setTitle(title); diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java index 48faf307..9922ca5a 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java @@ -1,9 +1,13 @@ package ctbrec.ui.tabs; +import static ctbrec.SubsequentAction.*; + import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; @@ -25,6 +29,7 @@ import ctbrec.Config; import ctbrec.Model; import ctbrec.Recording; import ctbrec.StringUtil; +import ctbrec.SubsequentAction; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; import ctbrec.ui.AutosizeAlert; @@ -59,8 +64,10 @@ import javafx.geometry.Pos; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.scene.control.ContextMenu; +import javafx.scene.control.DatePicker; import javafx.scene.control.Label; import javafx.scene.control.MenuItem; +import javafx.scene.control.RadioButton; import javafx.scene.control.ScrollPane; import javafx.scene.control.SelectionMode; import javafx.scene.control.Tab; @@ -71,6 +78,7 @@ import javafx.scene.control.TableColumn.SortType; import javafx.scene.control.TableRow; import javafx.scene.control.TableView; import javafx.scene.control.TextField; +import javafx.scene.control.ToggleGroup; import javafx.scene.control.Tooltip; import javafx.scene.control.cell.CheckBoxTableCell; import javafx.scene.control.cell.PropertyValueFactory; @@ -84,6 +92,7 @@ import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.FlowPane; +import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.util.Callback; @@ -612,6 +621,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { pauseRecording.setOnAction(e -> pauseRecording(selectedModels)); MenuItem resumeRecording = new MenuItem("Resume Recording"); resumeRecording.setOnAction(e -> resumeRecording(selectedModels)); + MenuItem stopRecordingAt = new MenuItem("Stop Recording at Date"); + stopRecordingAt.setOnAction(e -> setStopDate(selectedModels.get(0))); MenuItem openInBrowser = new MenuItem("Open in Browser"); openInBrowser.setOnAction(e -> DesktopIntegration.open(selectedModels.get(0).getUrl())); MenuItem openInPlayer = new MenuItem("Open in Player"); @@ -630,6 +641,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { ContextMenu menu = new ContextMenu(stop); if (selectedModels.size() == 1) { menu.getItems().add(selectedModels.get(0).isSuspended() ? resumeRecording : pauseRecording); + menu.getItems().add(stopRecordingAt); } else { menu.getItems().addAll(resumeRecording, pauseRecording); } @@ -646,6 +658,46 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { return menu; } + private void setStopDate(JavaFxModel model) { + DatePicker datePicker = new DatePicker(); + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(10); + grid.setPadding(new Insets(20, 150, 10, 10)); + grid.add(new Label("Stop at"), 0, 0); + grid.add(datePicker, 1, 0); + grid.add(new Label("And then"), 0, 1); + ToggleGroup toggleGroup = new ToggleGroup(); + RadioButton pauseButton = new RadioButton("pause recording"); + pauseButton.setSelected(model.getRecordUntilSubsequentAction() == PAUSE); + pauseButton.setToggleGroup(toggleGroup); + RadioButton removeButton = new RadioButton("remove model"); + removeButton.setSelected(model.getRecordUntilSubsequentAction() == REMOVE); + removeButton.setToggleGroup(toggleGroup); + HBox row = new HBox(); + row.getChildren().addAll(pauseButton, removeButton); + HBox.setMargin(pauseButton, new Insets(5)); + HBox.setMargin(removeButton, new Insets(5)); + grid.add(row, 1, 1); + if (model.getRecordUntil().toEpochMilli() != Long.MAX_VALUE) { + LocalDate localDate = LocalDate.ofInstant(model.getRecordUntil(), ZoneId.systemDefault()); + datePicker.setValue(localDate); + } + boolean userClickedOk = Dialogs.showCustomInput(getTabPane().getScene(), "Stop Recording at", grid); + if (userClickedOk) { + SubsequentAction action = pauseButton.isSelected() ? PAUSE : REMOVE; + LOG.info("Stop at {} and {}", datePicker.getValue(), action); + Instant stopAt = Instant.from(datePicker.getValue().atStartOfDay().atZone(ZoneId.systemDefault())); + model.setRecordUntil(stopAt); + model.setRecordUntilSubsequentAction(action); + try { + recorder.stopRecordingAt(model.getDelegate()); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Dialogs.showError(getTabPane().getScene(), "Error", "Couln't set stop date", e); + } + } + } + private void ignore(ObservableList selectedModels) { for (JavaFxModel fxModel : selectedModels) { Model modelToIgnore = fxModel.getDelegate(); diff --git a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java index 79161d58..c7826077 100644 --- a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java @@ -1,5 +1,6 @@ package ctbrec.recorder; +import static ctbrec.SubsequentAction.*; import static ctbrec.event.Event.Type.*; import java.io.File; @@ -10,6 +11,7 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.Instant; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -20,6 +22,7 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorCompletionService; import java.util.concurrent.Executors; @@ -42,7 +45,6 @@ import ctbrec.Config; import ctbrec.Model; import ctbrec.Recording; import ctbrec.Recording.State; -import ctbrec.SubsequentAction; import ctbrec.event.Event; import ctbrec.event.EventBusHolder; import ctbrec.event.ModelIsOnlineEvent; @@ -215,41 +217,14 @@ public class NextGenLocalRecorder implements Recorder { recorderLock.lock(); try { checkRecordingPreconditions(model); - LOG.info("Starting recording for model {}", model.getName()); - Download download = model.createDownload(); - download.init(config, model, Instant.now()); - Objects.requireNonNull(download.getStartTime(), - "At this point the download should have set a startTime. Make sure to set a startTime in " + download.getClass() + ".init()"); - LOG.debug("Downloading with {}", download.getClass().getSimpleName()); - - Recording rec = createRecording(model, download); - completionService.submit(() -> { - try { - setRecordingStatus(rec, State.RECORDING); - model.setLastRecorded(rec.getStartDate()); - recordingManager.saveRecording(rec); - download.start(); - } catch (Exception e) { - LOG.error("Download for {} failed. Download state: {}", model.getName(), rec.getStatus(), e); - } - boolean deleted = deleteIfEmpty(rec); - setRecordingStatus(rec, deleted ? State.DELETED : State.WAITING); - if (!deleted) { - // only save the status, if the recording has not been deleted, otherwise we recreate the metadata file - recordingManager.saveRecording(rec); - } - return rec; - }); + Download download = createDownload(model); + Recording rec = createRecording(download); + completionService.submit(createDownloadJob(rec)); } catch (RecordUntilExpiredException e) { LOG.info("Precondition not met to record {}: {}", model, e.getLocalizedMessage()); executeRecordUntilSubsequentAction(model); } catch (PreconditionNotMetException e) { - // 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; - // } LOG.info("Precondition not met to record {}: {}", model, e.getLocalizedMessage()); return; } finally { @@ -257,20 +232,53 @@ public class NextGenLocalRecorder implements Recorder { } } - private void executeRecordUntilSubsequentAction(Model model) throws IOException { - if (model.getRecordUntilSubsequentAction() == SubsequentAction.PAUSE) { - model.setSuspended(true); - } else if (model.getRecordUntilSubsequentAction() == SubsequentAction.REMOVE) { + private Download createDownload(Model model) { + Download download = model.createDownload(); + download.init(config, model, Instant.now()); + Objects.requireNonNull(download.getStartTime(), + "At this point the download should have set a startTime. Make sure to set a startTime in " + download.getClass() + ".init()"); + LOG.debug("Downloading with {}", download.getClass().getSimpleName()); + return download; + } + + private Callable createDownloadJob(Recording rec) { + return () -> { try { - LOG.info("Recording timeframe expired for model {} - {}", model, model.getRecordUntil()); + setRecordingStatus(rec, State.RECORDING); + rec.getModel().setLastRecorded(rec.getStartDate()); + recordingManager.saveRecording(rec); + rec.getDownload().start(); + } catch (Exception e) { + LOG.error("Download for {} failed. Download state: {}", rec.getModel().getName(), rec.getStatus(), e); + } + boolean deleted = deleteIfEmpty(rec); + setRecordingStatus(rec, deleted ? State.DELETED : State.WAITING); + if (!deleted) { + // only save the status, if the recording has not been deleted, otherwise we recreate the metadata file + recordingManager.saveRecording(rec); + } + return rec; + }; + } + + private void executeRecordUntilSubsequentAction(Model model) throws IOException { + if (model.getRecordUntilSubsequentAction() == PAUSE) { + model.setSuspended(true); + } else if (model.getRecordUntilSubsequentAction() == REMOVE) { + try { + LOG.info("Removing {} because the recording timeframe ended at {}", model, model.getRecordUntil().atZone(ZoneId.systemDefault())); stopRecording(model); } catch (InvalidKeyException | NoSuchAlgorithmException e1) { LOG.error("Error while stopping recording", e1); } } + // reset values, so that model can be recorded again + model.setRecordUntil(null); + model.setRecordUntilSubsequentAction(PAUSE); } - private Recording createRecording(Model model, Download download) throws IOException { + private Recording createRecording(Download download) throws IOException { + Model model = download.getModel(); Recording rec = new Recording(); rec.setDownload(download); rec.setPath(download.getPath(model).replaceAll("\\\\", "/")); @@ -749,4 +757,31 @@ public class NextGenLocalRecorder implements Recorder { rec.setNote(note); recordingManager.saveRecording(rec); } + + @Override + public void stopRecordingAt(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + recorderLock.lock(); + try { + int index = models.indexOf(model); + if (index >= 0) { + Model m = models.get(index); + m.setRecordUntil(model.getRecordUntil()); + m.setRecordUntilSubsequentAction(model.getRecordUntilSubsequentAction()); + config.save(); + } else { + throw new NoSuchElementException("Model " + model.getName() + " [" + model.getUrl() + "] not found in list of recorded models"); + } + + if (recordingProcesses.containsKey(model)) { + Recording rec = recordingProcesses.get(model); + rec.getDownload().stop(); + } + } finally { + recorderLock.unlock(); + } + + if (Instant.now().isAfter(model.getRecordUntil())) { + executeRecordUntilSubsequentAction(model); + } + } } diff --git a/common/src/main/java/ctbrec/recorder/Recorder.java b/common/src/main/java/ctbrec/recorder/Recorder.java index 7dee8c4d..dd8238a0 100644 --- a/common/src/main/java/ctbrec/recorder/Recorder.java +++ b/common/src/main/java/ctbrec/recorder/Recorder.java @@ -14,6 +14,7 @@ public interface Recorder { public void startRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; public void stopRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; + public void stopRecordingAt(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; public void switchStreamSource(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; diff --git a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java index 84a4b036..10fae6b5 100644 --- a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java +++ b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java @@ -86,6 +86,11 @@ public class RemoteRecorder implements Recorder { sendRequest("stop", model); } + @Override + public void stopRecordingAt(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + sendRequest("stopAt", model); + } + private void sendRequest(String action, Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { String payload = modelRequestAdapter.toJson(new ModelRequest(action, model)); LOG.debug("Sending request to recording server: {}", payload); diff --git a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java index 5c71abec..fb42c4a0 100644 --- a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java @@ -96,6 +96,17 @@ public class RecorderServlet extends AbstractCtbrecServlet { response = "{\"status\": \"success\", \"msg\": \"Stopping recording\"}"; resp.getWriter().write(response); break; + case "stopAt": + new Thread(() -> { + try { + recorder.stopRecordingAt(request.model); + } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) { + LOG.error("Couldn't stop recording for model {}", request.model, e); + } + }).start(); + response = "{\"status\": \"success\", \"msg\": \"Stopping recording\"}"; + resp.getWriter().write(response); + break; case "list": resp.getWriter().write("{\"status\": \"success\", \"msg\": \"List of models\", \"models\": ["); JsonAdapter modelAdapter = new ModelJsonAdapter();