diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 94edea00..c5df0f6e 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -63,12 +63,12 @@ import ctbrec.ui.settings.SettingsTab; import ctbrec.ui.tabs.DonateTabFx; import ctbrec.ui.tabs.HelpTab; import ctbrec.ui.tabs.RecentlyWatchedTab; -import ctbrec.ui.tabs.RecordedTab; import ctbrec.ui.tabs.RecordingsTab; import ctbrec.ui.tabs.SiteTab; import ctbrec.ui.tabs.TabSelectionListener; import ctbrec.ui.tabs.UpdateTab; import ctbrec.ui.tabs.logging.LoggingTab; +import ctbrec.ui.tabs.recorded.RecordedTab; import javafx.application.Application; import javafx.application.HostServices; import javafx.application.Platform; diff --git a/client/src/main/java/ctbrec/ui/Icon.java b/client/src/main/java/ctbrec/ui/Icon.java new file mode 100644 index 00000000..2f55c911 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/Icon.java @@ -0,0 +1,18 @@ +package ctbrec.ui; + +public enum Icon { + + GROUP_16(Icon.class.getResource("/16/users.png").toExternalForm()), + CHECK_16(Icon.class.getResource("/16/check-small.png").toExternalForm()), + CLOCK_16(Icon.class.getResource("/16/clock.png").toExternalForm()); + + private String url; + + private Icon(String url) { + this.url = url; + } + + public String url() { + return url; + } +} diff --git a/client/src/main/java/ctbrec/ui/JavaFxModel.java b/client/src/main/java/ctbrec/ui/JavaFxModel.java index b160a0f1..c76bc9ee 100644 --- a/client/src/main/java/ctbrec/ui/JavaFxModel.java +++ b/client/src/main/java/ctbrec/ui/JavaFxModel.java @@ -22,15 +22,13 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; /** * Just a wrapper for Model, which augments it with JavaFX value binding properties, so that UI widgets get updated proeprly */ public class JavaFxModel implements Model { - private transient StringProperty onlineProperty = new SimpleStringProperty(); - private transient StringProperty recordingProperty = new SimpleStringProperty(); + private transient BooleanProperty onlineProperty = new SimpleBooleanProperty(); + private transient BooleanProperty recordingProperty = new SimpleBooleanProperty(); private transient BooleanProperty pausedProperty = new SimpleBooleanProperty(); private transient SimpleIntegerProperty priorityProperty = new SimpleIntegerProperty(); private transient SimpleObjectProperty lastSeenProperty = new SimpleObjectProperty<>(); @@ -105,14 +103,22 @@ public class JavaFxModel implements Model { return delegate.toString(); } - public StringProperty getOnlineProperty() { + public BooleanProperty getOnlineProperty() { return onlineProperty; } - public StringProperty getRecordingProperty() { + public void setOnlineProperty(boolean online) { + this.onlineProperty.set(online); + } + + public BooleanProperty getRecordingProperty() { return recordingProperty; } + public void setRecordingProperty(boolean recording) { + this.recordingProperty.setValue(recording); + } + public BooleanProperty getPausedProperty() { return pausedProperty; } @@ -329,6 +335,4 @@ public class JavaFxModel implements Model { public void setMarkedForLaterRecording(boolean marked) { delegate.setMarkedForLaterRecording(marked); } - - } diff --git a/client/src/main/java/ctbrec/ui/action/AddToGroupAction.java b/client/src/main/java/ctbrec/ui/action/AddToGroupAction.java new file mode 100644 index 00000000..cf2bf057 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/AddToGroupAction.java @@ -0,0 +1,185 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.StringUtil; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.autocomplete.ObservableListSuggester; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Region; + +public class AddToGroupAction { + + private Node source; + private Model model; + private Recorder recorder; + + public AddToGroupAction(Node source, Recorder recorder, Model model) { + this.source = source; + this.recorder = recorder; + this.model = model; + } + + public void execute() { + source.setCursor(Cursor.WAIT); + try { + var dialog = new AddModelGroupDialog(); + boolean ok = Dialogs.showCustomInput(source.getScene(), "Add model to group", dialog.getMainPane()); + dialog.requestFocus(); + if (ok) { + String text = dialog.getText(); + if (StringUtil.isBlank(text)) { + return; + } + Set modelGroups = recorder.getModelGroups(); + Optional existingGroup = modelGroups.stream().filter(mg -> mg.getName().equalsIgnoreCase(text)).findFirst(); + if (existingGroup.isPresent()) { + existingGroup.get().add(model); + recorder.saveModelGroup(existingGroup.get()); + } else { + var group = new ModelGroup(); + group.setId(UUID.randomUUID()); + group.setName(text); + group.add(model); + modelGroups.add(group); + recorder.saveModelGroup(group); + } + } + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException e) { + Dialogs.showError(source.getScene(), "Add model to group", "Saving model group failed", e); + } finally { + source.setCursor(Cursor.DEFAULT); + } + } + + private static class AddModelGroupDialog { + private ComboBox comboBox; + private TextField editor; + private ObservableListSuggester suggester; + + public String getText() { + return comboBox.getEditor().getText(); + } + + public void requestFocus() { + comboBox.requestFocus(); + editor.requestFocus(); + } + + Region getMainPane() { + var dialogPane = new GridPane(); + Set modelGroups = Config.getInstance().getSettings().modelGroups; + List comboBoxItems = modelGroups.stream().map(ModelGroupListItem::new).sorted().collect(Collectors.toList()); + ObservableList comboBoxModel = FXCollections.observableArrayList(comboBoxItems); + suggester = new ObservableListSuggester(comboBoxModel); + comboBox = new ComboBox<>(comboBoxModel); + comboBox.setEditable(true); + editor = comboBox.getEditor(); + comboBox.getEditor().addEventHandler(KeyEvent.KEY_RELEASED, evt -> { + if (evt.getCode().isLetterKey() || evt.getCode().isDigitKey()) { + autocomplete(false); + } else if (evt.getCode() == KeyCode.ENTER) { + if (editor.getSelection().getLength() > 0) { + editor.selectRange(0, 0); + editor.insertText(editor.lengthProperty().get(), ":"); + editor.positionCaret(editor.lengthProperty().get()); + evt.consume(); + } + } else if (evt.getCode() == KeyCode.SPACE && evt.isControlDown()) { + autocomplete(true); + } + }); + comboBox.setPlaceholder(new Label(" type in a name to a add a new group ")); + dialogPane.add(new Label("Model group "), 0, 0); + dialogPane.add(comboBox, 1, 0); + return dialogPane; + } + + private void autocomplete(boolean fulltextSearch) { + String oldtext = getOldText(); + if(oldtext.isEmpty()) { + return; + } + + Optional match; + if (fulltextSearch) { + match = suggester.fulltext(oldtext); + } else { + match = suggester.startsWith(oldtext); + } + + if (match.isPresent()) { + editor.setText(match.get()); + int pos = oldtext.length(); + editor.positionCaret(pos); + editor.selectRange(pos, match.get().length()); + } + } + + private String getOldText() { + if(editor.getSelection().getLength() > 0) { + return editor.getText().substring(0, editor.getSelection().getStart()); + } else { + return editor.getText(); + } + } + } + + private static class ModelGroupListItem implements Comparable { + private ModelGroup modelGroup; + + public ModelGroupListItem(ModelGroup modelGroup) { + this.modelGroup = modelGroup; + } + + @Override + public String toString() { + return this.modelGroup.getName(); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(modelGroup); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ModelGroupListItem other = (ModelGroupListItem) obj; + return java.util.Objects.equals(modelGroup, other.modelGroup); + } + + @Override + public int compareTo(ModelGroupListItem o) { + return this.modelGroup.getName().compareTo(o.modelGroup.getName()); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/EditGroupAction.java b/client/src/main/java/ctbrec/ui/action/EditGroupAction.java new file mode 100644 index 00000000..602d0190 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/EditGroupAction.java @@ -0,0 +1,174 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +public class EditGroupAction { + + private static final String DIALOG_TITLE = "Edit model group"; + + private Node source; + private Model model; + private Recorder recorder; + + public EditGroupAction(Node source, Recorder recorder, Model model) { + this.source = source; + this.recorder = recorder; + this.model = model; + } + + public void execute() { + source.setCursor(Cursor.WAIT); + try { + var dialog = new EditModelGroupDialog(model); + boolean ok = Dialogs.showCustomInput(source.getScene(), DIALOG_TITLE, dialog); + if (ok) { + var group = dialog.getModelGroup(); + group.setName(dialog.getGroupName()); + group.getModelUrls().clear(); + group.getModelUrls().addAll(dialog.getUrls()); + recorder.saveModelGroup(group); + if (dialog.getUrls().isEmpty()) { + boolean delete = Dialogs.showConfirmDialog(DIALOG_TITLE, "Do you want to delete the group?", "Group is empty", source.getScene()); + if (delete) { + recorder.deleteModelGroup(group); + } + } + } + } catch (Exception e) { + Dialogs.showError(source.getScene(), DIALOG_TITLE, "Editing model group failed", e); + } finally { + source.setCursor(Cursor.DEFAULT); + } + } + + private class EditModelGroupDialog extends GridPane { + private TextField groupName; + private ListView urlListView; + private ObservableList urlList; + private ModelGroup modelGroup; + private List urls; + + public EditModelGroupDialog(Model model) { + Optional optionalModelGroup; + try { + optionalModelGroup = recorder.getModelGroup(model); + if (optionalModelGroup.isPresent()) { + modelGroup = optionalModelGroup.get(); + urls = new ArrayList<>(modelGroup.getModelUrls()); + createGui(modelGroup); + } else { + Dialogs.showError(getScene(), DIALOG_TITLE, "No group found for model", null); + } + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Dialogs.showError(getScene(), DIALOG_TITLE, "Couldn't edit model group", e); + } + } + + public ModelGroup getModelGroup() { + return modelGroup; + } + + public List getUrls() { + return urls; + } + + public String getGroupName() { + return groupName.getText(); + } + + void createGui(ModelGroup modelGroup) { + setHgap(5); + vgapProperty().bind(hgapProperty()); + + groupName = new TextField(modelGroup.getName()); + Button up = createUpButton(); + Button down = createDownButton(); + Button remove = createRemoveButton(); + var buttons = new VBox(3, up, down, remove); + urlList = FXCollections.observableList(modelGroup.getModelUrls()); + urlList.addListener((ListChangeListener) change -> { + urls = new ArrayList<>(urlList); + }); + urlListView = new ListView<>(urlList); + GridPane.setHgrow(urlListView, Priority.ALWAYS); + + var row = 0; + add(groupName, 0, row++); + add(urlListView, 0, row); + add(buttons, 1, row); + + urlListView.getSelectionModel().selectedIndexProperty().addListener((obs, oldV, newV) -> { + var idx = newV.intValue(); + boolean noSelection = idx == -1; + up.setDisable(noSelection || idx == 0); + down.setDisable(noSelection || idx == urlList.size() - 1); + remove.setDisable(noSelection); + }); + } + + private Button createUpButton() { + var button = createButton("\u25B4", "Move step up"); + button.setOnAction(evt -> { + int idx = urlListView.getSelectionModel().getSelectedIndex(); + String selectedItem = urlListView.getSelectionModel().getSelectedItem(); + urlList.remove(idx); + urlList.add(idx - 1, selectedItem); + urlListView.getSelectionModel().select(idx - 1); + }); + return button; + } + + private Button createDownButton() { + var button = createButton("\u25BE", "Move step down"); + button.setOnAction(evt -> { + int idx = urlListView.getSelectionModel().getSelectedIndex(); + String selectedItem = urlListView.getSelectionModel().getSelectedItem(); + urlList.remove(idx); + urlList.add(idx + 1, selectedItem); + urlListView.getSelectionModel().select(idx + 1); + }); + return button; + } + + private Button createRemoveButton() { + var button = createButton("-", "Remove selected step"); + button.setOnAction(evt -> { + String selectedItem = urlListView.getSelectionModel().getSelectedItem(); + if (selectedItem != null) { + urlList.remove(selectedItem); + } + }); + return button; + } + + private Button createButton(String text, String tooltip) { + var b = new Button(text); + b.setTooltip(new Tooltip(tooltip)); + b.setDisable(true); + b.setPrefSize(32, 32); + return b; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/Dialogs.java b/client/src/main/java/ctbrec/ui/controls/Dialogs.java index f9e3d78f..1e5292ec 100644 --- a/client/src/main/java/ctbrec/ui/controls/Dialogs.java +++ b/client/src/main/java/ctbrec/ui/controls/Dialogs.java @@ -4,16 +4,26 @@ import static javafx.scene.control.ButtonType.*; import java.io.InputStream; import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.StringUtil; import ctbrec.ui.AutosizeAlert; import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.scene.Scene; import javafx.scene.control.Alert; import javafx.scene.control.Alert.AlertType; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; +import javafx.scene.control.ComboBox; import javafx.scene.control.Dialog; +import javafx.scene.control.Label; import javafx.scene.control.TextArea; import javafx.scene.image.Image; import javafx.scene.layout.GridPane; @@ -46,7 +56,7 @@ public class Dialogs { } alert.setContentText(content); if (parent != null) { - Stage stage = (Stage) alert.getDialogPane().getScene().getWindow(); + var stage = (Stage) alert.getDialogPane().getScene().getWindow(); stage.getScene().getStylesheets().addAll(parent.getStylesheets()); } alert.showAndWait(); @@ -63,22 +73,22 @@ public class Dialogs { Dialog dialog = new Dialog<>(); dialog.setTitle(title); dialog.setHeaderText(header); - dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + dialog.getDialogPane().getButtonTypes().addAll(OK, CANCEL); dialog.initModality(Modality.APPLICATION_MODAL); dialog.setResizable(true); InputStream icon = Dialogs.class.getResourceAsStream("/icon.png"); - Stage stage = (Stage) dialog.getDialogPane().getScene().getWindow(); + var stage = (Stage) dialog.getDialogPane().getScene().getWindow(); stage.getIcons().add(new Image(icon)); if (parent != null) { stage.getScene().getStylesheets().addAll(parent.getStylesheets()); } - GridPane grid = new GridPane(); + var grid = new GridPane(); grid.setHgap(10); grid.setVgap(10); grid.setPadding(new Insets(20, 150, 10, 10)); - TextArea notes = new TextArea(text); + var notes = new TextArea(text); notes.setPrefRowCount(3); grid.add(notes, 0, 0); dialog.getDialogPane().setContent(grid); @@ -86,7 +96,7 @@ public class Dialogs { Platform.runLater(notes::requestFocus); dialog.setResultConverter(dialogButton -> { - if (dialogButton == ButtonType.OK) { + if (dialogButton == OK) { return notes.getText(); } return null; @@ -98,31 +108,31 @@ public class Dialogs { 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.getDialogPane().getButtonTypes().addAll(OK, CANCEL); dialog.initModality(Modality.APPLICATION_MODAL); dialog.setResizable(true); InputStream icon = Dialogs.class.getResourceAsStream("/icon.png"); - Stage stage = (Stage) dialog.getDialogPane().getScene().getWindow(); + var 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; + return dialog.getResult() == OK; } public static boolean showConfirmDialog(String title, String message, String header, Scene parent) { - AutosizeAlert confirm = new AutosizeAlert(AlertType.CONFIRMATION, message, parent, YES, NO); + var confirm = new AutosizeAlert(AlertType.CONFIRMATION, message, parent, YES, NO); confirm.setTitle(title); confirm.setHeaderText(header); confirm.showAndWait(); - return confirm.getResult() == ButtonType.YES; + return confirm.getResult() == YES; } public static ButtonType showShutdownDialog(Scene parent) { - String message = "There are recordings in progress"; - AutosizeAlert confirm = new AutosizeAlert(AlertType.CONFIRMATION, "", parent, YES, FINISH, NO); + var message = "There are recordings in progress"; + var confirm = new AutosizeAlert(AlertType.CONFIRMATION, "", parent, YES, FINISH, NO); confirm.setTitle("Shutdown"); confirm.setHeaderText(message); ((Button) confirm.getDialogPane().lookupButton(ButtonType.YES)).setText("Shutdown Now"); @@ -134,4 +144,36 @@ public class Dialogs { confirm.showAndWait(); return confirm.getResult(); } + + public static Optional showModelGroupSelectionDialog(Scene parent, Model model) { + var dialogPane = new GridPane(); + Set modelGroups = Config.getInstance().getSettings().modelGroups; + ObservableList comboBoxModel = FXCollections.observableArrayList(modelGroups); + ComboBox comboBox = new ComboBox<>(comboBoxModel); + comboBox.setEditable(true); + comboBox.setPlaceholder(new Label(" type in a name to a add a new group ")); + dialogPane.add(new Label("Model group"), 0, 0); + dialogPane.add(comboBox, 1, 0); + boolean ok = showCustomInput(parent, "Add model to group", dialogPane); + if (ok) { + String text = comboBox.getEditor().getText(); + if (StringUtil.isBlank(text)) { + return Optional.empty(); + } + Optional existingGroup = modelGroups.stream().filter(mg -> mg.getName().equalsIgnoreCase(text)).findFirst(); + if (existingGroup.isPresent()) { + existingGroup.get().add(model); + return existingGroup; + } else { + var group = new ModelGroup(); + group.setId(UUID.randomUUID()); + group.setName(text); + group.add(model); + modelGroups.add(group); + return Optional.of(group); + } + } else { + return Optional.empty(); + } + } } diff --git a/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java b/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java index c97da77a..787b042c 100644 --- a/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java +++ b/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java @@ -2,10 +2,8 @@ package ctbrec.ui.tabs; import java.io.IOException; -import ctbrec.Config; -import ctbrec.Recording; -import ctbrec.recorder.RecordingManager; import ctbrec.recorder.postprocessing.AbstractPlaceholderAwarePostProcessor; +import ctbrec.recorder.postprocessing.PostProcessingContext; public class DownloadPostprocessor extends AbstractPlaceholderAwarePostProcessor { @@ -15,7 +13,7 @@ public class DownloadPostprocessor extends AbstractPlaceholderAwarePostProcessor } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { // nothing really to do in here, we just inherit from AbstractPlaceholderAwarePostProcessor to use fillInPlaceHolders return true; } diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java index f36083e0..7338cccb 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java @@ -40,6 +40,7 @@ import ctbrec.io.UrlUtil; import ctbrec.recorder.ProgressListener; import ctbrec.recorder.Recorder; import ctbrec.recorder.RecordingPinnedException; +import ctbrec.recorder.postprocessing.PostProcessingContext; import ctbrec.ui.AutosizeAlert; import ctbrec.ui.CamrecApplication; import ctbrec.ui.DesktopIntegration; @@ -688,7 +689,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener, Shutdown } else { String downloadFilename = config.getSettings().downloadFilename; String fileSuffix = config.getSettings().ffmpegFileSuffix; - return new DownloadPostprocessor().fillInPlaceHolders(downloadFilename, recording, config) + '.' + fileSuffix; + PostProcessingContext ctx = new PostProcessingContext(); + ctx.setRecording(recording); + ctx.setConfig(config); + return new DownloadPostprocessor().fillInPlaceHolders(downloadFilename, ctx) + '.' + fileSuffix; } } diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java index 440f1f19..312949e9 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java @@ -4,6 +4,8 @@ import static ctbrec.ui.controls.Dialogs.*; import java.io.IOException; import java.net.SocketTimeoutException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Collections; @@ -29,6 +31,7 @@ import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.GlobalThreadPool; import ctbrec.Model; +import ctbrec.ModelGroup; import ctbrec.event.EventBusHolder; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; @@ -39,10 +42,13 @@ import ctbrec.ui.DesktopIntegration; import ctbrec.ui.SiteUiFactory; import ctbrec.ui.TipDialog; import ctbrec.ui.TokenLabel; +import ctbrec.ui.action.AddToGroupAction; +import ctbrec.ui.action.EditGroupAction; import ctbrec.ui.action.IgnoreModelsAction; import ctbrec.ui.action.OpenRecordingsDir; import ctbrec.ui.action.SetStopDateAction; import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.FasterVerticalScrollPaneSkin; import ctbrec.ui.controls.SearchBox; import ctbrec.ui.controls.SearchPopover; @@ -467,52 +473,57 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { } private ContextMenu createContextMenu(ThumbCell cell) { - Model model = cell.getModel(); + var model = cell.getModel(); boolean modelIsTrackedByRecorder = recorder.isTracked(model); - MenuItem openInPlayer = new MenuItem("Open in Player"); + var openInPlayer = new MenuItem("Open in Player"); openInPlayer.setOnAction(e -> startPlayer(getSelectedThumbCells(cell))); - MenuItem start = new MenuItem("Start Recording"); + var start = new MenuItem("Start Recording"); start.setOnAction(e -> startStopAction(getSelectedThumbCells(cell), true)); - MenuItem stop = new MenuItem("Stop Recording"); + var stop = new MenuItem("Stop Recording"); stop.setOnAction(e -> startStopAction(getSelectedThumbCells(cell), false)); - MenuItem startStop = recorder.isTracked(model) ? stop : start; + var startStop = recorder.isTracked(model) ? stop : start; - MenuItem recordUntil = new MenuItem("Start Recording Until"); + var recordUntil = new MenuItem("Start Recording Until"); recordUntil.setOnAction(e -> startRecordingWithTimeLimit(getSelectedThumbCells(cell))); - MenuItem addPaused = new MenuItem("Add in paused state"); + var addPaused = new MenuItem("Add in paused state"); addPaused.setOnAction(e -> addPaused(getSelectedThumbCells(cell))); - MenuItem recordLater = new MenuItem("Record Later"); + var recordLater = new MenuItem("Record Later"); recordLater.setOnAction(e -> recordLater(getSelectedThumbCells(cell), true)); - MenuItem removeRecordLater = new MenuItem("Forget Model"); + var removeRecordLater = new MenuItem("Forget Model"); removeRecordLater.setOnAction(e -> recordLater(getSelectedThumbCells(cell), false)); - MenuItem addRemoveBookmark = recorder.isMarkedForLaterRecording(model) ? removeRecordLater : recordLater; + var addRemoveBookmark = recorder.isMarkedForLaterRecording(model) ? removeRecordLater : recordLater; - MenuItem pause = new MenuItem("Pause Recording"); + var pause = new MenuItem("Pause Recording"); pause.setOnAction(e -> pauseResumeAction(getSelectedThumbCells(cell), true)); - MenuItem resume = new MenuItem("Resume Recording"); + var resume = new MenuItem("Resume Recording"); resume.setOnAction(e -> pauseResumeAction(getSelectedThumbCells(cell), false)); - MenuItem pauseResume = recorder.isSuspended(model) ? resume : pause; + var pauseResume = recorder.isSuspended(model) ? resume : pause; - MenuItem follow = new MenuItem("Follow"); + var follow = new MenuItem("Follow"); follow.setOnAction(e -> follow(getSelectedThumbCells(cell), true)); - MenuItem unfollow = new MenuItem("Unfollow"); + var unfollow = new MenuItem("Unfollow"); unfollow.setOnAction(e -> follow(getSelectedThumbCells(cell), false)); - MenuItem ignore = new MenuItem("Ignore"); + var addToGroup = new MenuItem("Add to group"); + addToGroup.setOnAction(e -> addToGroup(model)); + var editGroup = new MenuItem("Edit group"); + editGroup.setOnAction(e -> editGroup(model)); + + var ignore = new MenuItem("Ignore"); ignore.setOnAction(e -> ignore(getSelectedThumbCells(cell))); - MenuItem refresh = new MenuItem("Refresh Overview"); + var refresh = new MenuItem("Refresh Overview"); refresh.setOnAction(e -> refresh()); - MenuItem openRecDir = new MenuItem("Open recording directory"); + var openRecDir = new MenuItem("Open recording directory"); openRecDir.setOnAction(e -> new OpenRecordingsDir(cell, model).execute()); - MenuItem copyUrl = createCopyUrlMenuItem(cell); - MenuItem openInBrowser = createOpenInBrowser(cell); - MenuItem sendTip = createTipMenuItem(cell); + var copyUrl = createCopyUrlMenuItem(cell); + var openInBrowser = createOpenInBrowser(cell); + var sendTip = createTipMenuItem(cell); configureItemsForSelection(cell, openInPlayer, copyUrl, sendTip); @@ -528,16 +539,18 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { } contextMenu.getItems().add(new SeparatorMenuItem()); if (site.supportsFollow()) { - MenuItem followOrUnFollow = (this instanceof FollowedTab) ? unfollow : follow; + var followOrUnFollow = (this instanceof FollowedTab) ? unfollow : follow; followOrUnFollow.setDisable(!site.credentialsAvailable()); contextMenu.getItems().add(followOrUnFollow); } if (site.supportsTips()) { contextMenu.getItems().add(sendTip); } + Optional modelGroup = getModelGroup(model); + contextMenu.getItems().add(modelGroup.isEmpty() ? addToGroup : editGroup); contextMenu.getItems().addAll(copyUrl, openInBrowser, ignore, refresh, openRecDir); if (model instanceof MyFreeCamsModel && Objects.equals(System.getenv("CTBREC_DEV"), "1")) { - MenuItem debug = new MenuItem("debug"); + var debug = new MenuItem("debug"); debug.setOnAction(e -> MyFreeCamsClient.getInstance().getSessionState(model)); contextMenu.getItems().add(debug); } @@ -545,6 +558,23 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { return contextMenu; } + private Optional getModelGroup(Model model) { + try { + return recorder.getModelGroup(model); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Dialogs.showError(grid.getScene(), "Error", "Couldn't get model group for model " + model, e); + return Optional.empty(); + } + } + + private void editGroup(Model model) { + new EditGroupAction(this.getContent(), recorder, model).execute(); + } + + private void addToGroup(Model model) { + new AddToGroupAction(this.getContent(), recorder, model).execute(); + } + private void recordLater(List list, boolean recordLater) { for (ThumbCell cell : list) { cell.recordLater(recordLater); diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java index 25892349..c5f817ac 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java @@ -1,5 +1,13 @@ package ctbrec.ui.tabs; +import static ctbrec.ui.controls.Dialogs.*; + +import java.io.IOException; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import ctbrec.Model; import ctbrec.sites.Site; import ctbrec.ui.SiteUiFactory; @@ -7,13 +15,6 @@ import ctbrec.ui.controls.SearchPopover; import ctbrec.ui.controls.SearchPopoverTreeList; import javafx.application.Platform; import javafx.concurrent.Task; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.List; - -import static ctbrec.ui.controls.Dialogs.showError; public class ThumbOverviewTabSearchTask extends Task> { diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ClickableTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ClickableTableCell.java new file mode 100644 index 00000000..85262a0f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ClickableTableCell.java @@ -0,0 +1,21 @@ +package ctbrec.ui.tabs.recorded; + +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.action.PlayAction; +import javafx.scene.control.TableCell; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; + +public class ClickableTableCell extends TableCell { + + public ClickableTableCell() { + addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + JavaFxModel selectedModel = getTableView().getSelectionModel().getSelectedItem(); + if(selectedModel != null) { + new PlayAction(getTableView(), selectedModel).execute(); + } + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/IconTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/IconTableCell.java new file mode 100644 index 00000000..cd7884f9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/IconTableCell.java @@ -0,0 +1,47 @@ +package ctbrec.ui.tabs.recorded; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import ctbrec.ui.Icon; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; + +public class IconTableCell extends ClickableTableCell { + + protected String tooltip; + protected HBox iconRow; + private Map icons; + + public IconTableCell(Map icons) { + this.icons = Objects.requireNonNullElse(icons, new HashMap<>()); + iconRow = new HBox(3); + } + + protected void show(Icon iconName) { + var imageView = icons.get(iconName); + if (imageView != null) { + iconRow.getChildren().remove(imageView); + iconRow.getChildren().add(imageView); + } + } + + protected void hide(Icon iconName) { + var imageView = icons.get(iconName); + if (imageView != null) { + iconRow.getChildren().remove(imageView); + } + } + + @Override + protected void updateItem(T value, boolean empty) { + if (tooltip != null) { + setTooltip(new Tooltip(tooltip)); + } else { + setTooltip(null); + } + setGraphic(iconRow); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ModelNameTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelNameTableCell.java new file mode 100644 index 00000000..77def249 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelNameTableCell.java @@ -0,0 +1,56 @@ +package ctbrec.ui.tabs.recorded; + +import static ctbrec.ui.Icon.*; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.scene.image.ImageView; + +public class ModelNameTableCell extends IconTableCell { + + private Recorder recorder; + + public ModelNameTableCell(Recorder recorder) { + super(Map.of(GROUP_16, new ImageView(GROUP_16.url()))); + this.recorder = recorder; + } + + @Override + protected void updateItem(String item, boolean empty) { + setText(null); + tooltip = null; + hide(GROUP_16); + + if (item != null && !empty) { + setText(item); + Model m = getTableView().getItems().get(getTableRow().getIndex()); + Optional optionalGroup = getModelGroup(m); + if (optionalGroup.isPresent()) { + ModelGroup group = optionalGroup.get(); + setText(group.getName() + " (aka " + item + ')'); + show(GROUP_16); + tooltip = group.getModelUrls().size() + " models:\n"; + tooltip += group.getModelUrls().stream().collect(Collectors.joining("\n")); + } + } + super.updateItem(item, empty); + } + + private Optional getModelGroup(Model model) { + try { + return recorder.getModelGroup(model); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Dialogs.showError(getScene(), "Error", "Couldn't get model group for model " + model, e); + return Optional.empty(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/OnlineTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/OnlineTableCell.java new file mode 100644 index 00000000..fb535162 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/OnlineTableCell.java @@ -0,0 +1,32 @@ +package ctbrec.ui.tabs.recorded; + +import static ctbrec.ui.Icon.*; + +import java.util.Map; + +import com.google.common.base.Objects; + +import javafx.scene.image.ImageView; + +public class OnlineTableCell extends IconTableCell { + + public OnlineTableCell() { + super(Map.of(CHECK_16, new ImageView(CHECK_16.url()))); + } + + @Override + protected void updateItem(Boolean value, boolean empty) { + if (!empty) { + if (Objects.equal(value, Boolean.TRUE)) { + show(CHECK_16); + tooltip = "Online"; + } else { + hide(CHECK_16); + tooltip = null; + } + } else { + tooltip = null; + } + super.updateItem(value, empty); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordLaterTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordLaterTab.java similarity index 99% rename from client/src/main/java/ctbrec/ui/tabs/RecordLaterTab.java rename to client/src/main/java/ctbrec/ui/tabs/recorded/RecordLaterTab.java index 59a7266e..f7a2148e 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordLaterTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordLaterTab.java @@ -1,4 +1,4 @@ -package ctbrec.ui.tabs; +package ctbrec.ui.tabs.recorded; import java.io.IOException; import java.security.InvalidKeyException; @@ -37,6 +37,7 @@ import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.SearchBox; import ctbrec.ui.controls.autocomplete.AutoFillTextField; import ctbrec.ui.controls.autocomplete.ObservableListSuggester; +import ctbrec.ui.tabs.TabSelectionListener; import javafx.application.Platform; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringPropertyBase; diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java similarity index 89% rename from client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java rename to client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java index b0da6346..e43b9d0b 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java @@ -1,16 +1,11 @@ -package ctbrec.ui.tabs; +package ctbrec.ui.tabs.recorded; import static ctbrec.Recording.State.*; -import static ctbrec.ui.UnicodeEmoji.*; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; @@ -31,6 +26,7 @@ import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.Model; +import ctbrec.ModelGroup; import ctbrec.Recording; import ctbrec.StringUtil; import ctbrec.recorder.Recorder; @@ -40,7 +36,9 @@ import ctbrec.ui.DesktopIntegration; import ctbrec.ui.JavaFxModel; import ctbrec.ui.PreviewPopupHandler; import ctbrec.ui.StreamSourceSelectionDialog; +import ctbrec.ui.action.AddToGroupAction; import ctbrec.ui.action.CheckModelAccountAction; +import ctbrec.ui.action.EditGroupAction; import ctbrec.ui.action.EditNotesAction; import ctbrec.ui.action.FollowAction; import ctbrec.ui.action.IgnoreModelsAction; @@ -59,6 +57,7 @@ import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.SearchBox; import ctbrec.ui.controls.autocomplete.AutoFillTextField; import ctbrec.ui.controls.autocomplete.ObservableListSuggester; +import ctbrec.ui.tabs.TabSelectionListener; import javafx.application.Platform; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringPropertyBase; @@ -157,7 +156,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { table.setEditable(true); table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); - PreviewPopupHandler previewPopupHandler = new PreviewPopupHandler(table); + var previewPopupHandler = new PreviewPopupHandler(table); table.setRowFactory(tableview -> { TableRow row = new TableRow<>(); row.addEventHandler(MouseEvent.ANY, previewPopupHandler); @@ -174,7 +173,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { TableColumn name = new TableColumn<>("Model"); name.setPrefWidth(200); name.setCellValueFactory(new PropertyValueFactory<>("displayName")); - name.setCellFactory(new ClickableCellFactory<>()); + name.setCellFactory(param -> new ModelNameTableCell(recorder)); name.setEditable(false); name.setId("name"); TableColumn url = new TableColumn<>("URL"); @@ -183,15 +182,16 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { url.setPrefWidth(400); url.setEditable(false); url.setId("url"); - TableColumn online = new TableColumn<>("Online"); + TableColumn online = new TableColumn<>("Online"); online.setCellValueFactory(cdf -> cdf.getValue().getOnlineProperty()); + online.setCellFactory(param -> new OnlineTableCell()); online.setPrefWidth(100); online.setEditable(false); online.setId("online"); online.setStyle(STYLE_ALIGN_CENTER); - TableColumn recording = new TableColumn<>("Recording"); + TableColumn recording = new TableColumn<>("Recording"); recording.setCellValueFactory(cdf -> cdf.getValue().getRecordingProperty()); - recording.setCellFactory(tc -> new RecordingCell()); + recording.setCellFactory(tc -> new RecordingTableCell()); recording.setPrefWidth(100); recording.setEditable(false); recording.setId("recording"); @@ -272,7 +272,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { scrollPane.setContent(table); - HBox addModelBox = new HBox(5); + var addModelBox = new HBox(5); modelLabel.setPadding(new Insets(5, 0, 0, 0)); ObservableList suggestions = FXCollections.observableArrayList(); sites.forEach(site -> suggestions.add(site.getClass().getSimpleName())); @@ -302,7 +302,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { checkModelAccountExistance.setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder) .execute(Predicate.not(Model::isMarkedForLaterRecording))); - HBox filterContainer = new HBox(); + var filterContainer = new HBox(); filterContainer.setSpacing(0); filterContainer.setPadding(new Insets(0)); filterContainer.setAlignment(Pos.CENTER_RIGHT); @@ -326,7 +326,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { filterContainer.getChildren().add(filter); addModelBox.getChildren().add(filterContainer); - BorderPane root = new BorderPane(); + var root = new BorderPane(); root.setPadding(new Insets(5)); root.setTop(addModelBox); root.setCenter(scrollPane); @@ -338,7 +338,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { 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; + var startAt = 0; if (table.getSelectionModel().getSelectedIndex() >= 0) { startAt = table.getSelectionModel().getSelectedIndex() + 1; if (startAt >= table.getItems().size()) { @@ -346,7 +346,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } } - String c = code.getChar().toLowerCase(); + var c = code.getChar().toLowerCase(); int i = startAt; do { JavaFxModel current = table.getItems().get(i); @@ -378,7 +378,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { private void updatePriority(JavaFxModel model, int priority) { try { if (priority < 0 || priority > 100) { - String msg = "Priority has to be between 0 and 100"; + var msg = "Priority has to be between 0 and 100"; Dialogs.showError(table.getScene(), "Invalid value", msg, null); } else { model.setPriority(priority); @@ -404,7 +404,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { private void addModelByUrl(String url) { for (Site site : sites) { - Model newModel = site.createModelFromUrl(url); + var newModel = site.createModelFromUrl(url); if (newModel != null) { try { recorder.addModel(newModel); @@ -431,7 +431,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { for (Site site : sites) { if (Objects.equals(siteName.toLowerCase(), site.getClass().getSimpleName().toLowerCase())) { try { - Model m = site.createModel(modelName); + var m = site.createModel(modelName); recorder.addModel(m); } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { Dialogs.showError(getTabPane().getScene(), "Couldn't add model", "The model " + modelName + " could not be added:", e1); @@ -548,18 +548,18 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { String[] tokens = filter.split(" "); observableModels.addAll(filteredModels); filteredModels.clear(); - for (int i = 0; i < table.getItems().size(); i++) { - StringBuilder sb = new StringBuilder(); + for (var i = 0; i < table.getItems().size(); i++) { + var sb = new StringBuilder(); for (TableColumn tc : table.getColumns()) { Object cellData = tc.getCellData(i); if (cellData != null) { - String content = cellData.toString(); + var content = cellData.toString(); sb.append(content).append(' '); } } - String searchText = sb.toString(); + var searchText = sb.toString(); - boolean tokensMissing = false; + var tokensMissing = false; for (String token : tokens) { if (!searchText.toLowerCase().contains(token.toLowerCase())) { tokensMissing = true; @@ -594,18 +594,14 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { .peek(fxm -> { // NOSONAR for (Recording recording : recordings) { if(recording.getStatus() == RECORDING && Objects.equals(recording.getModel(), fxm)){ - String recordingValue = HEAVY_CHECK_MARK; - if(!Objects.equals(recording.getModel().getRecordUntil(), Instant.ofEpochMilli(Model.RECORD_INDEFINITELY))) { - recordingValue += ' ' + CLOCK; - } - fxm.getRecordingProperty().set(recordingValue); + fxm.setRecordingProperty(true); break; } } for (Model onlineModel : onlineModels) { if(Objects.equals(onlineModel, fxm)) { - fxm.getOnlineProperty().set(HEAVY_CHECK_MARK); + fxm.setOnlineProperty(true); break; } } @@ -616,7 +612,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } }; ExecutorService executor = Executors.newSingleThreadExecutor(r -> { - Thread t = new Thread(r); + var t = new Thread(r); t.setDaemon(true); t.setName("RecordedModelsTab UpdateService"); return t; @@ -645,44 +641,49 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { if (selectedModels.isEmpty()) { return null; } - MenuItem stop = new MenuItem("Remove Model"); + var stop = new MenuItem("Remove Model"); stop.setOnAction(e -> stopAction(selectedModels)); - MenuItem recordLater = new MenuItem("Record Later"); + var recordLater = new MenuItem("Record Later"); recordLater.setOnAction(e -> recordLater(selectedModels)); - MenuItem copyUrl = new MenuItem("Copy URL"); + var copyUrl = new MenuItem("Copy URL"); copyUrl.setOnAction(e -> { Model selected = selectedModels.get(0); - final Clipboard clipboard = Clipboard.getSystemClipboard(); - final ClipboardContent content = new ClipboardContent(); + final var clipboard = Clipboard.getSystemClipboard(); + final var content = new ClipboardContent(); content.putString(selected.getUrl()); clipboard.setContent(content); }); - MenuItem pauseRecording = new MenuItem("Pause Recording"); + var pauseRecording = new MenuItem("Pause Recording"); pauseRecording.setOnAction(e -> pauseRecording(selectedModels)); - MenuItem resumeRecording = new MenuItem("Resume Recording"); + var resumeRecording = new MenuItem("Resume Recording"); resumeRecording.setOnAction(e -> resumeRecording(selectedModels)); - MenuItem stopRecordingAt = new MenuItem("Stop Recording at Date"); + var stopRecordingAt = new MenuItem("Stop Recording at Date"); stopRecordingAt.setOnAction(e -> setStopDate(selectedModels.get(0))); - MenuItem removeTimeLimit = new MenuItem("Remove Time Limit"); + var removeTimeLimit = new MenuItem("Remove Time Limit"); removeTimeLimit.setOnAction(e -> removeTimeLimit(selectedModels.get(0))); - MenuItem openInBrowser = new MenuItem("Open in Browser"); + var openInBrowser = new MenuItem("Open in Browser"); openInBrowser.setOnAction(e -> DesktopIntegration.open(selectedModels.get(0).getUrl())); - MenuItem openInPlayer = new MenuItem("Open in Player"); + var openInPlayer = new MenuItem("Open in Player"); openInPlayer.setOnAction(e -> openInPlayer(selectedModels.get(0))); - MenuItem switchStreamSource = new MenuItem("Switch resolution"); + var switchStreamSource = new MenuItem("Switch resolution"); switchStreamSource.setOnAction(e -> switchStreamSource(selectedModels.get(0))); - MenuItem follow = new MenuItem("Follow"); + var 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"); + var ignore = new MenuItem("Ignore"); ignore.setOnAction(e -> ignore(selectedModels)); - MenuItem notes = new MenuItem("Notes"); + var notes = new MenuItem("Notes"); notes.setOnAction(e -> notes(selectedModels)); - MenuItem openRecDir = new MenuItem("Open recording directory"); + var openRecDir = new MenuItem("Open recording directory"); openRecDir.setOnAction(e -> new OpenRecordingsDir(table, selectedModels.get(0)).execute()); + var addToGroup = new MenuItem("Add to group"); + addToGroup.setOnAction(e -> addToGroup(selectedModels.get(0))); + var editGroup = new MenuItem("Edit group"); + editGroup.setOnAction(e -> editGroup(selectedModels.get(0))); + ContextMenu menu = new CustomMouseBehaviorContextMenu(stop, recordLater); if (selectedModels.size() == 1) { menu.getItems().add(selectedModels.get(0).isSuspended() ? resumeRecording : pauseRecording); @@ -693,6 +694,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } else { menu.getItems().addAll(resumeRecording, pauseRecording); } + Optional modelGroup = getModelGroup(selectedModels.get(0)); + menu.getItems().add(modelGroup.isEmpty() ? addToGroup : editGroup); menu.getItems().addAll(copyUrl, openInPlayer, openInBrowser, openRecDir, switchStreamSource, follow, notes, ignore); if (selectedModels.size() > 1) { @@ -706,6 +709,25 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { return menu; } + private Optional getModelGroup(Model model) { + try { + return recorder.getModelGroup(model); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Dialogs.showError(grid.getScene(), "Error", "Couldn't get model group for model " + model, e); + return Optional.empty(); + } + } + + private void addToGroup(Model model) { + new AddToGroupAction(this.getContent(), recorder, model).execute(); + table.refresh(); + } + + private void editGroup(Model model) { + new EditGroupAction(this.getContent(), recorder, model).execute(); + table.refresh(); + } + private void setStopDate(JavaFxModel model) { new SetStopDateAction(table, model.getDelegate(), recorder) // .execute() // @@ -735,7 +757,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } private void switchStreamSource(JavaFxModel fxModel) { - String couldntSwitchHeaderText = "Couldn't switch stream resolution"; + var couldntSwitchHeaderText = "Couldn't switch stream resolution"; try { if (!fxModel.isOnline(true)) { @@ -781,7 +803,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } private boolean stopAction(List selectedModels) { - boolean confirmed = true; + var confirmed = true; if (Config.getInstance().getSettings().confirmationForDangerousActions) { int n = selectedModels.size(); String plural = n > 1 ? "s" : ""; @@ -829,9 +851,9 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { Config.getInstance().getSettings().recordedModelsSortType = 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++) { + var columnWidths = new double[columns]; + var columnIds = new String[columns]; + for (var i = 0; i < columnWidths.length; i++) { columnWidths[i] = table.getColumns().get(i).getWidth(); columnIds[i] = table.getColumns().get(i).getId(); } @@ -862,8 +884,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { private void restoreColumnOrder() { String[] columnIds = Config.getInstance().getSettings().recordedModelsColumnIds; ObservableList> columns = table.getColumns(); - for (int i = 0; i < columnIds.length; i++) { - for (int j = 0; j < table.getColumns().size(); j++) { + for (var i = 0; i < columnIds.length; i++) { + for (var 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 @@ -876,7 +898,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { private void restoreColumnWidths() { double[] columnWidths = Config.getInstance().getSettings().recordedModelsColumnWidths; if (columnWidths != null && columnWidths.length == table.getColumns().size()) { - for (int i = 0; i < columnWidths.length; i++) { + for (var i = 0; i < columnWidths.length; i++) { table.getColumns().get(i).setPrefWidth(columnWidths[i]); } } @@ -930,25 +952,4 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { return tableCell; } } - - private class RecordingCell extends TableCell { - @Override - protected void updateItem(String value, boolean empty) { - super.updateItem(value, empty); - if (value == null) { - setTooltip(null); - setText(null); - } else { - Model m = getTableView().getItems().get(getTableRow().getIndex()); - if (m.isRecordingTimeLimited()) { - Tooltip tooltip = new Tooltip(); - DateTimeFormatter dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT); - ZonedDateTime zonedDateTime = m.getRecordUntil().atZone(ZoneId.systemDefault()); - tooltip.setText("Recording until " + dtf.format(zonedDateTime)); - setTooltip(tooltip); - } - setText(value); - } - } - } } diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedTab.java similarity index 96% rename from client/src/main/java/ctbrec/ui/tabs/RecordedTab.java rename to client/src/main/java/ctbrec/ui/tabs/recorded/RecordedTab.java index 38a112d4..547fdd33 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedTab.java @@ -1,10 +1,11 @@ -package ctbrec.ui.tabs; +package ctbrec.ui.tabs.recorded; import java.util.List; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; import ctbrec.ui.ShutdownListener; +import ctbrec.ui.tabs.TabSelectionListener; import javafx.beans.value.ChangeListener; import javafx.geometry.Side; import javafx.scene.control.Tab; diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/RecordingTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordingTableCell.java new file mode 100644 index 00000000..ffd35375 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordingTableCell.java @@ -0,0 +1,47 @@ +package ctbrec.ui.tabs.recorded; + +import static ctbrec.ui.Icon.*; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Map; +import java.util.Objects; + +import ctbrec.Model; +import ctbrec.SubsequentAction; +import javafx.scene.image.ImageView; + +public class RecordingTableCell extends IconTableCell { + + public RecordingTableCell() { + super(Map.of( // + CHECK_16, new ImageView(CHECK_16.url()), // + CLOCK_16, new ImageView(CLOCK_16.url()))); + } + + @Override + protected void updateItem(Boolean value, boolean empty) { + tooltip = null; + hide(CHECK_16); + hide(CLOCK_16); + if (value == null || empty) { + return; + } + + if (Objects.equals(value, Boolean.TRUE)) { + show(CHECK_16); + tooltip = "Recording"; + } + + Model m = getTableView().getItems().get(getTableRow().getIndex()); + if (m.isRecordingTimeLimited()) { + show(CLOCK_16); + var dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT); + var zonedDateTime = m.getRecordUntil().atZone(ZoneId.systemDefault()); + String action = m.getRecordUntilSubsequentAction() == SubsequentAction.PAUSE ? "Pause" : "Remove"; + tooltip = action + " at " + dtf.format(zonedDateTime); + } + super.updateItem(value, empty); + } +} \ No newline at end of file diff --git a/client/src/main/resources/16/check-circle.png b/client/src/main/resources/16/check-circle.png new file mode 100644 index 00000000..2c3c1024 Binary files /dev/null and b/client/src/main/resources/16/check-circle.png differ diff --git a/client/src/main/resources/16/check-small.png b/client/src/main/resources/16/check-small.png new file mode 100644 index 00000000..c3b7d245 Binary files /dev/null and b/client/src/main/resources/16/check-small.png differ diff --git a/client/src/main/resources/16/check.png b/client/src/main/resources/16/check.png new file mode 100644 index 00000000..36b0bc8f Binary files /dev/null and b/client/src/main/resources/16/check.png differ diff --git a/client/src/main/resources/16/clock.png b/client/src/main/resources/16/clock.png new file mode 100644 index 00000000..00c9e221 Binary files /dev/null and b/client/src/main/resources/16/clock.png differ diff --git a/client/src/main/resources/16/users.png b/client/src/main/resources/16/users.png new file mode 100644 index 00000000..cb1ce703 Binary files /dev/null and b/client/src/main/resources/16/users.png differ diff --git a/client/src/main/resources/32/check-circle.png b/client/src/main/resources/32/check-circle.png new file mode 100644 index 00000000..5687d94e Binary files /dev/null and b/client/src/main/resources/32/check-circle.png differ diff --git a/client/src/main/resources/32/check-small.png b/client/src/main/resources/32/check-small.png new file mode 100644 index 00000000..540010a1 Binary files /dev/null and b/client/src/main/resources/32/check-small.png differ diff --git a/client/src/main/resources/32/check-small.svg b/client/src/main/resources/32/check-small.svg new file mode 100644 index 00000000..2980ea7a --- /dev/null +++ b/client/src/main/resources/32/check-small.svg @@ -0,0 +1,57 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/client/src/main/resources/32/check.png b/client/src/main/resources/32/check.png new file mode 100644 index 00000000..bc8b618b Binary files /dev/null and b/client/src/main/resources/32/check.png differ diff --git a/client/src/main/resources/32/clock.png b/client/src/main/resources/32/clock.png new file mode 100644 index 00000000..a7ad0f32 Binary files /dev/null and b/client/src/main/resources/32/clock.png differ diff --git a/client/src/main/resources/32/users.png b/client/src/main/resources/32/users.png new file mode 100644 index 00000000..fe71827c Binary files /dev/null and b/client/src/main/resources/32/users.png differ diff --git a/client/src/main/resources/buymeacoffee-round.png b/client/src/main/resources/buymeacoffee-round.png deleted file mode 100644 index 341a0f8e..00000000 Binary files a/client/src/main/resources/buymeacoffee-round.png and /dev/null differ diff --git a/client/src/main/resources/html/docs/PostProcessing.md b/client/src/main/resources/html/docs/PostProcessing.md index 9fdc86b4..9cc13eeb 100644 --- a/client/src/main/resources/html/docs/PostProcessing.md +++ b/client/src/main/resources/html/docs/PostProcessing.md @@ -42,11 +42,14 @@ The part you have to copy is +###### Available variables: - **${modelName}** - the name of the recorded model - **${modelDisplayName}** - the name of the recorded model, which is shown on the webpage. Might be the same as ${modelName} - **${modelSanitizedName}** - sanitized name of the model. The following characters are replaced by an underscore: \\, /, ', " and space +- **${modelGroupName}** - name of the model group, if the model is part of a group +- **${modelGroupId}** - the unique ID of the model group, if the model is part of a group - **${siteName}** - the name of the cam site, the model streams on - **${siteSanitizedName}** - sanitized name of the site. The following characters are replaced by an underscore: \\, /, ', " and space @@ -116,4 +119,15 @@ The part you have to copy is - For more information see: [DateTimeFormatter](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/format/DateTimeFormatter.html) \ No newline at end of file + For more information see: [DateTimeFormatter](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/format/DateTimeFormatter.html) +###### Fallback values: +You can define a fallback value for each variable in case there is no value available for the variable. The syntax is + + ${placeholder?foobar} + +Let's for example say you have created some model groups. For models, which are part of a group, you want to use the group name. But for models, which +are not part of a group you want to use the sanitized name. You can achieve that by using the following expression: + + ${modelGroupName?${modelSanitizedName}} + +It can be read like "use the modelGroupName, but if that is not available use modelSanitizedName". \ No newline at end of file diff --git a/client/src/main/resources/kofi-round.png b/client/src/main/resources/kofi-round.png deleted file mode 100644 index 412a0a66..00000000 Binary files a/client/src/main/resources/kofi-round.png and /dev/null differ diff --git a/client/src/main/resources/kofi-round600.png b/client/src/main/resources/kofi-round600.png deleted file mode 100644 index 995171ea..00000000 Binary files a/client/src/main/resources/kofi-round600.png and /dev/null differ diff --git a/client/src/main/resources/patreon-round.png b/client/src/main/resources/patreon-round.png deleted file mode 100644 index 22c9edac..00000000 Binary files a/client/src/main/resources/patreon-round.png and /dev/null differ diff --git a/client/src/main/resources/paypal-round.png b/client/src/main/resources/paypal-round.png deleted file mode 100644 index 481490ea..00000000 Binary files a/client/src/main/resources/paypal-round.png and /dev/null differ diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java index f3c8d2c1..eb80fa40 100644 --- a/common/src/main/java/ctbrec/Config.java +++ b/common/src/main/java/ctbrec/Config.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Map.Entry; import java.util.Objects; import java.util.Optional; +import java.util.UUID; import java.util.stream.Collectors; import org.apache.commons.io.FileUtils; @@ -31,6 +32,7 @@ import ctbrec.Settings.SplitStrategy; import ctbrec.io.FileJsonAdapter; import ctbrec.io.ModelJsonAdapter; import ctbrec.io.PostProcessorJsonAdapter; +import ctbrec.io.UuidJSonAdapter; import ctbrec.recorder.postprocessing.DeleteTooShort; import ctbrec.recorder.postprocessing.PostProcessor; import ctbrec.recorder.postprocessing.RemoveKeepFile; @@ -74,6 +76,7 @@ public class Config { .add(Model.class, new ModelJsonAdapter(sites)) .add(PostProcessor.class, new PostProcessorJsonAdapter()) .add(File.class, new FileJsonAdapter()) + .add(UUID.class, new UuidJSonAdapter()) .build(); JsonAdapter adapter = moshi.adapter(Settings.class).lenient(); File configFile = new File(configDir, filename); @@ -182,11 +185,11 @@ public class Config { } // 3.11.0 make Cam4 model names lower case settings.models.stream() - .filter(Cam4Model.class::isInstance) - .forEach(m -> m.setName(m.getName().toLowerCase())); + .filter(Cam4Model.class::isInstance) + .forEach(m -> m.setName(m.getName().toLowerCase())); settings.modelsIgnored.stream() - .filter(Cam4Model.class::isInstance) - .forEach(m -> m.setName(m.getName().toLowerCase())); + .filter(Cam4Model.class::isInstance) + .forEach(m -> m.setName(m.getName().toLowerCase())); // 4.1.2 reduce models ignore to store only the URL if (settings.modelsIgnored != null && !settings.modelsIgnored.isEmpty()) { settings.ignoredModels = settings.modelsIgnored.stream() @@ -227,13 +230,14 @@ public class Config { } public synchronized void save() throws IOException { - if (savingDisabled) { - return; - } + if (savingDisabled) { + return; + } Moshi moshi = new Moshi.Builder() .add(Model.class, new ModelJsonAdapter()) .add(PostProcessor.class, new PostProcessorJsonAdapter()) .add(File.class, new FileJsonAdapter()) + .add(UUID.class, new UuidJSonAdapter()) .build(); JsonAdapter adapter = moshi.adapter(Settings.class).indent(" "); String json = adapter.toJson(settings); @@ -299,12 +303,12 @@ public class Config { public String getModelNotes(Model m) { return Config.getInstance().getSettings().modelNotes.getOrDefault(m.getUrl(), ""); } - + public void disableSaving() { - savingDisabled = true; + savingDisabled = true; } - + public void enableSaving() { - savingDisabled = false; + savingDisabled = false; } } diff --git a/common/src/main/java/ctbrec/ModelGroup.java b/common/src/main/java/ctbrec/ModelGroup.java new file mode 100644 index 00000000..1f9d53ca --- /dev/null +++ b/common/src/main/java/ctbrec/ModelGroup.java @@ -0,0 +1,72 @@ +package ctbrec; + +import java.io.Serializable; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public class ModelGroup implements Serializable { + private static final long serialVersionUID = 1L; + + private UUID id; + private String name; + private List modelUrls = new LinkedList<>(); + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getModelUrls() { + return modelUrls; + } + + public void setModelUrls(List modelUrls) { + this.modelUrls = modelUrls; + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ModelGroup other = (ModelGroup) obj; + return Objects.equals(id, other.id); + } + + @Override + public String toString() { + return "ModelGroup [id=" + id + ", name=" + name + ", models=" + modelUrls + "]"; + } + + public void add(String modelUrl) { + modelUrls.add(modelUrl); + } + + public void add(Model model) { + modelUrls.add(model.getUrl()); + } +} diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 8f5b737b..81de026f 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -3,8 +3,10 @@ package ctbrec; import java.io.File; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import ctbrec.event.EventHandlerConfiguration; import ctbrec.recorder.postprocessing.PostProcessor; @@ -104,10 +106,12 @@ public class Settings { public String mfcPassword = ""; public String mfcUsername = ""; public boolean minimizeToTray = false; + @Deprecated public int minimumLengthInSeconds = 0; public long minimumSpaceLeftInBytes = 0; public Map modelNotes = new HashMap<>(); public List models = new ArrayList<>(); + public Set modelGroups = new HashSet<>(); @Deprecated public List modelsIgnored = new ArrayList<>(); public boolean monitorClipboard = false; diff --git a/common/src/main/java/ctbrec/io/UuidJSonAdapter.java b/common/src/main/java/ctbrec/io/UuidJSonAdapter.java new file mode 100644 index 00000000..b3a481a7 --- /dev/null +++ b/common/src/main/java/ctbrec/io/UuidJSonAdapter.java @@ -0,0 +1,22 @@ +package ctbrec.io; + +import java.io.IOException; +import java.util.UUID; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; + +public class UuidJSonAdapter extends JsonAdapter { + + @Override + public UUID fromJson(JsonReader reader) throws IOException { + return UUID.fromString(reader.nextString()); + } + + @Override + public void toJson(JsonWriter writer, UUID value) throws IOException { + writer.value(value.toString()); + } + +} diff --git a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java index fa29d037..30f2c79b 100644 --- a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; @@ -45,6 +46,7 @@ import com.google.common.eventbus.Subscribe; import ctbrec.Config; import ctbrec.Model; +import ctbrec.ModelGroup; import ctbrec.Recording; import ctbrec.Recording.State; import ctbrec.event.Event; @@ -54,6 +56,7 @@ import ctbrec.event.NoSpaceLeftEvent; import ctbrec.event.RecordingStateChangedEvent; import ctbrec.io.HttpClient; import ctbrec.recorder.download.Download; +import ctbrec.recorder.postprocessing.PostProcessingContext; import ctbrec.recorder.postprocessing.PostProcessor; import ctbrec.sites.Site; @@ -88,18 +91,7 @@ public class NextGenLocalRecorder implements Recorder { downloadPool = Executors.newScheduledThreadPool(5, createThreadFactory("Download", MAX_PRIORITY)); threadPoolScaler = new ThreadPoolScaler((ThreadPoolExecutor) downloadPool, 5); recordingManager = new RecordingManager(config, sites); - config.getSettings().models.stream().forEach(m -> { - if (m.getSite() != null) { - if (m.getSite().isEnabled()) { - models.add(m); - } else { - LOG.info("{} disabled -> ignoring {}", m.getSite().getName(), m.getName()); - } - } else { - LOG.info("Site for model {} is unknown -> ignoring", m.getName()); - } - }); - + loadModels(); int ppThreads = config.getSettings().postProcessingThreads; ppPool = new ThreadPoolExecutor(ppThreads, ppThreads, 5, TimeUnit.MINUTES, ppQueue, createThreadFactory("PP", MIN_PRIORITY)); @@ -127,6 +119,20 @@ public class NextGenLocalRecorder implements Recorder { }, 1, 1, TimeUnit.SECONDS); } + private void loadModels() { + config.getSettings().models.stream().forEach(m -> { + if (m.getSite() != null) { + if (m.getSite().isEnabled()) { + models.add(m); + } else { + LOG.info("{} disabled -> ignoring {}", m.getSite().getName(), m.getName()); + } + } else { + LOG.info("Site for model {} is unknown -> ignoring", m.getName()); + } + }); + } + private void startCompletionHandler() { downloadCompletionPool.submit(() -> { while (!Thread.currentThread().isInterrupted()) { @@ -209,9 +215,10 @@ public class NextGenLocalRecorder implements Recorder { recordingManager.saveRecording(recording); recording.postprocess(); List postProcessors = config.getSettings().postProcessors; + PostProcessingContext ctx = createPostProcessingContext(recording); for (PostProcessor postProcessor : postProcessors) { LOG.debug("Running post-processor: {}", postProcessor.getName()); - boolean continuePP = postProcessor.postprocess(recording, recordingManager, config); + boolean continuePP = postProcessor.postprocess(ctx); if (!continuePP) { break; } @@ -237,6 +244,15 @@ public class NextGenLocalRecorder implements Recorder { }); } + private PostProcessingContext createPostProcessingContext(Recording recording) { + PostProcessingContext ctx = new PostProcessingContext(); + ctx.setConfig(config); + ctx.setRecorder(this); + ctx.setRecording(recording); + ctx.setRecordingManager(recordingManager); + return ctx; + } + private void setRecordingStatus(Recording recording, State status) { recording.setStatus(status); RecordingStateChangedEvent evt = new RecordingStateChangedEvent(recording.getDownload().getTarget(), status, recording.getModel(), @@ -761,4 +777,23 @@ public class NextGenLocalRecorder implements Recorder { public int getModelCount() { return (int) models.stream().filter(m -> !m.isMarkedForLaterRecording()).count(); } + + @Override + public Set getModelGroups() { + return config.getSettings().modelGroups; + } + + @Override + public void saveModelGroup(ModelGroup group) throws IOException { + Set modelGroups = config.getSettings().modelGroups; + modelGroups.remove(group); + modelGroups.add(group); + config.save(); + } + + @Override + public void deleteModelGroup(ModelGroup group) throws IOException { + config.getSettings().modelGroups.remove(group); + config.save(); + } } diff --git a/common/src/main/java/ctbrec/recorder/Recorder.java b/common/src/main/java/ctbrec/recorder/Recorder.java index 4c88b47b..6bc17ad5 100644 --- a/common/src/main/java/ctbrec/recorder/Recorder.java +++ b/common/src/main/java/ctbrec/recorder/Recorder.java @@ -1,15 +1,18 @@ package ctbrec.recorder; -import ctbrec.Model; -import ctbrec.Recording; -import ctbrec.io.HttpClient; - import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.Recording; +import ctbrec.io.HttpClient; + public interface Recorder { public void addModel(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; @@ -143,5 +146,29 @@ public interface Recorder { */ public void resume() throws InvalidKeyException, NoSuchAlgorithmException, IOException; + /** + * Returns the number of models, which are on the recording list and not marked for later recording + * @return + */ public int getModelCount(); + + public Set getModelGroups() throws InvalidKeyException, NoSuchAlgorithmException, IOException; + + /** + * Saves a model group. If the group already exists, it will be overwritten. Otherwise it will + * be saved as a new group. + * @param group + * @throws IOException + * @throws NoSuchAlgorithmException + * @throws InvalidKeyException + */ + public void saveModelGroup(ModelGroup group) throws IOException, InvalidKeyException, NoSuchAlgorithmException; + + public void deleteModelGroup(ModelGroup group) throws IOException, InvalidKeyException, NoSuchAlgorithmException; + + default Optional getModelGroup(Model model) throws InvalidKeyException, NoSuchAlgorithmException, IOException { + return getModelGroups().stream() + .filter(mg -> mg.getModelUrls().contains(model.getUrl())) + .findFirst(); + } } diff --git a/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java b/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java index b1008beb..0fdee9fb 100644 --- a/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java +++ b/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java @@ -2,7 +2,10 @@ package ctbrec.recorder; import static ctbrec.recorder.NextGenLocalRecorder.*; import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.time.Instant; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -12,6 +15,7 @@ import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.Model; +import ctbrec.ModelGroup; import ctbrec.Recording; import ctbrec.recorder.download.Download; @@ -26,7 +30,7 @@ public class RecordingPreconditions { this.recorder = recorder; } - void check(Model model) throws IOException { + void check(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { ensureRecorderIsActive(); ensureModelIsNotSuspended(model); ensureModelIsNotMarkedForLaterRecording(model); @@ -36,6 +40,7 @@ public class RecordingPreconditions { ensureEnoughSpaceForRecording(); ensureDownloadSlotAvailable(model); ensureModelIsOnline(model); + ensureNoOtherFromModelGroupIsRecording(model); } private void ensureModelIsOnline(Model model) { @@ -130,4 +135,55 @@ public class RecordingPreconditions { int concurrentRecordings = Config.getInstance().getSettings().concurrentRecordings; return concurrentRecordings == 0 || concurrentRecordings > 0 && recorder.getRecordingProcesses().size() < concurrentRecordings; } + + private void ensureNoOtherFromModelGroupIsRecording(Model model) throws InvalidKeyException, NoSuchAlgorithmException, IOException { + Optional modelGroup = recorder.getModelGroup(model); + if (modelGroup.isPresent()) { + for (String modelUrl : modelGroup.get().getModelUrls()) { + if (modelUrl.equals(model.getUrl())) { + // no other model with higher prio is online, start recording + // but before that stop all recordings of models with lower prio + stopModelsWithLowerPrio(modelGroup.get()); + return; + } else { + Optional otherModel = getModelForUrl(modelUrl); + if (otherModel.isPresent() && otherModelCanBeRecorded(otherModel.get())) { + throw new PreconditionNotMetException(otherModel.get() + " from the same group is already recorded"); + } + } + } + } + } + + private void stopModelsWithLowerPrio(ModelGroup modelGroup) throws InvalidKeyException, NoSuchAlgorithmException, IOException { + recorder.getCurrentlyRecording().stream() + .filter(m -> modelGroup.getModelUrls().contains(m.getUrl())) + .forEach(recorder::stopRecordingProcess); + + } + + private Optional getModelForUrl(String modelUrl) { + return Config.getInstance().getSettings().models.stream() + .filter(m -> Objects.equals(m.getUrl(), modelUrl)) + .findFirst(); + } + + private boolean otherModelCanBeRecorded(Model model) { + try { + ensureRecorderIsActive(); + ensureModelIsNotSuspended(model); + ensureModelIsNotMarkedForLaterRecording(model); + ensureRecordUntilIsInFuture(model); + ensureModelShouldBeRecorded(model); + ensureEnoughSpaceForRecording(); + ensureDownloadSlotAvailable(model); + ensureModelIsOnline(model); + return true; + } catch (PreconditionNotMetException e) { + // precondition for other model not met + } catch (IOException e) { + LOG.warn("Couldn't check if preconditions of other model from group are met. Assuming she's offline", e); + } + return false; + } } diff --git a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java index fa075ef1..67bf7cbf 100644 --- a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java +++ b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java @@ -1,25 +1,5 @@ package ctbrec.recorder; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import ctbrec.Config; -import ctbrec.Hmac; -import ctbrec.Model; -import ctbrec.Recording; -import ctbrec.event.EventBusHolder; -import ctbrec.event.NoSpaceLeftEvent; -import ctbrec.event.RecordingStateChangedEvent; -import ctbrec.io.*; -import ctbrec.sites.Site; -import okhttp3.MediaType; -import okhttp3.Request; -import okhttp3.Request.Builder; -import okhttp3.RequestBody; -import okhttp3.Response; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.File; import java.io.IOException; import java.io.UnsupportedEncodingException; @@ -27,7 +7,43 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; + +import ctbrec.Config; +import ctbrec.Hmac; +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.Recording; +import ctbrec.event.EventBusHolder; +import ctbrec.event.NoSpaceLeftEvent; +import ctbrec.event.RecordingStateChangedEvent; +import ctbrec.io.BandwidthMeter; +import ctbrec.io.FileJsonAdapter; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.io.InstantJsonAdapter; +import ctbrec.io.ModelJsonAdapter; +import ctbrec.io.UuidJSonAdapter; +import ctbrec.sites.Site; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.Request.Builder; +import okhttp3.RequestBody; +import okhttp3.Response; public class RemoteRecorder implements Recorder { @@ -41,16 +57,20 @@ public class RemoteRecorder implements Recorder { .add(Instant.class, new InstantJsonAdapter()) .add(Model.class, new ModelJsonAdapter()) .add(File.class, new FileJsonAdapter()) + .add(UUID.class, new UuidJSonAdapter()) .build(); private JsonAdapter modelListResponseAdapter = moshi.adapter(ModelListResponse.class); private JsonAdapter recordingListResponseAdapter = moshi.adapter(RecordingListResponse.class); private JsonAdapter modelRequestAdapter = moshi.adapter(ModelRequest.class); + private JsonAdapter modelGroupRequestAdapter = moshi.adapter(ModelGroupRequest.class); + private JsonAdapter modelGroupListResponseAdapter = moshi.adapter(ModelGroupListResponse.class); private JsonAdapter recordingRequestAdapter = moshi.adapter(RecordingRequest.class); private JsonAdapter simpleResponseAdapter = moshi.adapter(SimpleResponse.class); private List models = Collections.emptyList(); private List onlineModels = Collections.emptyList(); private List recordings = Collections.emptyList(); + private Set modelGroups = new HashSet<>(); private List sites; private long spaceTotal = -1; private long spaceFree = -1; @@ -91,7 +111,7 @@ public class RemoteRecorder implements Recorder { private Optional sendRequest(String action) throws IOException, InvalidKeyException, NoSuchAlgorithmException { String msg = "{\"action\": \"" + action + "\"}"; - LOG.debug("Sending request to recording server: {}", msg); + LOG.trace("Sending request to recording server: {}", msg); RequestBody requestBody = RequestBody.Companion.create(msg, JSON); Request.Builder builder = new Request.Builder().url(getRecordingEndpoint()).post(requestBody); addHmacIfNeeded(msg, builder); @@ -109,7 +129,7 @@ public class RemoteRecorder implements Recorder { 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); + LOG.trace("Sending request to recording server: {}", payload); RequestBody body = RequestBody.Companion.create(payload, JSON); Request.Builder builder = new Request.Builder().url(getRecordingEndpoint()).post(body); addHmacIfNeeded(payload, builder); @@ -139,6 +159,7 @@ public class RemoteRecorder implements Recorder { String msg = recordingRequestAdapter.toJson(recReq); RequestBody body = RequestBody.Companion.create(msg, JSON); Request.Builder builder = new Request.Builder().url(getRecordingEndpoint()).post(body); + LOG.trace("Sending request to recording server: {}", msg); addHmacIfNeeded(msg, builder); Request request = builder.build(); try (Response response = client.execute(request)) { @@ -158,6 +179,33 @@ public class RemoteRecorder implements Recorder { } } + private void sendRequest(String action, ModelGroup model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + String payload = modelGroupRequestAdapter.toJson(new ModelGroupRequest(action, model)); + LOG.trace("Sending request to recording server: {}", payload); + RequestBody body = RequestBody.Companion.create(payload, JSON); + Request.Builder builder = new Request.Builder().url(getRecordingEndpoint()).post(body); + addHmacIfNeeded(payload, builder); + Request request = builder.build(); + try (Response response = client.execute(request)) { + if (response.isSuccessful()) { + String json = response.body().string(); + updateModelGroups(json); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void updateModelGroups(String responseBody) throws IOException { + ModelGroupListResponse resp = modelGroupListResponseAdapter.fromJson(responseBody); + if (!resp.status.equals(SUCCESS)) { + throw new IOException("Server returned error " + resp.status + " " + resp.msg); + } + + modelGroups.clear(); + modelGroups.addAll(resp.groups); + } + private void addHmacIfNeeded(String msg, Builder builder) throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { if (Config.getInstance().getSettings().requireAuthentication) { byte[] key = Config.getInstance().getSettings().key; @@ -225,10 +273,25 @@ public class RemoteRecorder implements Recorder { syncOnlineModels(); syncSpace(); syncRecordings(); + syncModelGroups(); sleep(); } } + private void syncModelGroups() { + try { + sendRequest("listModelGroups").ifPresent(body -> { + try { + updateModelGroups(body); + } catch (IOException e) { + LOG.error("Error while loading model groups from server", e); + } + }); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException e) { + LOG.error("Error while loading model groups from server", e); + } + } + private void syncSpace() { try { String msg = "{\"action\": \"space\"}"; @@ -412,6 +475,12 @@ public class RemoteRecorder implements Recorder { public List models; } + private static class ModelGroupListResponse { + public String status; + public String msg; + public List groups; + } + private static class SimpleResponse { public String status; public String msg; @@ -460,6 +529,33 @@ public class RemoteRecorder implements Recorder { } } + public static class ModelGroupRequest { + private String action; + private ModelGroup modelGroup; + + public ModelGroupRequest(String action, ModelGroup modelGroup) { + super(); + this.action = action; + this.modelGroup = modelGroup; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public ModelGroup getModelGroup() { + return modelGroup; + } + + public void setModelGroup(ModelGroup model) { + this.modelGroup = model; + } + } + public static class RecordingRequest { private String action; private Recording recording; @@ -592,4 +688,19 @@ public class RemoteRecorder implements Recorder { public int getModelCount() { return (int) models.stream().filter(m -> !m.isMarkedForLaterRecording()).count(); } + + @Override + public Set getModelGroups() throws InvalidKeyException, NoSuchAlgorithmException, IOException { + return modelGroups; + } + + @Override + public void saveModelGroup(ModelGroup group) throws InvalidKeyException, NoSuchAlgorithmException, IOException { + sendRequest("saveModelGroup", group); + } + + @Override + public void deleteModelGroup(ModelGroup group) throws InvalidKeyException, NoSuchAlgorithmException, IOException { + sendRequest("deleteModelGroup", group); + } } diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java b/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java index bb2ae13f..d5956422 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java @@ -3,105 +3,167 @@ package ctbrec.recorder.postprocessing; import static ctbrec.StringUtil.*; import static java.util.Optional.*; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; import java.time.ZoneId; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.HashMap; import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import ctbrec.Config; +import ctbrec.Model; +import ctbrec.ModelGroup; import ctbrec.Recording; +import ctbrec.StringUtil; import ctbrec.sites.Site; public abstract class AbstractPlaceholderAwarePostProcessor extends AbstractPostProcessor { - public static final String[] PLACE_HOLDERS = { - "${modelName}", - "${modelDisplayName}", - "${modelSanitizedName}", - "${siteName}", - "${siteSanitizedName}", - "${utcDateTime}", - "${localDateTime}", - "${epochSecond}", - "${fileSuffix}", - "${modelNotes}", - "${recordingNotes}", - "${recordingsDir}", - "${absolutePath}", - "${absoluteParentPath}" - }; + private static final Logger LOG = LoggerFactory.getLogger(AbstractPlaceholderAwarePostProcessor.class); - public String fillInPlaceHolders(String input, Recording rec, Config config) { - // @formatter:off - String output = input - .replace("${modelName}", ofNullable(rec.getModel().getName()).orElse("modelName")) - .replace("${modelDisplayName}", ofNullable(rec.getModel().getDisplayName()).orElse("displayName")) - .replace("${modelSanitizedName}", ofNullable(rec.getModel().getSanitizedNamed()).orElse("sanitizedName")) - .replace("${siteName}", ofNullable(rec.getModel().getSite()).map(Site::getName).orElse("site")) - .replace("${siteSanitizedName}", getSanitizedSiteName(rec)) - .replace("${fileSuffix}", getFileSuffix(rec)) - .replace("${epochSecond}", Long.toString(rec.getStartDate().getEpochSecond())) - .replace("${modelNotes}", sanitize(config.getModelNotes(rec.getModel()))) - .replace("${recordingNotes}", getSanitizedRecordingNotes(rec)) - .replace("${recordingsDir}", config.getSettings().recordingsDir) - .replace("${absolutePath}", rec.getPostProcessedFile().getAbsolutePath()) - .replace("${absoluteParentPath}", rec.getPostProcessedFile().getParentFile().getAbsolutePath()) - ; + public String fillInPlaceHolders(String input, PostProcessingContext ctx) { + Recording rec = ctx.getRecording(); + Config config = ctx.getConfig(); + Optional modelGroup; + try { + modelGroup = ctx.getRecorder().getModelGroup(rec.getModel()); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + LOG.error("Couldn't get model group for {}", rec.getModel(), e); + return input; + } - output = replaceUtcDateTime(rec, output); - output = replaceLocalDateTime(rec, output); + Map>> placeholderValueSuppliers = new HashMap<>(); + placeholderValueSuppliers.put("modelName", r -> ofNullable(rec.getModel().getName())); + placeholderValueSuppliers.put("modelDisplayName", r -> ofNullable(rec.getModel().getDisplayName())); + placeholderValueSuppliers.put("modelSanitizedName", r -> getSanitizedName(rec.getModel())); + placeholderValueSuppliers.put("siteName", r -> ofNullable(rec.getModel().getSite()).map(Site::getName)); + placeholderValueSuppliers.put("siteSanitizedName", r -> getSanitizedSiteName(rec)); + placeholderValueSuppliers.put("fileSuffix", r -> getFileSuffix(rec)); + placeholderValueSuppliers.put("epochSecond", r -> ofNullable(rec.getStartDate()).map(Instant::getEpochSecond).map(l -> Long.toString(l))); // NOSONAR + placeholderValueSuppliers.put("modelNotes", r -> getSanitizedModelNotes(config, rec.getModel())); + placeholderValueSuppliers.put("recordingNotes", r -> getSanitizedRecordingNotes(rec)); + placeholderValueSuppliers.put("recordingsDir", r -> Optional.of(config.getSettings().recordingsDir)); + placeholderValueSuppliers.put("absolutePath", r -> Optional.of(rec.getPostProcessedFile().getAbsolutePath())); + placeholderValueSuppliers.put("absoluteParentPath", r -> Optional.of(rec.getPostProcessedFile().getParentFile().getAbsolutePath())); + placeholderValueSuppliers.put("modelGroupName", r -> modelGroup.map(ModelGroup::getName)); + placeholderValueSuppliers.put("modelGroupId", r -> modelGroup.map(ModelGroup::getId).map(UUID::toString)); + placeholderValueSuppliers.put("utcDateTime", pattern -> replaceUtcDateTime(rec, pattern)); + placeholderValueSuppliers.put("localDateTime", pattern -> replaceLocalDateTime(rec, pattern)); + String output = fillInPlaceHolders(input, placeholderValueSuppliers); return output; - // @formatter:on } - private String replaceUtcDateTime(Recording rec, String filename) { - return replaceDateTime(rec, filename, "utcDateTime", ZoneOffset.UTC); - } - - private String replaceLocalDateTime(Recording rec, String filename) { - return replaceDateTime(rec, filename, "localDateTime", ZoneId.systemDefault()); - } - - private String replaceDateTime(Recording rec, String filename, String placeHolder, ZoneId zone) { - String pattern = "yyyy-MM-dd_HH-mm-ss"; - Pattern regex = Pattern.compile("\\$\\{" + placeHolder + "(?:\\((.*?)\\))?\\}"); - Matcher m = regex.matcher(filename); - while (m.find()) { - String p = m.group(1); - if (p != null) { - pattern = p; - } - String formattedDate = getDateTime(rec, pattern, zone); - filename = m.replaceFirst(formattedDate); - m = regex.matcher(filename); - } - return filename; - } - - private String getDateTime(Recording rec, String pattern, ZoneId zone) { - return DateTimeFormatter.ofPattern(pattern) - .withLocale(Locale.getDefault()) - .withZone(zone) - .format(rec.getStartDate()); - } - - private CharSequence getFileSuffix(Recording rec) { - if(rec.isSingleFile()) { - String filename = rec.getPostProcessedFile().getName(); - return filename.substring(filename.lastIndexOf('.') + 1); + private Optional getSanitizedName(Model model) { + String name = model.getSanitizedNamed(); + if (StringUtil.isBlank(name)) { + return Optional.empty(); } else { - return ""; + return Optional.of(name); } } - private CharSequence getSanitizedSiteName(Recording rec) { - return sanitize(ofNullable(rec.getModel().getSite()).map(Site::getName).orElse("")); + private String fillInPlaceHolders(String input, Map>> placeholderValueSuppliers) { + boolean somethingReplaced = false; + do { + somethingReplaced = false; + int end = input.indexOf("}"); + if (end > 0) { + int start = input.substring(0, end).lastIndexOf("${"); + if (start >= 0) { + String placeholder = input.substring(start, end + 1); + String placeholderName = placeholder.substring(2, placeholder.length() - 1); + String defaultValue = null; + String expression = null; + int questionMark = placeholder.indexOf('?'); + if (questionMark > 0) { + placeholderName = placeholder.substring(2, questionMark); + defaultValue = placeholder.substring(questionMark + 1, placeholder.length() - 1); + } + int bracket = placeholder.indexOf('('); + if (bracket > 0) { + placeholderName = placeholder.substring(2, bracket); + expression = placeholder.substring(bracket + 1, placeholder.indexOf(')', bracket)); + } + + final String name = placeholderName; + Optional optionalValue = placeholderValueSuppliers.getOrDefault(name, r -> Optional.of(name)).apply(expression); + String value = optionalValue.orElse(defaultValue); + StringBuilder sb = new StringBuilder(input); + String output = sb.replace(start, end+1, value).toString(); + somethingReplaced = !Objects.equals(input, output); + input = output; + } + } + } while (somethingReplaced); + return input; } - private CharSequence getSanitizedRecordingNotes(Recording rec) { - return sanitize(ofNullable(rec.getNote()).orElse("")); + private Optional replaceUtcDateTime(Recording rec, String pattern) { + return replaceDateTime(rec, pattern, ZoneOffset.UTC); + } + + private Optional replaceLocalDateTime(Recording rec, String filename) { + return replaceDateTime(rec, filename, ZoneId.systemDefault()); + } + + private Optional replaceDateTime(Recording rec, String pattern, ZoneId zone) { + pattern = pattern != null ? pattern : "yyyy-MM-dd_HH-mm-ss"; + return getDateTime(rec, pattern, zone); + } + + private Optional getDateTime(Recording rec, String pattern, ZoneId zone) { + return Optional.ofNullable(rec.getStartDate()) // + .map(DateTimeFormatter.ofPattern(pattern) // + .withLocale(Locale.getDefault()) // + .withZone(zone) // + ::format); + } + + private Optional getFileSuffix(Recording rec) { + if (rec.isSingleFile()) { + String filename = rec.getPostProcessedFile().getName(); + return Optional.of(filename.substring(filename.lastIndexOf('.') + 1)); + } else { + return Optional.empty(); + } + } + + private Optional getSanitizedSiteName(Recording rec) { + Optional name = ofNullable(rec.getModel().getSite()).map(Site::getName); + if (name.isPresent()) { + return Optional.of(sanitize(name.get())); + } else { + return Optional.empty(); + } + } + + private Optional getSanitizedRecordingNotes(Recording rec) { + Optional notes = ofNullable(rec.getNote()); + if (notes.isPresent()) { + return Optional.of(sanitize(notes.get())); + } else { + return Optional.empty(); + } + } + + private Optional getSanitizedModelNotes(Config config, Model m) { + Optional notes = ofNullable(config.getModelNotes(m)); + if (notes.isPresent()) { + return Optional.of(sanitize(notes.get())); + } else { + return Optional.empty(); + } } } diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Copy.java b/common/src/main/java/ctbrec/recorder/postprocessing/Copy.java index 9ffeb315..d604e249 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Copy.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Copy.java @@ -8,9 +8,7 @@ import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import ctbrec.Config; import ctbrec.Recording; -import ctbrec.recorder.RecordingManager; public class Copy extends AbstractPostProcessor { @@ -22,7 +20,8 @@ public class Copy extends AbstractPostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { + Recording rec = ctx.getRecording(); File orig = rec.getPostProcessedFile(); String copyFilename = getFilenameForCopy(orig); File copy = new File(orig.getParentFile(), copyFilename); diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java b/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java index 5459dcdc..951cac6c 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java @@ -22,7 +22,6 @@ import ctbrec.OS; import ctbrec.Recording; import ctbrec.io.IoUtils; import ctbrec.recorder.FFmpeg; -import ctbrec.recorder.RecordingManager; import ctbrec.recorder.download.ProcessExitedUncleanException; @ThreadSafe @@ -45,7 +44,9 @@ public class CreateContactSheet extends AbstractPlaceholderAwarePostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { + Recording rec = ctx.getRecording(); + Config config = ctx.getConfig(); int totalWidth = Integer.parseInt(getConfig().getOrDefault(TOTAL_SIZE, "1920")); int padding = Integer.parseInt(getConfig().getOrDefault(PADDING, "4")); int cols = Integer.parseInt(getConfig().getOrDefault(COLS, "8")); @@ -65,7 +66,7 @@ public class CreateContactSheet extends AbstractPlaceholderAwarePostProcessor { color}, new StringBuffer(), null).toString(); File executionDir = rec.getPostProcessedFile().isDirectory() ? rec.getPostProcessedFile() : rec.getPostProcessedFile().getParentFile(); - File output = new File(executionDir, fillInPlaceHolders(filename, rec, config)); + File output = new File(executionDir, fillInPlaceHolders(filename, ctx)); String[] args = { "-y", diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/CreateTimelineThumbs.java b/common/src/main/java/ctbrec/recorder/postprocessing/CreateTimelineThumbs.java index 46cb7240..c3b7b42e 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/CreateTimelineThumbs.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/CreateTimelineThumbs.java @@ -2,10 +2,7 @@ package ctbrec.recorder.postprocessing; import java.io.IOException; -import ctbrec.Config; import ctbrec.NotImplementedExcetion; -import ctbrec.Recording; -import ctbrec.recorder.RecordingManager; public class CreateTimelineThumbs extends AbstractPlaceholderAwarePostProcessor { @@ -15,7 +12,7 @@ public class CreateTimelineThumbs extends AbstractPlaceholderAwarePostProcessor } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { // create 1 thumb every second with a width of 360 pixels and save it as jpeg with a 5-digit sequence number // ffmpeg -i -vf 'fps=1,scale=360:-1' thumbs/out%05d.jpg throw new NotImplementedExcetion(); diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/DeleteOriginal.java b/common/src/main/java/ctbrec/recorder/postprocessing/DeleteOriginal.java index 7edf92fb..965ef2f0 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/DeleteOriginal.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/DeleteOriginal.java @@ -5,9 +5,7 @@ import static ctbrec.io.IoUtils.*; import java.io.IOException; import java.nio.file.Files; -import ctbrec.Config; import ctbrec.Recording; -import ctbrec.recorder.RecordingManager; public class DeleteOriginal extends AbstractPostProcessor { @@ -17,7 +15,8 @@ public class DeleteOriginal extends AbstractPostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { + Recording rec = ctx.getRecording(); if (rec.getAbsoluteFile().isFile()) { Files.deleteIfExists(rec.getAbsoluteFile().toPath()); deleteEmptyParents(rec.getAbsoluteFile().getParentFile()); diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/DeleteTooShort.java b/common/src/main/java/ctbrec/recorder/postprocessing/DeleteTooShort.java index 39b3691a..7835285c 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/DeleteTooShort.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/DeleteTooShort.java @@ -6,7 +6,6 @@ import java.time.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import ctbrec.Config; import ctbrec.Recording; import ctbrec.recorder.RecordingManager; @@ -21,7 +20,9 @@ public class DeleteTooShort extends AbstractPostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException { + public boolean postprocess(PostProcessingContext ctx) throws IOException { + Recording rec = ctx.getRecording(); + RecordingManager recordingManager = ctx.getRecordingManager(); Duration minimumLengthInSeconds = Duration.ofSeconds(Integer.parseInt(getConfig().getOrDefault(MIN_LEN_IN_SECS, "0"))); if (minimumLengthInSeconds.getSeconds() > 0) { Duration recordingLength = rec.getLength(); diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Move.java b/common/src/main/java/ctbrec/recorder/postprocessing/Move.java index 3b33b736..1633400a 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Move.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Move.java @@ -10,9 +10,7 @@ import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import ctbrec.Config; import ctbrec.Recording; -import ctbrec.recorder.RecordingManager; public class Move extends AbstractPlaceholderAwarePostProcessor { @@ -26,9 +24,10 @@ public class Move extends AbstractPlaceholderAwarePostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException { + public boolean postprocess(PostProcessingContext ctx) throws IOException { + Recording rec = ctx.getRecording(); String pathTemplate = getConfig().getOrDefault(PATH_TEMPLATE, DEFAULT); - String path = fillInPlaceHolders(pathTemplate, rec, config); + String path = fillInPlaceHolders(pathTemplate, ctx); File src = rec.getPostProcessedFile(); boolean isFile = src.isFile(); File target = new File(path, src.getName()); diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessingContext.java b/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessingContext.java new file mode 100644 index 00000000..057c948f --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessingContext.java @@ -0,0 +1,46 @@ +package ctbrec.recorder.postprocessing; + +import ctbrec.Config; +import ctbrec.Recording; +import ctbrec.recorder.Recorder; +import ctbrec.recorder.RecordingManager; + +public class PostProcessingContext { + + private Recorder recorder; + private Recording recording; + private RecordingManager recordingManager; + private Config config; + + public Recorder getRecorder() { + return recorder; + } + + public void setRecorder(Recorder recorder) { + this.recorder = recorder; + } + + public Recording getRecording() { + return recording; + } + + public void setRecording(Recording recording) { + this.recording = recording; + } + + public RecordingManager getRecordingManager() { + return recordingManager; + } + + public void setRecordingManager(RecordingManager recordingManager) { + this.recordingManager = recordingManager; + } + + public Config getConfig() { + return config; + } + + public void setConfig(Config config) { + this.config = config; + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessor.java b/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessor.java index 66a84591..8e2d474b 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessor.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessor.java @@ -4,23 +4,17 @@ import java.io.IOException; import java.io.Serializable; import java.util.Map; -import ctbrec.Config; -import ctbrec.Recording; -import ctbrec.recorder.RecordingManager; - public interface PostProcessor extends Serializable { String getName(); /** * Runs the post-processing code on the given recording - * @param rec the recording to post-process - * @param recordingManager - * @param config + * @param ctx {@link PostProcessingContext}, which allows access to post-processing related objects * @return false to stop futher post-processing, true to continue * @throws IOException * @throws InterruptedException */ - boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException; + boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException; Map getConfig(); void setConfig(Map conf); diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/RemoveKeepFile.java b/common/src/main/java/ctbrec/recorder/postprocessing/RemoveKeepFile.java index 5e47b7c7..7e574459 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/RemoveKeepFile.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/RemoveKeepFile.java @@ -2,10 +2,6 @@ package ctbrec.recorder.postprocessing; import java.io.IOException; -import ctbrec.Config; -import ctbrec.Recording; -import ctbrec.recorder.RecordingManager; - public class RemoveKeepFile extends AbstractPostProcessor { @Override @@ -14,9 +10,9 @@ public class RemoveKeepFile extends AbstractPostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { - recordingManager.remove(rec); - rec.setMetaDataFile(null); + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { + ctx.getRecordingManager().remove(ctx.getRecording()); + ctx.getRecording().setMetaDataFile(null); return true; } } diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java b/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java index c18eb389..3994830b 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java @@ -9,12 +9,10 @@ import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import ctbrec.Config; import ctbrec.OS; import ctbrec.Recording; import ctbrec.io.IoUtils; import ctbrec.recorder.FFmpeg; -import ctbrec.recorder.RecordingManager; public class Remux extends AbstractPostProcessor { @@ -29,7 +27,8 @@ public class Remux extends AbstractPostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { + Recording rec = ctx.getRecording(); final File inputFile; if (rec.getPostProcessedFile().isDirectory()) { inputFile = new File(rec.getPostProcessedFile(), "playlist.m3u8"); @@ -44,7 +43,7 @@ public class Remux extends AbstractPostProcessor { File ffmpegLog = new File(System.getProperty("java.io.tmpdir"), "remux_" + rec.getId() + ".log"); FFmpeg ffmpeg = new FFmpeg.Builder() - .logOutput(config.getSettings().logFFmpegOutput) + .logOutput(ctx.getConfig().getSettings().logFFmpegOutput) .logFile(ffmpegLog) .onExit(exitCode -> finalizeStep(exitCode, rec, inputFile, remuxedFile)) .build(); diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Rename.java b/common/src/main/java/ctbrec/recorder/postprocessing/Rename.java index 17871e39..298e3653 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Rename.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Rename.java @@ -7,9 +7,7 @@ import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import ctbrec.Config; import ctbrec.Recording; -import ctbrec.recorder.RecordingManager; public class Rename extends AbstractPlaceholderAwarePostProcessor { @@ -24,10 +22,11 @@ public class Rename extends AbstractPlaceholderAwarePostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException { + public boolean postprocess(PostProcessingContext ctx) throws IOException { + Recording rec = ctx.getRecording(); String defaultTemplate = rec.isSingleFile() ? DEFAULT : DEFAULT_DIR; String filenameTemplate = getConfig().getOrDefault(FILE_NAME_TEMPLATE, defaultTemplate); - String filename = fillInPlaceHolders(filenameTemplate, rec, config); + String filename = fillInPlaceHolders(filenameTemplate, ctx); File src = rec.getPostProcessedFile(); File target = new File(src.getParentFile(), filename); if (Objects.equals(src, target)) { diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Script.java b/common/src/main/java/ctbrec/recorder/postprocessing/Script.java index ec5c8eb7..43c8d1eb 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Script.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Script.java @@ -10,11 +10,9 @@ import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import ctbrec.Config; import ctbrec.OS; import ctbrec.Recording; import ctbrec.io.StreamRedirector; -import ctbrec.recorder.RecordingManager; import ctbrec.recorder.download.ProcessExitedUncleanException; public class Script extends AbstractPlaceholderAwarePostProcessor { @@ -29,8 +27,9 @@ public class Script extends AbstractPlaceholderAwarePostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { - List cmdline = buildCommandLine(rec, config); + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { + Recording rec = ctx.getRecording(); + List cmdline = buildCommandLine(ctx); Runtime rt = Runtime.getRuntime(); String[] args = cmdline.toArray(new String[0]); if (LOG.isDebugEnabled()) { @@ -50,12 +49,12 @@ public class Script extends AbstractPlaceholderAwarePostProcessor { return true; } - private List buildCommandLine(Recording rec, Config config) throws IOException { + private List buildCommandLine(PostProcessingContext ctx) { String script = getConfig().getOrDefault(SCRIPT_EXECUTABLE, "somescript"); String params = getConfig().getOrDefault(SCRIPT_PARAMS, "${absolutePath}"); List cmdline = new ArrayList<>(); cmdline.add(script); - String replacedParams = fillInPlaceHolders(params, rec, config); + String replacedParams = fillInPlaceHolders(params, ctx); Arrays.stream(replacedParams.split(" ")).forEach(cmdline::add); return cmdline; } diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java b/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java index cee86c2d..284041da 100644 --- a/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java @@ -2,10 +2,7 @@ package ctbrec.recorder.postprocessing; import java.io.IOException; -import ctbrec.Config; import ctbrec.NotImplementedExcetion; -import ctbrec.Recording; -import ctbrec.recorder.RecordingManager; public class Webhook extends AbstractPlaceholderAwarePostProcessor { @@ -21,7 +18,7 @@ public class Webhook extends AbstractPlaceholderAwarePostProcessor { } @Override - public boolean postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { throw new NotImplementedExcetion(); } diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java index e3e6797d..06f91a45 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java @@ -37,19 +37,19 @@ public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest { @Test public void testModelNameReplacement() { String input = "asdf_${modelName}_asdf"; - assertEquals("asdf_Mockita Boobilicious_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_Mockita Boobilicious_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); input = "asdf_${modelDisplayName}_asdf"; - assertEquals("asdf_Mockita Boobilicious_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_Mockita Boobilicious_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); input = "asdf_${modelSanitizedName}_asdf"; - assertEquals("asdf_Mockita_Boobilicious_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_Mockita_Boobilicious_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); } @Test public void testSiteNameReplacement() { String input = "asdf_${siteName}_asdf"; - assertEquals("asdf_Chaturbate_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_Chaturbate_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); input = "asdf_${siteSanitizedName}_asdf"; - assertEquals("asdf_Chaturbate_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_Chaturbate_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); } @Test @@ -60,7 +60,7 @@ public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest { .withZone(ZoneOffset.UTC) .format(rec.getStartDate()); String input = "asdf_${utcDateTime}_asdf"; - assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); // with user defined pattern date = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") @@ -68,7 +68,7 @@ public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest { .withZone(ZoneOffset.UTC) .format(rec.getStartDate()); input = "asdf_${utcDateTime(yyyyMMdd-HHmmss)}_asdf"; - assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); // multiple occurences with user defined patterns date = DateTimeFormatter.ofPattern("yyyy-MM-dd/yyyy") @@ -76,7 +76,7 @@ public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest { .withZone(ZoneOffset.UTC) .format(rec.getStartDate()); input = "asdf_${utcDateTime(yyyy)}-${utcDateTime(MM)}-${utcDateTime(dd)}/${utcDateTime(yyyy)}_asdf"; - assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); } @Test @@ -86,50 +86,68 @@ public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest { .withZone(ZoneId.systemDefault()) .format(rec.getStartDate()); String input = "asdf_${localDateTime}_asdf"; - assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); date = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") .withLocale(Locale.US) .withZone(ZoneId.systemDefault()) .format(rec.getStartDate()); input = "asdf_${localDateTime(yyyyMMdd-HHmmss)}_asdf"; - assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); } @Test public void testEpochReplacement() { long epoch = now.toEpochMilli() / 1000; String input = "asdf_${epochSecond}_asdf"; - assertEquals("asdf_" + epoch + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_" + epoch + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); } @Test public void testFileSuffixReplacement() { String input = "asdf_${fileSuffix}_asdf"; - assertEquals("asdf_ts_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_ts_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); } @Test public void testRecordingsDirReplacement() { String input = "asdf_${recordingsDir}_asdf"; - assertEquals("asdf_" + recDir.toString() + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_" + recDir.toString() + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); } @Test public void testAbsolutePathReplacement() { String input = "asdf_${absolutePath}_asdf"; - assertEquals("asdf_" + postProcessed.getAbsolutePath().toString() + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_" + postProcessed.getAbsolutePath().toString() + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); } @Test public void testAbsoluteParentPathReplacement() { String input = "asdf_${absoluteParentPath}_asdf"; - assertEquals("asdf_" + postProcessed.getParentFile().toString() + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_" + postProcessed.getParentFile().toString() + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); } @Test public void testModelNotesReplacement() { String input = "asdf_${modelNotes}_asdf"; - assertEquals("asdf_tag,_foo,_bar_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + assertEquals("asdf_tag,_foo,_bar_asdf", placeHolderAwarePp.fillInPlaceHolders(input, createPostProcessingContext(rec, null, config))); + } + + @Test + public void testPlaceholderDefaultValues() throws IOException { + String input = "asdf_${modelGroupName?${modelSanitizedName?anonymous}}_asdf"; + PostProcessingContext ctx = createPostProcessingContext(rec, null, config); + ctx.getRecording().getModel().setName(null); + assertEquals("asdf_anonymous_asdf", placeHolderAwarePp.fillInPlaceHolders(input, ctx)); + + input = "asdf_${modelGroupName?${utcDateTime(yyyy)?anonymous}}_asdf"; + assertEquals("asdf_2021_asdf", placeHolderAwarePp.fillInPlaceHolders(input, ctx)); + + ctx.getRecording().setStartDate(null); + input = "asdf_${modelGroupName?${utcDateTime(yyyy)?anonymous}}_asdf"; + assertEquals("asdf_anonymous_asdf", placeHolderAwarePp.fillInPlaceHolders(input, ctx)); + + input = "asdf_${modelGroupName?${utcDateTime?anonymous}}_asdf"; + assertEquals("asdf_anonymous_asdf", placeHolderAwarePp.fillInPlaceHolders(input, ctx)); } } diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPpTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPpTest.java index 0d9e65e7..ba3cf6b7 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPpTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPpTest.java @@ -18,6 +18,7 @@ import org.mockito.MockedStatic; import ctbrec.Config; import ctbrec.Model; +import ctbrec.Recording; import ctbrec.Settings; import ctbrec.recorder.RecordingManager; import ctbrec.sites.Site; @@ -81,4 +82,12 @@ public abstract class AbstractPpTest { settings.recordingsDir = recDir.toString(); return settings; } + + PostProcessingContext createPostProcessingContext(Recording rec, RecordingManager recordingManager, Config config) { + PostProcessingContext ctx = new PostProcessingContext(); + ctx.setConfig(config); + ctx.setRecording(rec); + ctx.setRecordingManager(recordingManager); + return ctx; + } } diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/CopyTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/CopyTest.java index f28f4e26..27624a12 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/CopyTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/CopyTest.java @@ -20,7 +20,7 @@ public class CopyTest extends AbstractPpTest { rec.setStartDate(now); rec.setSingleFile(false); Copy pp = new Copy(); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); assertNotEquals(rec.getAbsoluteFile(), rec.getPostProcessedFile()); assertTrue(original.exists()); @@ -36,7 +36,7 @@ public class CopyTest extends AbstractPpTest { rec.setStartDate(now); rec.setSingleFile(false); Copy pp = new Copy(); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); assertNotEquals(rec.getAbsoluteFile(), rec.getPostProcessedFile()); assertTrue(originalDir.exists()); diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/DeleteOriginalTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/DeleteOriginalTest.java index 48e54386..7616c2c9 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/DeleteOriginalTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/DeleteOriginalTest.java @@ -23,7 +23,7 @@ public class DeleteOriginalTest extends AbstractPpTest { Config config = mockConfig(); DeleteOriginal pp = new DeleteOriginal(); - pp.postprocess(rec, null, config); + pp.postprocess(createPostProcessingContext(rec, null, config)); assertEquals(postProcessed, rec.getAbsoluteFile()); assertTrue(rec.getAbsoluteFile().exists()); @@ -42,7 +42,7 @@ public class DeleteOriginalTest extends AbstractPpTest { Config config = mockConfig(); Files.createDirectories(postProcessedDir.toPath()); DeleteOriginal pp = new DeleteOriginal(); - pp.postprocess(rec, null, config); + pp.postprocess(createPostProcessingContext(rec, null, config)); assertEquals(postProcessedDir, rec.getAbsoluteFile()); assertTrue(rec.getAbsoluteFile().exists()); diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/DeleteTooShortTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/DeleteTooShortTest.java index e99cbada..0b11176b 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/DeleteTooShortTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/DeleteTooShortTest.java @@ -42,7 +42,7 @@ public class DeleteTooShortTest extends AbstractPpTest { DeleteTooShort pp = new DeleteTooShort(); pp.getConfig().put(DeleteTooShort.MIN_LEN_IN_SECS, "10"); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); assertFalse(rec.getAbsoluteFile().exists()); assertFalse(original.exists()); @@ -66,7 +66,7 @@ public class DeleteTooShortTest extends AbstractPpTest { DeleteTooShort pp = new DeleteTooShort(); pp.getConfig().put(DeleteTooShort.MIN_LEN_IN_SECS, "0"); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); assertTrue(rec.getAbsoluteFile().exists()); assertTrue(original.exists()); @@ -83,7 +83,7 @@ public class DeleteTooShortTest extends AbstractPpTest { DeleteTooShort pp = new DeleteTooShort(); pp.getConfig().put(DeleteTooShort.MIN_LEN_IN_SECS, "1"); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); assertTrue(rec.getAbsoluteFile().exists()); assertTrue(original.exists()); diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/MoveDirectoryTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/MoveDirectoryTest.java index 93508e66..882664ac 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/MoveDirectoryTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/MoveDirectoryTest.java @@ -28,7 +28,7 @@ public class MoveDirectoryTest extends AbstractPpTest { rec.setSingleFile(false); Move pp = new Move(); pp.getConfig().put(Move.PATH_TEMPLATE, new File(baseDir.toFile(), Move.DEFAULT).getAbsolutePath()); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); Matcher m = Pattern.compile(baseDir.toString() + "/Mockita_Boobilicious/\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}/original").matcher(rec.getAbsoluteFile().getCanonicalPath()); assertTrue(m.matches()); @@ -50,6 +50,6 @@ public class MoveDirectoryTest extends AbstractPpTest { Move pp = new Move(); Config config = mockConfig(); pp.getConfig().put(Move.PATH_TEMPLATE, new File(baseDir.toFile(), Move.DEFAULT).getAbsolutePath()); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); } } diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/MoveSingleFileTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/MoveSingleFileTest.java index b2199b80..4758288a 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/MoveSingleFileTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/MoveSingleFileTest.java @@ -28,7 +28,7 @@ public class MoveSingleFileTest extends AbstractPpTest { Move pp = new Move(); pp.getConfig().put(Move.PATH_TEMPLATE, new File(baseDir.toFile(), Move.DEFAULT).getAbsolutePath()); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); Matcher m = Pattern.compile(baseDir.toFile() + "/Mockita_Boobilicious/\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}/original\\.ts").matcher(rec.getAbsoluteFile().toString()); assertTrue(m.matches()); @@ -48,7 +48,7 @@ public class MoveSingleFileTest extends AbstractPpTest { Move pp = new Move(); Config config = mockConfig(); pp.getConfig().put(Move.PATH_TEMPLATE, original.getParentFile().getCanonicalPath()); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); } @Test @@ -63,7 +63,7 @@ public class MoveSingleFileTest extends AbstractPpTest { Move pp = new Move(); Config config = mockConfig(); pp.getConfig().put(Move.PATH_TEMPLATE, new File(baseDir.toFile(), Move.DEFAULT).getAbsolutePath()); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); } @Test diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/RemoveKeepFileTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/RemoveKeepFileTest.java index 8909091f..a4b342fd 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/RemoveKeepFileTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/RemoveKeepFileTest.java @@ -25,9 +25,9 @@ public class RemoveKeepFileTest extends AbstractPpTest { Config config = mockConfig(); RecordingManager rm = new RecordingManager(config, Collections.emptyList()); rm.add(rec); - assertTrue(rm.getAll().size() == 1); + assertEquals(1, rm.getAll().size()); RemoveKeepFile pp = new RemoveKeepFile(); - pp.postprocess(rec, rm, config); + pp.postprocess(createPostProcessingContext(rec, rm, config)); assertTrue(rec.getAbsoluteFile().exists()); assertTrue(rec.getPostProcessedFile().exists()); diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/RenameDirectoryTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/RenameDirectoryTest.java index cdd9cfb2..a8213d81 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/RenameDirectoryTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/RenameDirectoryTest.java @@ -26,7 +26,7 @@ public class RenameDirectoryTest extends AbstractPpTest { rec.setStartDate(now); rec.setSingleFile(false); Rename pp = new Rename(); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); Matcher m = Pattern.compile("Mockita_Boobilicious_\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}").matcher(rec.getAbsoluteFile().getName()); assertTrue(m.matches()); @@ -47,6 +47,6 @@ public class RenameDirectoryTest extends AbstractPpTest { Files.createDirectories(postProcessedDir.toPath()); Rename pp = new Rename(); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); } } diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/RenameSingleFileTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/RenameSingleFileTest.java index de739cd5..a0f59ff9 100644 --- a/common/src/test/java/ctbrec/recorder/postprocessing/RenameSingleFileTest.java +++ b/common/src/test/java/ctbrec/recorder/postprocessing/RenameSingleFileTest.java @@ -25,7 +25,7 @@ public class RenameSingleFileTest extends AbstractPpTest { rec.setStartDate(now); rec.setSingleFile(true); Rename pp = new Rename(); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); Matcher m = Pattern.compile("Mockita_Boobilicious_\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}\\.ts").matcher(rec.getAbsoluteFile().getName()); assertTrue(m.matches()); @@ -45,7 +45,7 @@ public class RenameSingleFileTest extends AbstractPpTest { doThrow(new RuntimeException("Unexpected call of setAbsoluteFile")).when(rec).setAbsoluteFile(any()); Rename pp = new Rename(); pp.getConfig().put(Rename.FILE_NAME_TEMPLATE, original.getName()); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); } @Test @@ -59,7 +59,7 @@ public class RenameSingleFileTest extends AbstractPpTest { when(rec.getStartDate()).thenReturn(now); doThrow(new RuntimeException("Unexpected call of setAbsoluteFile")).when(rec).setAbsoluteFile(any()); Rename pp = new Rename(); - pp.postprocess(rec, recordingManager, config); + pp.postprocess(createPostProcessingContext(rec, recordingManager, config)); } @Test diff --git a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java index 1cefe802..ca1c032e 100644 --- a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java @@ -10,6 +10,8 @@ import java.time.Instant; import java.util.Iterator; import java.util.List; import java.util.Objects; +import java.util.Set; +import java.util.UUID; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; @@ -25,11 +27,13 @@ import com.squareup.moshi.Moshi; import ctbrec.Config; import ctbrec.GlobalThreadPool; import ctbrec.Model; +import ctbrec.ModelGroup; import ctbrec.Recording; import ctbrec.io.BandwidthMeter; import ctbrec.io.FileJsonAdapter; import ctbrec.io.InstantJsonAdapter; import ctbrec.io.ModelJsonAdapter; +import ctbrec.io.UuidJSonAdapter; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; @@ -67,6 +71,7 @@ public class RecorderServlet extends AbstractCtbrecServlet { .add(Instant.class, new InstantJsonAdapter()) .add(Model.class, new ModelJsonAdapter(sites)) .add(File.class, new FileJsonAdapter()) + .add(UUID.class, new UuidJSonAdapter()) .build(); JsonAdapter requestAdapter = moshi.adapter(Request.class); Request request = requestAdapter.fromJson(json); @@ -234,6 +239,17 @@ public class RecorderServlet extends AbstractCtbrecServlet { response = "{\"status\": \"success\"}"; resp.getWriter().write(response); break; + case "saveModelGroup": + recorder.saveModelGroup(request.modelGroup); + sendModelGroups(resp, recorder.getModelGroups()); + break; + case "deleteModelGroup": + recorder.deleteModelGroup(request.modelGroup); + sendModelGroups(resp, recorder.getModelGroups()); + break; + case "listModelGroups": + sendModelGroups(resp, recorder.getModelGroups()); + break; default: resp.setStatus(SC_BAD_REQUEST); response = "{\"status\": \"error\", \"msg\": \"Unknown action ["+request.action+"]\"}"; @@ -258,6 +274,13 @@ public class RecorderServlet extends AbstractCtbrecServlet { } } + private void sendModelGroups(HttpServletResponse resp, Set modelGroups) throws IOException { + JSONObject jsonResponse = new JSONObject(); + jsonResponse.put("status", "success"); + jsonResponse.put("groups", modelGroups); + resp.getWriter().write(jsonResponse.toString()); + } + private void startByUrl(Request request) throws InvalidKeyException, NoSuchAlgorithmException, IOException { String url = request.model.getUrl(); for (Site site : sites) { @@ -291,5 +314,6 @@ public class RecorderServlet extends AbstractCtbrecServlet { public String action; public Model model; public Recording recording; + public ModelGroup modelGroup; } }