diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c509f2b..4992061d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ NEXT ======================== +* Added "record later" tab to "bookmark" models * Added config option to show the total number of models in the title bar 3.11.0 diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 11fa65ce..1e049d2a 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -64,7 +64,7 @@ import ctbrec.ui.news.NewsTab; import ctbrec.ui.settings.SettingsTab; import ctbrec.ui.tabs.DonateTabFx; import ctbrec.ui.tabs.HelpTab; -import ctbrec.ui.tabs.RecordedModelsTab; +import ctbrec.ui.tabs.RecordedTab; import ctbrec.ui.tabs.RecordingsTab; import ctbrec.ui.tabs.SiteTab; import ctbrec.ui.tabs.TabSelectionListener; @@ -105,7 +105,7 @@ public class CamrecApplication extends Application { public static HttpClient httpClient; public static String title; private Stage primaryStage; - private RecordedModelsTab modelsTab; + private RecordedTab modelsTab; private RecordingsTab recordingsTab; private ScheduledExecutorService scheduler; private int activeRecordings = 0; @@ -213,7 +213,7 @@ public class CamrecApplication extends Application { } } - modelsTab = new RecordedModelsTab("Recording", recorder, sites); + modelsTab = new RecordedTab(recorder, sites); tabPane.getTabs().add(modelsTab); recordingsTab = new RecordingsTab("Recordings", recorder, config); tabPane.getTabs().add(recordingsTab); diff --git a/client/src/main/java/ctbrec/ui/JavaFxModel.java b/client/src/main/java/ctbrec/ui/JavaFxModel.java index f2ad24c8..b160a0f1 100644 --- a/client/src/main/java/ctbrec/ui/JavaFxModel.java +++ b/client/src/main/java/ctbrec/ui/JavaFxModel.java @@ -319,4 +319,16 @@ public class JavaFxModel implements Model { public boolean isRecordingTimeLimited() { return delegate.isRecordingTimeLimited(); } + + @Override + public boolean isMarkedForLaterRecording() { + return delegate.isMarkedForLaterRecording(); + } + + @Override + public void setMarkedForLaterRecording(boolean marked) { + delegate.setMarkedForLaterRecording(marked); + } + + } diff --git a/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java b/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java index fdcf4d5c..cad69b2a 100644 --- a/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java +++ b/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java @@ -4,6 +4,8 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,13 +29,15 @@ public class CheckModelAccountAction { } - public void execute() { + public void execute(Predicate filter) { String buttonText = b.getText(); b.setDisable(true); CompletableFuture.runAsync(() -> { List deletedAccounts = new ArrayList<>(); try { - List models = recorder.getModels(); + List models = recorder.getModels().stream() // + .filter(filter) // + .collect(Collectors.toList()); int total = models.size(); for (int i = 0; i < total; i++) { final int counter = i+1; diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordLaterTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordLaterTab.java new file mode 100644 index 00000000..1824776d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/RecordLaterTab.java @@ -0,0 +1,633 @@ +package ctbrec.ui.tabs; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.ui.AutosizeAlert; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.PreviewPopupHandler; +import ctbrec.ui.action.CheckModelAccountAction; +import ctbrec.ui.action.EditNotesAction; +import ctbrec.ui.action.FollowAction; +import ctbrec.ui.action.IgnoreModelsAction; +import ctbrec.ui.action.PlayAction; +import ctbrec.ui.action.ResumeAction; +import ctbrec.ui.action.StopRecordingAction; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.SearchBox; +import ctbrec.ui.controls.autocomplete.AutoFillTextField; +import ctbrec.ui.controls.autocomplete.ObservableListSuggester; +import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringPropertyBase; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.ScheduledService; +import javafx.concurrent.Task; +import javafx.concurrent.WorkerStateEvent; +import javafx.event.ActionEvent; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +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.TableCell; +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.TextField; +import javafx.scene.control.Tooltip; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.util.Callback; +import javafx.util.Duration; + +public class RecordLaterTab extends Tab implements TabSelectionListener { + private static final Logger LOG = LoggerFactory.getLogger(RecordLaterTab.class); + + private ReentrantLock lock = new ReentrantLock(); + private ScheduledService> updateService; + private Recorder recorder; + private List sites; + + FlowPane grid = new FlowPane(); + ScrollPane scrollPane = new ScrollPane(); + TableView table = new TableView<>(); + ObservableList observableModels = FXCollections.observableArrayList(); + ObservableList filteredModels = FXCollections.observableArrayList(); + ContextMenu popup; + + Label modelLabel = new Label("Model"); + AutoFillTextField model; + Button addModelButton = new Button("Record"); + Button checkModelAccountExistance = new Button("Check URLs"); + TextField filter; + + public RecordLaterTab(String title, Recorder recorder, List sites) { + super(title); + this.recorder = recorder; + this.sites = sites; + createGui(); + setClosable(false); + initializeUpdateService(); + } + + @SuppressWarnings("unchecked") + private void createGui() { + grid.setPadding(new Insets(5)); + grid.setHgap(5); + grid.setVgap(5); + + scrollPane.setContent(grid); + scrollPane.setFitToHeight(true); + scrollPane.setFitToWidth(true); + BorderPane.setMargin(scrollPane, new Insets(5)); + + 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"); + if (!Config.getInstance().getSettings().livePreviews) { + preview.setVisible(false); + } + TableColumn name = new TableColumn<>("Model"); + name.setPrefWidth(200); + name.setCellValueFactory(new PropertyValueFactory<>("displayName")); + name.setCellFactory(new ClickableCellFactory<>()); + name.setEditable(false); + name.setId("name"); + TableColumn url = new TableColumn<>("URL"); + url.setCellValueFactory(new PropertyValueFactory<>("url")); + url.setCellFactory(new ClickableCellFactory<>()); + url.setPrefWidth(400); + url.setEditable(false); + url.setId("url"); + TableColumn notes = new TableColumn<>("Notes"); + notes.setCellValueFactory(cdf -> { + JavaFxModel m = cdf.getValue(); + return new StringPropertyBase() { + @Override + public String getName() { + return "Model Notes"; + } + + @Override + public Object getBean() { + return null; + } + + @Override + public String get() { + String modelNotes = Config.getInstance().getModelNotes(m); + return modelNotes; + } + }; + }); + notes.setPrefWidth(400); + notes.setEditable(false); + notes.setId("notes"); + table.getColumns().addAll(preview, name, url, notes); + table.setItems(observableModels); + table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { + popup = createContextMenu(); + if (popup != null) { + popup.show(table, event.getScreenX(), event.getScreenY()); + } + event.consume(); + }); + table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { + if (popup != null) { + popup.hide(); + } + }); + table.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + List selectedModels = table.getSelectionModel().getSelectedItems(); + if (event.getCode() == KeyCode.DELETE) { + stopAction(selectedModels); + } else { + jumpToNextModel(event.getCode()); + } + }); + + scrollPane.setContent(table); + + HBox addModelBox = new HBox(5); + modelLabel.setPadding(new Insets(5, 0, 0, 0)); + ObservableList suggestions = FXCollections.observableArrayList(); + sites.forEach(site -> suggestions.add(site.getClass().getSimpleName())); + model = new AutoFillTextField(new ObservableListSuggester(suggestions)); + model.minWidth(150); + model.prefWidth(600); + HBox.setHgrow(model, Priority.ALWAYS); + model.setPromptText("e.g. MyFreeCams:ModelName or an URL like https://chaturbate.com/modelname/"); + model.onActionHandler(this::addModel); + model.setTooltip(new Tooltip("To add a model enter SiteName:ModelName\n" + "press ENTER to confirm a suggested site name")); + BorderPane.setMargin(addModelBox, new Insets(5)); + addModelButton.setOnAction(this::addModel); + addModelButton.setPadding(new Insets(5)); + addModelBox.getChildren().addAll(modelLabel, model, addModelButton, checkModelAccountExistance); + checkModelAccountExistance.setPadding(new Insets(5)); + checkModelAccountExistance.setTooltip(new Tooltip("Go over all model URLs and check, if the account still exists")); + HBox.setMargin(checkModelAccountExistance, new Insets(0, 0, 0, 20)); + checkModelAccountExistance.setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder) + .execute(Model::isMarkedForLaterRecording)); + + HBox filterContainer = new HBox(); + filterContainer.setSpacing(0); + filterContainer.setPadding(new Insets(0)); + filterContainer.setAlignment(Pos.CENTER_RIGHT); + filterContainer.minWidth(100); + filterContainer.prefWidth(150); + HBox.setHgrow(filterContainer, Priority.ALWAYS); + filter = new SearchBox(false); + filter.minWidth(100); + filter.prefWidth(150); + filter.setPromptText("Filter"); + filter.textProperty().addListener((observableValue, oldValue, newValue) -> { + String q = filter.getText(); + lock.lock(); + try { + filter(q); + } finally { + lock.unlock(); + } + }); + filter.getStyleClass().remove("search-box-icon"); + filterContainer.getChildren().add(filter); + addModelBox.getChildren().add(filterContainer); + + BorderPane root = new BorderPane(); + root.setPadding(new Insets(5)); + root.setTop(addModelBox); + root.setCenter(scrollPane); + setContent(root); + + restoreState(); + } + + private void jumpToNextModel(KeyCode code) { + if (!table.getItems().isEmpty() && (code.isLetterKey() || code.isDigitKey())) { + // determine where to start looking for the next model + int startAt = 0; + if (table.getSelectionModel().getSelectedIndex() >= 0) { + startAt = table.getSelectionModel().getSelectedIndex() + 1; + if (startAt >= table.getItems().size()) { + startAt = 0; + } + } + + String c = code.getChar().toLowerCase(); + int i = startAt; + do { + JavaFxModel current = table.getItems().get(i); + if (current.getName().toLowerCase().replaceAll("[^0-9a-z]", "").startsWith(c)) { + table.getSelectionModel().clearAndSelect(i); + table.scrollTo(i); + break; + } + + i++; + if (i >= table.getItems().size()) { + i = 0; + } + } while (i != startAt); + } + } + + 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 newModel = site.createModelFromUrl(url); + if (newModel != null) { + try { + newModel.setMarkedForLaterRecording(true); + recorder.startRecording(newModel); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + Dialogs.showError(getTabPane().getScene(), "Couldn't add model", "The model " + newModel.getName() + " could not be added: ", e1); + } + return; + } + } + + Dialogs.showError(getTabPane().getScene(), "Unknown URL format", + "The URL you entered has an unknown format or the function does not support this site, yet", null); + } + + private void addModelByName(String siteModelCombo) { + String[] parts = siteModelCombo.trim().split(":"); + if (parts.length != 2) { + Dialogs.showError(getTabPane().getScene(), "Wrong input format", "Use something like \"MyFreeCams:ModelName\"", null); + return; + } + + String siteName = parts[0]; + String modelName = parts[1]; + for (Site site : sites) { + if (Objects.equals(siteName.toLowerCase(), site.getClass().getSimpleName().toLowerCase())) { + try { + Model m = site.createModel(modelName); + m.setMarkedForLaterRecording(true); + recorder.startRecording(m); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + Dialogs.showError(getTabPane().getScene(), "Couldn't add model", "The model " + modelName + " could not be added:", e1); + } + return; + } + } + + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getTabPane().getScene()); + alert.setTitle("Unknown site"); + alert.setHeaderText("Couldn't add model"); + alert.setContentText("The site you entered is unknown"); + alert.showAndWait(); + } + + void initializeUpdateService() { + updateService = createUpdateService(); + updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2))); + updateService.setOnSucceeded(this::onUpdateSuccess); + updateService.setOnFailed(event -> LOG.info("Couldn't get list of models from recorder", event.getSource().getException())); + } + + private void onUpdateSuccess(WorkerStateEvent event) { + List updatedModels = updateService.getValue(); + if (updatedModels == null) { + return; + } + + lock.lock(); + try { + addOrUpdateModels(updatedModels); + + // remove old ones, which are not in the list of updated models + for (Iterator iterator = observableModels.iterator(); iterator.hasNext();) { + Model oldModel = iterator.next(); + if (!updatedModels.contains(oldModel)) { + iterator.remove(); + } + } + } finally { + lock.unlock(); + } + + filteredModels.clear(); + filter(filter.getText()); + table.sort(); + } + + private void addOrUpdateModels(List updatedModels) { + for (JavaFxModel updatedModel : updatedModels) { + int index = observableModels.indexOf(updatedModel); + if (index == -1) { + observableModels.add(updatedModel); + } else { + // make sure to update the JavaFX online property, so that the table cell is updated + JavaFxModel oldModel = observableModels.get(index); + oldModel.setSuspended(updatedModel.isSuspended()); + oldModel.getOnlineProperty().set(updatedModel.getOnlineProperty().get()); + oldModel.getRecordingProperty().set(updatedModel.getRecordingProperty().get()); + oldModel.lastRecordedProperty().set(updatedModel.lastRecordedProperty().get()); + oldModel.lastSeenProperty().set(updatedModel.lastSeenProperty().get()); + } + } + } + + private void filter(String filter) { + lock.lock(); + try { + if (StringUtil.isBlank(filter)) { + observableModels.addAll(filteredModels); + filteredModels.clear(); + return; + } + + String[] tokens = filter.split(" "); + observableModels.addAll(filteredModels); + filteredModels.clear(); + for (int i = 0; i < table.getItems().size(); i++) { + StringBuilder sb = new StringBuilder(); + for (TableColumn tc : table.getColumns()) { + Object cellData = tc.getCellData(i); + if (cellData != null) { + String content = cellData.toString(); + sb.append(content).append(' '); + } + } + String searchText = sb.toString(); + + boolean tokensMissing = false; + for (String token : tokens) { + if (!searchText.toLowerCase().contains(token.toLowerCase())) { + tokensMissing = true; + break; + } + } + if (tokensMissing) { + JavaFxModel filteredModel = table.getItems().get(i); + filteredModels.add(filteredModel); + } + } + observableModels.removeAll(filteredModels); + } finally { + lock.unlock(); + } + } + + private ScheduledService> createUpdateService() { + ScheduledService> modelUpdateService = new ScheduledService>() { + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws InvalidKeyException, NoSuchAlgorithmException, IOException { + LOG.trace("Updating models marked for later recording"); + return recorder.getModels().stream().filter(Model::isMarkedForLaterRecording).map(JavaFxModel::new).collect(Collectors.toList()); + } + }; + } + }; + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("RecordLaterTab UpdateService"); + return t; + }); + modelUpdateService.setExecutor(executor); + return modelUpdateService; + } + + @Override + public void selected() { + if (updateService != null) { + updateService.reset(); + updateService.restart(); + } + } + + @Override + public void deselected() { + if (updateService != null) { + updateService.cancel(); + } + } + + private ContextMenu createContextMenu() { + ObservableList selectedModels = table.getSelectionModel().getSelectedItems(); + if (selectedModels.isEmpty()) { + return null; + } + MenuItem start = new MenuItem("Start Recording"); + start.setOnAction(e -> startAction(selectedModels)); + MenuItem stop = new MenuItem("Remove Model"); + stop.setOnAction(e -> stopAction(selectedModels)); + + MenuItem copyUrl = new MenuItem("Copy URL"); + copyUrl.setOnAction(e -> { + Model selected = selectedModels.get(0); + final Clipboard clipboard = Clipboard.getSystemClipboard(); + final ClipboardContent content = new ClipboardContent(); + content.putString(selected.getUrl()); + clipboard.setContent(content); + }); + + MenuItem openInBrowser = new MenuItem("Open in Browser"); + openInBrowser.setOnAction(e -> DesktopIntegration.open(selectedModels.get(0).getUrl())); + MenuItem openInPlayer = new MenuItem("Open in Player"); + openInPlayer.setOnAction(e -> openInPlayer(selectedModels.get(0))); + MenuItem follow = new MenuItem("Follow"); + follow.setOnAction(e -> follow(selectedModels)); + follow.setDisable(!selectedModels.stream().allMatch(m -> m.getSite().supportsFollow() && m.getSite().credentialsAvailable())); + MenuItem ignore = new MenuItem("Ignore"); + ignore.setOnAction(e -> ignore(selectedModels)); + MenuItem notes = new MenuItem("Notes"); + notes.setOnAction(e -> notes(selectedModels)); + + ContextMenu menu = new CustomMouseBehaviorContextMenu(start, stop, copyUrl, openInPlayer, openInBrowser, follow, notes, ignore); + + if (selectedModels.size() > 1) { + copyUrl.setDisable(true); + openInPlayer.setDisable(true); + openInBrowser.setDisable(true); + notes.setDisable(true); + } + + return menu; + } + + private void ignore(ObservableList selectedModels) { + new IgnoreModelsAction(table, selectedModels, recorder, true).execute(); + } + + private void follow(ObservableList selectedModels) { + new FollowAction(getTabPane(), new ArrayList<>(selectedModels)).execute(); + } + + private void notes(ObservableList selectedModels) { + new EditNotesAction(getTabPane(), selectedModels.get(0), table).execute(); + } + + private void openInPlayer(JavaFxModel selectedModel) { + new PlayAction(getTabPane(), selectedModel).execute(); + } + + private void startAction(List selectedModels) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).collect(Collectors.toList()); + new ResumeAction(table, models, recorder).execute(); + } + + private void stopAction(List selectedModels) { + boolean confirmed = true; + if (Config.getInstance().getSettings().confirmationForDangerousActions) { + int n = selectedModels.size(); + String plural = n > 1 ? "s" : ""; + String header = "This will remove " + n + " model" + plural; + confirmed = Dialogs.showConfirmDialog("Remove From List", "Continue?", header, table.getScene()); + } + if (confirmed) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).collect(Collectors.toList()); + new StopRecordingAction(getTabPane(), models, recorder).execute(m -> Platform.runLater(() -> { + table.getSelectionModel().clearSelection(table.getItems().indexOf(m)); + table.getItems().remove(m); + })); + } + } + + public void saveState() { + if (!table.getSortOrder().isEmpty()) { + TableColumn col = table.getSortOrder().get(0); + Config.getInstance().getSettings().recordLaterSortColumn = col.getText(); + Config.getInstance().getSettings().recordLaterSortType = col.getSortType().toString(); + } + int columns = table.getColumns().size(); + double[] columnWidths = new double[columns]; + String[] columnIds = new String[columns]; + for (int i = 0; i < columnWidths.length; i++) { + columnWidths[i] = table.getColumns().get(i).getWidth(); + columnIds[i] = table.getColumns().get(i).getId(); + } + Config.getInstance().getSettings().recordLaterColumnWidths = columnWidths; + Config.getInstance().getSettings().recordLaterColumnIds = columnIds; + } + + private void restoreState() { + restoreColumnOrder(); + restoreColumnWidths(); + restoreSorting(); + } + + private void restoreSorting() { + String sortCol = Config.getInstance().getSettings().recordLaterSortColumn; + if (StringUtil.isNotBlank(sortCol)) { + for (TableColumn col : table.getColumns()) { + if (Objects.equals(sortCol, col.getText())) { + col.setSortType(SortType.valueOf(Config.getInstance().getSettings().recordLaterSortType)); + table.getSortOrder().clear(); + table.getSortOrder().add(col); + break; + } + } + } + } + + private void restoreColumnOrder() { + String[] columnIds = Config.getInstance().getSettings().recordLaterColumnIds; + ObservableList> columns = table.getColumns(); + for (int i = 0; i < columnIds.length; i++) { + for (int j = 0; j < table.getColumns().size(); j++) { + if(Objects.equals(columnIds[i], columns.get(j).getId())) { + TableColumn col = columns.get(j); + columns.remove(j); // NOSONAR + columns.add(i, col); + } + } + } + } + + private void restoreColumnWidths() { + double[] columnWidths = Config.getInstance().getSettings().recordLaterColumnWidths; + if (columnWidths != null && columnWidths.length == table.getColumns().size()) { + for (int i = 0; i < columnWidths.length; i++) { + table.getColumns().get(i).setPrefWidth(columnWidths[i]); + } + } + } + + private class ClickableCellFactory implements Callback, TableCell> { + @Override + public TableCell call(TableColumn param) { + TableCell cell = new TableCell<>() { + @Override + protected void updateItem(Object item, boolean empty) { + setText(empty ? "" : Objects.toString(item)); + } + }; + + cell.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + JavaFxModel selectedModel = table.getSelectionModel().getSelectedItem(); + if (selectedModel != null) { + new PlayAction(table, selectedModel).execute(); + } + } + }); + return cell; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java index 9c9dbc69..d60a2411 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java @@ -23,6 +23,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -49,6 +50,7 @@ import ctbrec.ui.action.PlayAction; import ctbrec.ui.action.RemoveTimeLimitAction; import ctbrec.ui.action.ResumeAction; import ctbrec.ui.action.SetStopDateAction; +import ctbrec.ui.action.StartRecordingAction; import ctbrec.ui.action.StopRecordingAction; import ctbrec.ui.action.ToggleRecordingAction; import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; @@ -297,7 +299,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { checkModelAccountExistance.setPadding(new Insets(5)); checkModelAccountExistance.setTooltip(new Tooltip("Go over all model URLs and check, if the account still exists")); HBox.setMargin(checkModelAccountExistance, new Insets(0, 0, 0, 20)); - checkModelAccountExistance.setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder).execute()); + checkModelAccountExistance.setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder) + .execute(Predicate.not(Model::isMarkedForLaterRecording))); HBox filterContainer = new HBox(); filterContainer.setSpacing(0); @@ -584,6 +587,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { List onlineModels = recorder.getOnlineModels(); return recorder.getModels() .stream() + .filter(Predicate.not(Model::isMarkedForLaterRecording)) .map(JavaFxModel::new) .peek(fxm -> { // NOSONAR for (Recording recording : recordings) { @@ -641,6 +645,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } MenuItem stop = new MenuItem("Remove Model"); stop.setOnAction(e -> stopAction(selectedModels)); + MenuItem recordLater = new MenuItem("Record Later"); + recordLater.setOnAction(e -> recordLater(selectedModels)); MenuItem copyUrl = new MenuItem("Copy URL"); copyUrl.setOnAction(e -> { @@ -675,7 +681,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { MenuItem openRecDir = new MenuItem("Open recording directory"); openRecDir.setOnAction(e -> new OpenRecordingsDir(table, selectedModels.get(0)).execute()); - ContextMenu menu = new CustomMouseBehaviorContextMenu(stop); + ContextMenu menu = new CustomMouseBehaviorContextMenu(stop, recordLater); if (selectedModels.size() == 1) { menu.getItems().add(selectedModels.get(0).isSuspended() ? resumeRecording : pauseRecording); menu.getItems().add(stopRecordingAt); @@ -772,7 +778,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { alert.showAndWait(); } - private void stopAction(List selectedModels) { + private boolean stopAction(List selectedModels) { boolean confirmed = true; if (Config.getInstance().getSettings().confirmationForDangerousActions) { int n = selectedModels.size(); @@ -787,6 +793,21 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { table.getItems().remove(m); })); } + return confirmed; + } + + private void recordLater(List selectedModels) { + boolean confirmed = stopAction(selectedModels); + if (confirmed) { + List models = selectedModels.stream() + .map(JavaFxModel::getDelegate) + .map(m -> { + m.setMarkedForLaterRecording(true); + return m; + }) + .collect(Collectors.toList()); + new StartRecordingAction(table, models, recorder).execute(); + } } private void pauseRecording(List selectedModels) { diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java new file mode 100644 index 00000000..bc33c61a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java @@ -0,0 +1,61 @@ +package ctbrec.ui.tabs; + +import java.util.List; + +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Side; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; + +public class RecordedTab extends Tab implements TabSelectionListener { + + private TabPane tabPane; + private RecordedModelsTab recordedModelsTab; + private RecordLaterTab recordLaterTab; + + public RecordedTab(Recorder recorder, List sites) { + super("Recording"); + setClosable(false); + + recordedModelsTab = new RecordedModelsTab("Active", recorder, sites); + recordLaterTab = new RecordLaterTab("Later", recorder, sites); + + tabPane = new TabPane(); + tabPane.setSide(Side.LEFT); + tabPane.getTabs().addAll(recordedModelsTab, recordLaterTab); + setContent(tabPane); + + // register changelistener to activate / deactivate tabs, when the user switches between them + tabPane.getSelectionModel().selectedItemProperty().addListener((ChangeListener) (ov, from, to) -> { + if (from instanceof TabSelectionListener) { + ((TabSelectionListener) from).deselected(); + } + if (to instanceof TabSelectionListener) { + ((TabSelectionListener) to).selected(); + } + }); + } + + @Override + public void selected() { + Tab selectedTab = tabPane.getSelectionModel().getSelectedItem(); + if(selectedTab instanceof TabSelectionListener) { + ((TabSelectionListener) selectedTab).selected(); + } + } + + @Override + public void deselected() { + Tab selectedTab = tabPane.getSelectionModel().getSelectedItem(); + if(selectedTab instanceof TabSelectionListener) { + ((TabSelectionListener) selectedTab).deselected(); + } + } + + public void saveState() { + recordedModelsTab.saveState(); + recordLaterTab.saveState(); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java index 8ae9a2f6..d0985810 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java @@ -512,7 +512,7 @@ public class ThumbCell extends StackPane { try { if (start) { recorder.startRecording(model); - setRecording(true); + setRecording(!model.isMarkedForLaterRecording()); } else { recorder.stopRecording(model); setRecording(false); @@ -561,6 +561,11 @@ public class ThumbCell extends StackPane { }); } + void recordLater() { + model.setMarkedForLaterRecording(true); + startStopAction(true); + } + public Model getModel() { return model; } diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java index c12ffc75..fa433a4d 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java @@ -488,7 +488,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { MenuItem addPaused = new MenuItem("Add in paused state"); addPaused.setOnAction(e -> addPaused(getSelectedThumbCells(cell))); MenuItem recordLater = new MenuItem("Record Later"); - recordLater.setOnAction(e -> LOG.debug("Record Later not implemented, yet")); + recordLater.setOnAction(e -> recordLater(getSelectedThumbCells(cell))); MenuItem pause = new MenuItem("Pause Recording"); pause.setOnAction(e -> pauseResumeAction(getSelectedThumbCells(cell), true)); @@ -523,7 +523,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { if(modelIsTrackedByRecorder) { contextMenu.getItems().add(pauseResume); } else { - contextMenu.getItems().addAll(recordUntil, addPaused/*, recordLater*/); + contextMenu.getItems().addAll(recordUntil, addPaused, recordLater); } contextMenu.getItems().add(new SeparatorMenuItem()); if(site.supportsFollow()) { @@ -544,6 +544,12 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { return contextMenu; } + private void recordLater(List list) { + for (ThumbCell cell : list) { + cell.recordLater(); + } + } + private void startRecordingWithTimeLimit(List list) { for (ThumbCell cell : list) { cell.startStopAction(true); diff --git a/common/src/main/java/ctbrec/AbstractModel.java b/common/src/main/java/ctbrec/AbstractModel.java index 8c16d499..b0ab9814 100644 --- a/common/src/main/java/ctbrec/AbstractModel.java +++ b/common/src/main/java/ctbrec/AbstractModel.java @@ -33,6 +33,7 @@ public abstract class AbstractModel implements Model { private int streamUrlIndex = -1; private int priority = 50; private boolean suspended = false; + private boolean markedForLaterRecording = false; protected transient Site site; protected State onlineState = State.UNKNOWN; private Instant lastSeen; @@ -145,6 +146,16 @@ public abstract class AbstractModel implements Model { this.suspended = suspended; } + @Override + public boolean isMarkedForLaterRecording() { + return markedForLaterRecording; + } + + @Override + public void setMarkedForLaterRecording(boolean marked) { + this.markedForLaterRecording = marked; + } + @Override public State getOnlineState(boolean failFast) throws IOException, ExecutionException { return onlineState; diff --git a/common/src/main/java/ctbrec/Model.java b/common/src/main/java/ctbrec/Model.java index 1a75a170..551ac5c4 100644 --- a/common/src/main/java/ctbrec/Model.java +++ b/common/src/main/java/ctbrec/Model.java @@ -122,6 +122,10 @@ public interface Model extends Comparable, Serializable { public void setSuspended(boolean suspended); + public boolean isMarkedForLaterRecording(); + + public void setMarkedForLaterRecording(boolean marked); + public Download createDownload(); public void setPriority(int priority); diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 8cd2cfee..73ed55bb 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -118,6 +118,10 @@ public class Settings { public String proxyPort; public ProxyType proxyType = ProxyType.DIRECT; public String proxyUser; + public double[] recordLaterColumnWidths = new double[0]; + public String[] recordLaterColumnIds = new String[0]; + public String recordLaterSortColumn = ""; + public String recordLaterSortType = ""; public double[] recordedModelsColumnWidths = new double[0]; public String[] recordedModelsColumnIds = new String[0]; public String recordedModelsSortColumn = ""; @@ -128,6 +132,7 @@ public class Settings { public DirectoryStructure recordingsDirStructure = DirectoryStructure.FLAT; public String recordingsSortColumn = ""; public String recordingsSortType = ""; + public List recordLater = new ArrayList<>(); public boolean recordSingleFile = false; public boolean removeRecordingAfterPostProcessing = false; public boolean requireAuthentication = false; diff --git a/common/src/main/java/ctbrec/io/ModelJsonAdapter.java b/common/src/main/java/ctbrec/io/ModelJsonAdapter.java index a15172ff..7f585968 100644 --- a/common/src/main/java/ctbrec/io/ModelJsonAdapter.java +++ b/common/src/main/java/ctbrec/io/ModelJsonAdapter.java @@ -35,51 +35,41 @@ public class ModelJsonAdapter extends JsonAdapter { @Override public Model fromJson(JsonReader reader) throws IOException { reader.beginObject(); - String name = null; - String description = null; - String url = null; Object type = null; - int streamUrlIndex = -1; - int priority; - boolean suspended = false; - Model model = null; - while(reader.hasNext()) { + + while (reader.hasNext()) { try { Token token = reader.peek(); - if(token == Token.NAME) { + if (token == Token.NAME) { String key = reader.nextName(); - if(key.equals("type")) { + if (key.equals("type")) { type = reader.readJsonValue(); Class modelClass = Class.forName(Optional.ofNullable(type).orElse(ChaturbateModel.class.getName()).toString()); model = (Model) modelClass.getDeclaredConstructor().newInstance(); - } else if(key.equals("name")) { - name = reader.nextString(); - model.setName(name); - } else if(key.equals("description")) { - description = reader.nextString(); - model.setDescription(description); - } else if(key.equals("url")) { - url = reader.nextString(); - model.setUrl(url); - } else if(key.equals("priority")) { - priority = reader.nextInt(); - model.setPriority(priority); - } else if(key.equals("streamUrlIndex")) { - streamUrlIndex = reader.nextInt(); - model.setStreamUrlIndex(streamUrlIndex); - } else if(key.equals("suspended")) { - suspended = reader.nextBoolean(); - model.setSuspended(suspended); - } else if(key.equals("lastSeen")) { + } else if (key.equals("name")) { + model.setName(reader.nextString()); + } else if (key.equals("description")) { + model.setDescription(reader.nextString()); + } else if (key.equals("url")) { + model.setUrl(reader.nextString()); + } else if (key.equals("priority")) { + model.setPriority(reader.nextInt()); + } else if (key.equals("streamUrlIndex")) { + model.setStreamUrlIndex(reader.nextInt()); + } else if (key.equals("suspended")) { + model.setSuspended(reader.nextBoolean()); + } else if (key.equals("markedForLater")) { + model.setMarkedForLaterRecording(reader.nextBoolean()); + } else if (key.equals("lastSeen")) { model.setLastSeen(Instant.ofEpochMilli(reader.nextLong())); - } else if(key.equals("lastRecorded")) { + } else if (key.equals("lastRecorded")) { model.setLastRecorded(Instant.ofEpochMilli(reader.nextLong())); - } else if(key.equals("recordUntil")) { + } else if (key.equals("recordUntil")) { model.setRecordUntil(Instant.ofEpochMilli(reader.nextLong())); - } else if(key.equals("recordUntilSubsequentAction")) { + } else if (key.equals("recordUntilSubsequentAction")) { model.setRecordUntilSubsequentAction(SubsequentAction.valueOf(reader.nextString())); - } else if(key.equals("siteSpecific")) { + } else if (key.equals("siteSpecific")) { reader.beginObject(); try { model.readSiteSpecificData(reader); @@ -92,15 +82,16 @@ public class ModelJsonAdapter extends JsonAdapter { } else { reader.skipValue(); } - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) { + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { throw new IOException("Couldn't instantiate model class [" + type + "]", e); } } reader.endObject(); - if(sites != null) { + if (sites != null) { for (Site site : sites) { - if(site.isSiteForModel(model)) { + if (site.isSiteForModel(model)) { model.setSite(site); } } @@ -118,6 +109,7 @@ public class ModelJsonAdapter extends JsonAdapter { writer.name("priority").value(model.getPriority()); writer.name("streamUrlIndex").value(model.getStreamUrlIndex()); writer.name("suspended").value(model.isSuspended()); + writer.name("markedForLater").value(model.isMarkedForLaterRecording()); writer.name("lastSeen").value(model.getLastSeen().toEpochMilli()); writer.name("lastRecorded").value(model.getLastRecorded().toEpochMilli()); writer.name("recordUntil").value(model.getRecordUntil().toEpochMilli()); @@ -130,7 +122,7 @@ public class ModelJsonAdapter extends JsonAdapter { } private void writeValueIfSet(JsonWriter writer, String name, String value) throws IOException { - if(value != null) { + if (value != null) { writer.name(name).value(value); } } diff --git a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java index 74c81669..ac3d08a6 100644 --- a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java @@ -215,7 +215,7 @@ public class NextGenLocalRecorder implements Recorder { } } - private CompletableFuture startRecordingProcess(Model model) throws IOException { + private CompletableFuture startRecordingProcess(Model model) { return CompletableFuture.runAsync(() -> { recorderLock.lock(); try { @@ -287,7 +287,7 @@ public class NextGenLocalRecorder implements Recorder { Recording rec = new Recording(); rec.setId(UUID.randomUUID().toString()); rec.setDownload(download); - String recordingFile = download.getPath(model).replaceAll("\\\\", "/"); + String recordingFile = download.getPath(model).replace("\\\\", "/"); File absoluteFile = new File(config.getSettings().recordingsDir, recordingFile); rec.setAbsoluteFile(absoluteFile); rec.setModel(model); @@ -367,10 +367,22 @@ public class NextGenLocalRecorder implements Recorder { } private void stopRecordingProcesses() { + LOG.debug("Stopping all recording processes"); recorderLock.lock(); try { - for (Recording rec : recordingProcesses.values()) { - rec.getDownload().stop(); + // make a copy to avoid ConcurrentModificationException + List toStop = new ArrayList<>(recordingProcesses.values()); + if (!toStop.isEmpty()) { + ExecutorService shutdownPool = Executors.newFixedThreadPool(toStop.size()); + for (Recording rec : toStop) { + Optional.ofNullable(rec.getDownload()).ifPresent(d -> shutdownPool.submit(d::stop)); + } + shutdownPool.shutdown(); + try { + shutdownPool.awaitTermination(10, TimeUnit.MINUTES); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } } finally { recorderLock.unlock(); @@ -381,7 +393,13 @@ public class NextGenLocalRecorder implements Recorder { public boolean isTracked(Model model) { recorderLock.lock(); try { - return models.contains(model); + int index = models.indexOf(model); + if (index >= 0) { + Model modelFromList = models.get(index); + return !modelFromList.isMarkedForLaterRecording(); + } else { + return false; + } } finally { recorderLock.unlock(); } @@ -409,63 +427,44 @@ public class NextGenLocalRecorder implements Recorder { @Override public void shutdown(boolean immediately) { - // TODO add a config flag for waitign or stopping immediately LOG.info("Shutting down"); recording = false; if (!immediately) { - LOG.debug("Stopping all recording processes"); - recorderLock.lock(); - try { - // make a copy to avoid ConcurrentModificationException - List toStop = new ArrayList<>(recordingProcesses.values()); - if (!toStop.isEmpty()) { - ExecutorService shutdownPool = Executors.newFixedThreadPool(toStop.size()); - List> shutdownFutures = new ArrayList<>(toStop.size()); - for (Recording rec : toStop) { - Optional.ofNullable(rec.getDownload()).ifPresent(d -> { - shutdownFutures.add(shutdownPool.submit(() -> d.stop())); - }); - } - shutdownPool.shutdown(); - try { - shutdownPool.awaitTermination(10, TimeUnit.MINUTES); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - } finally { - recorderLock.unlock(); - } + stopRecordingProcesses(); + awaitDownloadsFinish(); + shutdownThreadPools(); + } + } - // wait for downloads to finish - LOG.info("Waiting for downloads to finish"); - for (int i = 0; i < 60; i++) { - if (!recordingProcesses.isEmpty()) { - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOG.error("Error while waiting for downloads to finish", e); - } + private void awaitDownloadsFinish() { + LOG.info("Waiting for downloads to finish"); + for (int i = 0; i < 60; i++) { + if (!recordingProcesses.isEmpty()) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.error("Error while waiting for downloads to finish", e); } } + } + } - // shutdown threadpools - try { - LOG.info("Shutting down download pool"); - downloadPool.shutdown(); - client.shutdown(); - downloadPool.awaitTermination(1, TimeUnit.MINUTES); - LOG.info("Shutting down post-processing pool"); - ppPool.shutdown(); - int minutesToWait = 10; - LOG.info("Waiting {} minutes (max) for post-processing to finish", minutesToWait); - ppPool.awaitTermination(minutesToWait, TimeUnit.MINUTES); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOG.error("Error while waiting for pools to finish", e); - } + private void shutdownThreadPools() { + try { + LOG.info("Shutting down download pool"); + downloadPool.shutdown(); + client.shutdown(); + downloadPool.awaitTermination(1, TimeUnit.MINUTES); + LOG.info("Shutting down post-processing pool"); + ppPool.shutdown(); + int minutesToWait = 10; + LOG.info("Waiting {} minutes (max) for post-processing to finish", minutesToWait); + ppPool.awaitTermination(minutesToWait, TimeUnit.MINUTES); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.error("Error while waiting for pools to finish", e); } } @@ -500,7 +499,9 @@ public class NextGenLocalRecorder implements Recorder { int index = models.indexOf(model); Model m = models.get(index); m.setSuspended(false); + m.setMarkedForLaterRecording(false); model.setSuspended(false); + model.setMarkedForLaterRecording(false); config.save(); startRecordingProcess(m); } else { @@ -651,7 +652,6 @@ public class NextGenLocalRecorder implements Recorder { config.save(); } else { LOG.warn("Couldn't change priority for model {}. Not found in list", model.getName()); - return; } } catch (IOException e) { LOG.error("Couldn't save config", e); diff --git a/common/src/main/java/ctbrec/recorder/OnlineMonitor.java b/common/src/main/java/ctbrec/recorder/OnlineMonitor.java index 166fdf32..93a4a93c 100644 --- a/common/src/main/java/ctbrec/recorder/OnlineMonitor.java +++ b/common/src/main/java/ctbrec/recorder/OnlineMonitor.java @@ -82,7 +82,9 @@ public class OnlineMonitor extends Thread { // submit online check jobs to the executor for the model's site List> futures = new LinkedList<>(); for (Model model : models) { - if (config.getSettings().onlineCheckSkipsPausedModels && model.isSuspended()) { + boolean skipCheckForSuspended = config.getSettings().onlineCheckSkipsPausedModels && model.isSuspended(); + boolean skipCheckForMarkedAsLater = model.isMarkedForLaterRecording(); + if (skipCheckForSuspended || skipCheckForMarkedAsLater) { continue; } else { futures.add(updateModel(model)); diff --git a/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java b/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java index 9f526cca..b1008beb 100644 --- a/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java +++ b/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java @@ -29,6 +29,7 @@ public class RecordingPreconditions { void check(Model model) throws IOException { ensureRecorderIsActive(); ensureModelIsNotSuspended(model); + ensureModelIsNotMarkedForLaterRecording(model); ensureRecordUntilIsInFuture(model); ensureNoRecordingRunningForModel(model); ensureModelShouldBeRecorded(model); @@ -113,6 +114,12 @@ public class RecordingPreconditions { } } + private void ensureModelIsNotMarkedForLaterRecording(Model model) { + if (model.isMarkedForLaterRecording()) { + throw new PreconditionNotMetException("Model " + model + " is marked for later recording"); + } + } + private void ensureRecorderIsActive() { if (!recorder.isRecording()) { throw new PreconditionNotMetException("Recorder is not in recording mode");