diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc1f8af..00a88bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +3.10.0 +======================== +* New post-processing +* Fix: MV Live models with spaces in the name not indicated as recording + 3.9.0 ======================== * Added support for Manyvids Live. diff --git a/client/pom.xml b/client/pom.xml index 2bfba9df..5f7f04d4 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -8,7 +8,7 @@ ctbrec master - 3.9.0 + 3.10.0 ../master diff --git a/client/src/main/java/ctbrec/ui/JavaFxRecording.java b/client/src/main/java/ctbrec/ui/JavaFxRecording.java index 0009d9d2..f74f666c 100644 --- a/client/src/main/java/ctbrec/ui/JavaFxRecording.java +++ b/client/src/main/java/ctbrec/ui/JavaFxRecording.java @@ -1,5 +1,6 @@ package ctbrec.ui; +import java.io.File; import java.time.Instant; import ctbrec.Config; @@ -156,11 +157,7 @@ public class JavaFxRecording extends Recording { setStatus(updated.getStatus()); setProgress(updated.getProgress()); setSizeInByte(updated.getSizeInByte()); - } - - @Override - public String getPath() { - return delegate.getPath(); + setSingleFile(updated.isSingleFile()); } @Override @@ -192,6 +189,11 @@ public class JavaFxRecording extends Recording { return delegate.isSingleFile(); } + @Override + public void setSingleFile(boolean singleFile) { + delegate.setSingleFile(singleFile); + } + @Override public boolean isPinned() { return delegate.isPinned(); @@ -223,4 +225,26 @@ public class JavaFxRecording extends Recording { public StringProperty getNoteProperty() { return notesProperty; } + + @Override + public File getAbsoluteFile() { + return delegate.getAbsoluteFile(); + } + + @Override + public void setAbsoluteFile(File absoluteFile) { + delegate.setAbsoluteFile(absoluteFile); + } + + @Override + public String getId() { + return delegate.getId(); + } + + @Override + public void setId(String id) { + delegate.setId(id); + } + + } diff --git a/client/src/main/java/ctbrec/ui/Player.java b/client/src/main/java/ctbrec/ui/Player.java index 96df79dd..af88b945 100644 --- a/client/src/main/java/ctbrec/ui/Player.java +++ b/client/src/main/java/ctbrec/ui/Player.java @@ -152,7 +152,7 @@ public class Player { Config cfg = Config.getInstance(); try { if (cfg.getSettings().localRecording && rec != null) { - File file = new File(cfg.getSettings().recordingsDir, rec.getPath()); + File file = rec.getAbsoluteFile(); String[] cmdline = createCmdline(file.getAbsolutePath()); playerProcess = rt.exec(cmdline, OS.getEnvironment(), file.getParentFile()); } else { @@ -206,7 +206,7 @@ public class Player { private String getRemoteRecordingUrl(Recording rec, Config cfg) throws MalformedURLException, InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { String hlsBase = Config.getInstance().getServerUrl() + "/hls"; - String recUrl = hlsBase + rec.getPath() + (rec.isSingleFile() ? "" : "/playlist.m3u8"); + String recUrl = hlsBase + '/' + rec.getId() + (rec.isSingleFile() ? "" : "/playlist.m3u8"); if (cfg.getSettings().requireAuthentication) { URL u = new URL(recUrl); String path = u.getPath(); diff --git a/client/src/main/java/ctbrec/ui/settings/AbstractPostProcessingPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/AbstractPostProcessingPaneFactory.java new file mode 100644 index 00000000..76c2fdc6 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/AbstractPostProcessingPaneFactory.java @@ -0,0 +1,194 @@ +package ctbrec.ui.settings; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.ui.controls.DirectorySelectionBox; +import ctbrec.ui.controls.ProgramSelectionBox; +import ctbrec.ui.settings.api.ExclusiveSelectionProperty; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.PreferencesStorage; +import ctbrec.ui.settings.api.Setting; +import ctbrec.ui.settings.api.SimpleDirectoryProperty; +import ctbrec.ui.settings.api.SimpleFileProperty; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ListProperty; +import javafx.beans.property.LongProperty; +import javafx.beans.property.Property; +import javafx.beans.property.StringProperty; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.HBox; +import javafx.util.converter.NumberStringConverter; + +public abstract class AbstractPostProcessingPaneFactory { + + private static final Logger LOG = LoggerFactory.getLogger(AbstractPostProcessingPaneFactory.class); + private PostProcessor pp; + Set> properties = new HashSet<>(); + + public abstract Preferences doCreatePostProcessorPane(PostProcessor pp); + + public Preferences createPostProcessorPane(PostProcessor pp) { + this.pp = pp; + return doCreatePostProcessorPane(pp); + } + + class MapPreferencesStorage implements PreferencesStorage { + + @Override + public void save(Preferences preferences) throws IOException { + for (Property property : properties) { + String key = property.getName(); + Object value = preferences.getSetting(key).get().getProperty().getValue(); + LOG.debug("{}={}", key, value.toString()); + pp.getConfig().put(key, value.toString()); + } + } + + @Override + public void load(Preferences preferences) { + // no op + } + + @Override + public Node createGui(Setting setting) throws Exception { + Property prop = setting.getProperty(); + if (prop instanceof ExclusiveSelectionProperty) { + return createRadioGroup(setting); + } else if (prop instanceof SimpleDirectoryProperty) { + return createDirectorySelector(setting); + } else if (prop instanceof SimpleFileProperty) { + return createFileSelector(setting); + } else if (prop instanceof IntegerProperty) { + return createIntegerProperty(setting); + } else if (prop instanceof LongProperty) { + return createLongProperty(setting); + } else if (prop instanceof BooleanProperty) { + return createBooleanProperty(setting); + } else if (prop instanceof ListProperty) { + return createComboBox(setting); + } else if (prop instanceof StringProperty) { + return createStringProperty(setting); + } else { + return new Label("Unsupported Type for key " + setting.getKey() + ": " + setting.getProperty()); + } + } + } + + private Node createRadioGroup(Setting setting) { + ExclusiveSelectionProperty prop = (ExclusiveSelectionProperty) setting.getProperty(); + ToggleGroup toggleGroup = new ToggleGroup(); + RadioButton optionA = new RadioButton(prop.getOptionA()); + optionA.setSelected(prop.getValue()); + optionA.setToggleGroup(toggleGroup); + RadioButton optionB = new RadioButton(prop.getOptionB()); + optionB.setSelected(!optionA.isSelected()); + optionB.setToggleGroup(toggleGroup); + optionA.selectedProperty().bindBidirectional(prop); + HBox row = new HBox(); + row.getChildren().addAll(optionA, optionB); + HBox.setMargin(optionA, new Insets(5)); + HBox.setMargin(optionB, new Insets(5)); + return row; + } + + private Node createFileSelector(Setting setting) { + ProgramSelectionBox programSelector = new ProgramSelectionBox(""); + // programSelector.fileProperty().addListener((obs, o, n) -> saveValue(() -> { + // String path = n; + // Field field = Settings.class.getField(setting.getKey()); + // String oldValue = (String) field.get(settings); + // if (!Objects.equals(path, oldValue)) { + // field.set(settings, path); + // config.save(); + // } + // })); + StringProperty property = (StringProperty) setting.getProperty(); + programSelector.fileProperty().bindBidirectional(property); + return programSelector; + } + + private Node createDirectorySelector(Setting setting) { + DirectorySelectionBox directorySelector = new DirectorySelectionBox(""); + directorySelector.prefWidth(400); + // directorySelector.fileProperty().addListener((obs, o, n) -> saveValue(() -> { + // String path = n; + // Field field = Settings.class.getField(setting.getKey()); + // String oldValue = (String) field.get(settings); + // if (!Objects.equals(path, oldValue)) { + // field.set(settings, path); + // config.save(); + // } + // })); + StringProperty property = (StringProperty) setting.getProperty(); + directorySelector.fileProperty().bindBidirectional(property); + return directorySelector; + } + + @SuppressWarnings("unchecked") + private Node createStringProperty(Setting setting) { + TextField ctrl = new TextField(); + ctrl.textProperty().bindBidirectional(setting.getProperty()); + return ctrl; + } + + @SuppressWarnings("unchecked") + private Node createIntegerProperty(Setting setting) { + TextField ctrl = new TextField(); + Property prop = setting.getProperty(); + ctrl.textProperty().bindBidirectional(prop, new NumberStringConverter()); + return ctrl; + } + + @SuppressWarnings("unchecked") + private Node createLongProperty(Setting setting) { + TextField ctrl = new TextField(); + Property prop = setting.getProperty(); + ctrl.textProperty().bindBidirectional(prop, new NumberStringConverter()); + return ctrl; + } + + private Node createBooleanProperty(Setting setting) { + CheckBox ctrl = new CheckBox(); + BooleanProperty prop = (BooleanProperty) setting.getProperty(); + ctrl.selectedProperty().bindBidirectional(prop); + return ctrl; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private Node createComboBox(Setting setting) throws NoSuchFieldException, IllegalAccessException { + ListProperty listProp = (ListProperty) setting.getProperty(); + ComboBox comboBox = new ComboBox(listProp); + // Field field = Settings.class.getField(setting.getKey()); + // Object value = field.get(Config.getInstance().getSettings()); + // if (StringUtil.isNotBlank(value.toString())) { + // if (setting.getConverter() != null) { + // comboBox.getSelectionModel().select(setting.getConverter().convertTo(value)); + // } else { + // comboBox.getSelectionModel().select(value); + // } + // } + // comboBox.valueProperty().addListener((obs, oldV, newV) -> saveValue(() -> { + // if (setting.getConverter() != null) { + // field.set(settings, setting.getConverter().convertFrom(newV)); + // } else { + // field.set(settings, newV); + // } + // config.save(); + // })); + return comboBox; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/DeleteTooShortPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/DeleteTooShortPaneFactory.java new file mode 100644 index 00000000..3974ce58 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/DeleteTooShortPaneFactory.java @@ -0,0 +1,24 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.DeleteTooShort; +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class DeleteTooShortPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + SimpleStringProperty minimumLengthInSeconds = new SimpleStringProperty(null, DeleteTooShort.MIN_LEN_IN_SECS, pp.getConfig().getOrDefault(DeleteTooShort.MIN_LEN_IN_SECS, "10")); + properties.add(minimumLengthInSeconds); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("Minimum length in seconds", minimumLengthInSeconds) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/MoverPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/MoverPaneFactory.java new file mode 100644 index 00000000..c45bc1c9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/MoverPaneFactory.java @@ -0,0 +1,24 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.Move; +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class MoverPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + SimpleStringProperty pathTemplate = new SimpleStringProperty(null, Move.PATH_TEMPLATE, pp.getConfig().getOrDefault(Move.PATH_TEMPLATE, Move.DEFAULT)); + properties.add(pathTemplate); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("Directory", pathTemplate) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java b/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java new file mode 100644 index 00000000..c9657160 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java @@ -0,0 +1,77 @@ +package ctbrec.ui.settings; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import ctbrec.Config; +import ctbrec.recorder.postprocessing.DeleteTooShort; +import ctbrec.recorder.postprocessing.Move; +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.recorder.postprocessing.Remux; +import ctbrec.recorder.postprocessing.Rename; +import ctbrec.recorder.postprocessing.Script; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.settings.api.Preferences; +import javafx.collections.ObservableList; +import javafx.scene.Scene; +import javafx.scene.layout.Region; + +public class PostProcessingDialogFactory { + + static Map, Class> ppToDialogMap = new HashMap<>(); + static { + ppToDialogMap.put(Remux.class, RemuxerPaneFactory.class); + ppToDialogMap.put(Script.class, ScriptPaneFactory.class); + ppToDialogMap.put(Rename.class, RenamerPaneFactory.class); + ppToDialogMap.put(Move.class, MoverPaneFactory.class); + ppToDialogMap.put(DeleteTooShort.class, DeleteTooShortPaneFactory.class); + } + + private PostProcessingDialogFactory() { + } + + public static void openNewDialog(PostProcessor pp, Config config, Scene scene, ObservableList stepList) { + openDialog(pp, config, scene, stepList, true); + } + + public static void openEditDialog(PostProcessor pp, Config config, Scene scene, ObservableList stepList) { + openDialog(pp, config, scene, stepList, false); + } + + private static void openDialog(PostProcessor pp, Config config, Scene scene, ObservableList stepList, boolean newEntry) { + boolean ok; + try { + Optional preferences = createPreferences(pp); + if(preferences.isPresent()) { + Region view = preferences.get().getView(false); + view.setMinWidth(600); + ok = Dialogs.showCustomInput(scene, "Configure " + pp.getName(), view); + if (ok) { + preferences.get().save(); + if (newEntry) { + stepList.add(pp); + } + } + } else if (newEntry) { + stepList.add(pp); + } + } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException + | InstantiationException | IOException e) { + Dialogs.showError("New post-processing step", "Couldn't create dialog for " + pp.getName(), e); + } + } + + private static Optional createPreferences(PostProcessor pp) throws InstantiationException, IllegalAccessException, IllegalArgumentException, + InvocationTargetException, NoSuchMethodException, SecurityException { + Class paneFactoryClass = ppToDialogMap.get(pp.getClass()); + if (paneFactoryClass != null) { + AbstractPostProcessingPaneFactory factory = (AbstractPostProcessingPaneFactory) paneFactoryClass.getDeclaredConstructor().newInstance(); + return Optional.of(factory.createPostProcessorPane(pp)); + } else { + return Optional.empty(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java b/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java new file mode 100644 index 00000000..3a659888 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java @@ -0,0 +1,199 @@ +package ctbrec.ui.settings; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; + +import ctbrec.Config; +import ctbrec.recorder.postprocessing.Copy; +import ctbrec.recorder.postprocessing.DeleteOriginal; +import ctbrec.recorder.postprocessing.DeleteTooShort; +import ctbrec.recorder.postprocessing.Move; +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.recorder.postprocessing.RemoveKeepFile; +import ctbrec.recorder.postprocessing.Remux; +import ctbrec.recorder.postprocessing.Rename; +import ctbrec.recorder.postprocessing.Script; +import ctbrec.ui.controls.Dialogs; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.control.Button; +import javafx.scene.control.ChoiceDialog; +import javafx.scene.control.ListView; +import javafx.scene.control.Tooltip; +import javafx.scene.image.Image; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +public class PostProcessingStepPanel extends GridPane { + + private Config config; + + + private static final Class[] POST_PROCESSOR_CLASSES = new Class[] { // @formatter: off + Copy.class, + Rename.class, + Move.class, + Remux.class, + Script.class, + DeleteOriginal.class, + DeleteTooShort.class, + RemoveKeepFile.class + }; // @formatter: on + + ListView stepListView; + ObservableList stepList; + + Button up; + Button down; + + Button add; + Button remove; + Button edit; + + public PostProcessingStepPanel(Config config) { + this.config = config; + initGui(); + } + + private void initGui() { + setHgap(5); + vgapProperty().bind(hgapProperty()); + + up = createUpButton(); + down = createDownButton(); + add = createAddButton(); + remove = createRemoveButton(); + edit = createEditButton(); + VBox buttons = new VBox(5, add, edit, up, down, remove); + + stepList = FXCollections.observableList(config.getSettings().postProcessors); + stepList.addListener((ListChangeListener) change -> { + try { + config.save(); + } catch (IOException e) { + Dialogs.showError(getScene(), "Couldn't save configuration", "An error occurred while saving the configuration", e); + } + }); + stepListView = new ListView<>(stepList); + GridPane.setHgrow(stepListView, Priority.ALWAYS); + + add(stepListView, 0, 0); + add(buttons, 1, 0); + + stepListView.getSelectionModel().selectedIndexProperty().addListener((obs, oldV, newV) -> { + int idx = newV.intValue(); + boolean noSelection = idx == -1; + up.setDisable(noSelection || idx == 0); + down.setDisable(noSelection || idx == stepList.size() - 1); + edit.setDisable(noSelection); + remove.setDisable(noSelection); + }); + } + + private Button createUpButton() { + Button up = createButton("\u25B4", "Move step up"); + up.setOnAction(evt -> { + int idx = stepListView.getSelectionModel().getSelectedIndex(); + PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); + stepList.remove(idx); + stepList.add(idx - 1, selectedItem); + stepListView.getSelectionModel().select(idx - 1); + }); + return up; + } + + private Button createDownButton() { + Button down = createButton("\u25BE", "Move step down"); + down.setOnAction(evt -> { + int idx = stepListView.getSelectionModel().getSelectedIndex(); + PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); + stepList.remove(idx); + stepList.add(idx + 1, selectedItem); + stepListView.getSelectionModel().select(idx + 1); + }); + return down; + } + + private Button createAddButton() { + Button add = createButton("+", "Add a new step"); + add.setDisable(false); + add.setOnAction(evt -> { + PostProcessor[] options = createOptions(); + ChoiceDialog choice = new ChoiceDialog<>(options[0], options); + choice.setTitle("New Post-Processing Step"); + choice.setHeaderText("Select the new step type"); + choice.setResizable(true); + choice.setWidth(600); + choice.getDialogPane().setMinWidth(400); + Stage stage = (Stage) choice.getDialogPane().getScene().getWindow(); + stage.getScene().getStylesheets().addAll(getScene().getStylesheets()); + InputStream icon = Dialogs.class.getResourceAsStream("/icon.png"); + stage.getIcons().add(new Image(icon)); + + Optional result = choice.showAndWait(); + result.ifPresent(pp -> PostProcessingDialogFactory.openNewDialog(pp, config, getScene(), stepList)); + saveConfig(); + }); + return add; + } + + private void saveConfig() { + try { + config.save(); + } catch (IOException e) { + Dialogs.showError("Post-Processing", "Couldn't save post-processing step", e); + } + } + + private PostProcessor[] createOptions() { + try { + PostProcessor[] options = new PostProcessor[POST_PROCESSOR_CLASSES.length]; + for (int i = 0; i < POST_PROCESSOR_CLASSES.length; i++) { + Class cls = POST_PROCESSOR_CLASSES[i]; + PostProcessor pp; + pp = (PostProcessor) cls.getDeclaredConstructor().newInstance(); + options[i] = pp; + } + return options; + } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException + | SecurityException e) { + Dialogs.showError(getScene(), "Create post-processor selection", "Error while reaing in post-processing options", e); + return new PostProcessor[0]; + } + } + + private Button createRemoveButton() { + Button remove = createButton("-", "Remove selected step"); + remove.setOnAction(evt -> { + PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); + if (selectedItem != null) { + stepList.remove(selectedItem); + } + }); + return remove; + } + + private Button createEditButton() { + Button edit = createButton("\u270E", "Edit selected step"); + edit.setOnAction(evt -> { + PostProcessor selectedItem = stepListView.getSelectionModel().getSelectedItem(); + PostProcessingDialogFactory.openEditDialog(selectedItem, config, getScene(), stepList); + stepListView.refresh(); + saveConfig(); + }); + return edit; + } + + private Button createButton(String text, String tooltip) { + Button 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/settings/RemuxerPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/RemuxerPaneFactory.java new file mode 100644 index 00000000..64a9c0fb --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/RemuxerPaneFactory.java @@ -0,0 +1,27 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.recorder.postprocessing.Remux; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class RemuxerPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + SimpleStringProperty ffmpegParams = new SimpleStringProperty(null, Remux.FFMPEG_ARGS, pp.getConfig().getOrDefault(Remux.FFMPEG_ARGS, "-c:v copy -c:a copy -movflags faststart -y -f mp4")); + SimpleStringProperty fileExt = new SimpleStringProperty(null, Remux.FILE_EXT, pp.getConfig().getOrDefault(Remux.FILE_EXT, "mp4")); + properties.add(ffmpegParams); + properties.add(fileExt); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("FFmpeg parameters", ffmpegParams), + Setting.of("File extension", fileExt) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/RenamerPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/RenamerPaneFactory.java new file mode 100644 index 00000000..4c887403 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/RenamerPaneFactory.java @@ -0,0 +1,24 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.recorder.postprocessing.Rename; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class RenamerPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + SimpleStringProperty fileTemplate = new SimpleStringProperty(null, Rename.FILE_NAME_TEMPLATE, pp.getConfig().getOrDefault(Rename.FILE_NAME_TEMPLATE, Rename.DEFAULT)); + properties.add(fileTemplate); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("File name", fileTemplate) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/ScriptPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/ScriptPaneFactory.java new file mode 100644 index 00000000..1c56cc5b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/ScriptPaneFactory.java @@ -0,0 +1,27 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.recorder.postprocessing.Script; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class ScriptPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + SimpleStringProperty script = new SimpleStringProperty(null, Script.SCRIPT_EXECUTABLE, pp.getConfig().getOrDefault(Script.SCRIPT_EXECUTABLE, "c:\\users\\johndoe\\somescript")); + SimpleStringProperty params = new SimpleStringProperty(null, Script.SCRIPT_PARAMS, pp.getConfig().getOrDefault(Script.SCRIPT_PARAMS, "${absolutePath}")); + properties.add(script); + properties.add(params); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("Script", script), + Setting.of("Parameters", params) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java index f83a38f7..02cb7e2e 100644 --- a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java +++ b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java @@ -88,19 +88,18 @@ public class SettingsTab extends Tab implements TabSelectionListener { private SimpleIntegerProperty onlineCheckIntervalInSecs; private SimpleBooleanProperty onlineCheckSkipsPausedModels; private SimpleLongProperty leaveSpaceOnDevice; - private SimpleIntegerProperty minimumLengthInSecs; private SimpleStringProperty ffmpegParameters; private SimpleStringProperty fileExtension; private SimpleStringProperty server; private SimpleIntegerProperty port; private SimpleStringProperty path; + private SimpleStringProperty downloadFilename; private SimpleBooleanProperty requireAuthentication; private SimpleBooleanProperty transportLayerSecurity; private ExclusiveSelectionProperty recordLocal; - private SimpleFileProperty postProcessing; private SimpleIntegerProperty postProcessingThreads; - private SimpleBooleanProperty removeRecordingAfterPp; private IgnoreList ignoreList; + private PostProcessingStepPanel postProcessingStepPanel; public SettingsTab(List sites, Recorder recorder) { this.sites = sites; @@ -137,23 +136,22 @@ public class SettingsTab extends Tab implements TabSelectionListener { concurrentRecordings = new SimpleIntegerProperty(null, "concurrentRecordings", settings.concurrentRecordings); onlineCheckIntervalInSecs = new SimpleIntegerProperty(null, "onlineCheckIntervalInSecs", settings.onlineCheckIntervalInSecs); leaveSpaceOnDevice = new SimpleLongProperty(null, "minimumSpaceLeftInBytes", (long) new GigabytesConverter().convertTo(settings.minimumSpaceLeftInBytes)); - minimumLengthInSecs = new SimpleIntegerProperty(null, "minimumLengthInSeconds", settings.minimumLengthInSeconds); ffmpegParameters = new SimpleStringProperty(null, "ffmpegMergedDownloadArgs", settings.ffmpegMergedDownloadArgs); fileExtension = new SimpleStringProperty(null, "ffmpegFileSuffix", settings.ffmpegFileSuffix); server = new SimpleStringProperty(null, "httpServer", settings.httpServer); port = new SimpleIntegerProperty(null, "httpPort", settings.httpPort); path = new SimpleStringProperty(null, "servletContext", settings.servletContext); + downloadFilename = new SimpleStringProperty(null, "downloadFilename", settings.downloadFilename); requireAuthentication = new SimpleBooleanProperty(null, "requireAuthentication", settings.requireAuthentication); requireAuthentication.addListener(this::requireAuthenticationChanged); transportLayerSecurity = new SimpleBooleanProperty(null, "transportLayerSecurity", settings.transportLayerSecurity); recordLocal = new ExclusiveSelectionProperty(null, "localRecording", settings.localRecording, "Local", "Remote"); - postProcessing = new SimpleFileProperty(null, "postProcessing", settings.postProcessing); postProcessingThreads = new SimpleIntegerProperty(null, "postProcessingThreads", settings.postProcessingThreads); - removeRecordingAfterPp = new SimpleBooleanProperty(null, "removeRecordingAfterPostProcessing", settings.removeRecordingAfterPostProcessing); onlineCheckSkipsPausedModels = new SimpleBooleanProperty(null, "onlineCheckSkipsPausedModels", settings.onlineCheckSkipsPausedModels); } private void createGui() { + postProcessingStepPanel = new PostProcessingStepPanel(config); ignoreList = new IgnoreList(sites); List siteCategories = new ArrayList<>(); for (Site site : sites) { @@ -200,16 +198,15 @@ public class SettingsTab extends Tab implements TabSelectionListener { Setting.of("Server", server), Setting.of("Port", port), Setting.of("Path", path, "Leave empty, if you didn't change the servletContext in the server config"), + Setting.of("Download Filename", downloadFilename, "File name pattern for downloads"), Setting.of("Require authentication", requireAuthentication), Setting.of("Use Secure Communication (TLS)", transportLayerSecurity) ) ), Category.of("Post-Processing", Group.of("Post-Processing", - Setting.of("Post-Processing", postProcessing), Setting.of("Threads", postProcessingThreads), - Setting.of("Delete recordings shorter than (secs)", minimumLengthInSecs, "Delete recordings, which are shorter than x seconds. 0 to disable"), - Setting.of("Remove recording after post-processing", removeRecordingAfterPp) + Setting.of("Steps", postProcessingStepPanel) ) ), Category.of("Events & Actions", new ActionSettingsPanel(recorder)), @@ -246,6 +243,9 @@ public class SettingsTab extends Tab implements TabSelectionListener { prefs.getSetting("removeRecordingAfterPostProcessing").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); prefs.getSetting("minimumLengthInSeconds").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); prefs.getSetting("concurrentRecordings").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("concurrentRecordings").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("downloadFilename").ifPresent(s -> bindEnabledProperty(s, recordLocal)); + postProcessingStepPanel.disableProperty().bind(recordLocal.not()); } private void bindEnabledProperty(Setting s, BooleanExpression bindTo) { diff --git a/client/src/main/java/ctbrec/ui/settings/api/Preferences.java b/client/src/main/java/ctbrec/ui/settings/api/Preferences.java index 9b096406..d8c9bb7d 100644 --- a/client/src/main/java/ctbrec/ui/settings/api/Preferences.java +++ b/client/src/main/java/ctbrec/ui/settings/api/Preferences.java @@ -2,6 +2,7 @@ package ctbrec.ui.settings.api; import static java.util.Optional.*; +import java.io.IOException; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; @@ -21,6 +22,7 @@ import javafx.scene.control.TreeView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.GridPane; import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; import javafx.scene.layout.VBox; public class Preferences { @@ -31,7 +33,10 @@ public class Preferences { private TreeView categoryTree; + private PreferencesStorage preferencesStorage; + private Preferences(PreferencesStorage preferencesStorage, Category...categories) { + this.preferencesStorage = preferencesStorage; this.categories = categories; for (Category category : categories) { assignPreferencesStorage(category, preferencesStorage); @@ -56,15 +61,15 @@ public class Preferences { return new Preferences(preferencesStorage, categories); } - public void save() { - throw new RuntimeException("save not implemented"); + public void save() throws IOException { + preferencesStorage.save(this); } Category[] getCategories() { return categories; } - public Node getView() { + public Region getView(boolean withNavigation) { SearchBox search = new SearchBox(true); search.textProperty().addListener(this::filterTree); TreeItem categoryTreeItems = createCategoryTree(categories, new TreeItem<>(), null); @@ -76,7 +81,9 @@ public class Preferences { VBox.setMargin(categoryTree, new Insets(2)); BorderPane main = new BorderPane(); - main.setLeft(leftSide); + if (withNavigation) { + main.setLeft(leftSide); + } main.setCenter(new Label("Center")); BorderPane.setMargin(leftSide, new Insets(2)); @@ -92,6 +99,10 @@ public class Preferences { return main; } + public Region getView() { + return getView(true); + } + private void filterTree(ObservableValue obs, String oldV, String newV) { String q = ofNullable(newV).orElse("").toLowerCase().trim(); TreeItem filteredCategoryTree = createCategoryTree(categories, new TreeItem<>(), q); @@ -151,6 +162,8 @@ public class Preferences { private Node createGrid(Setting[] settings) throws Exception { GridPane pane = new GridPane(); + pane.setHgap(2); + pane.vgapProperty().bind(pane.hgapProperty()); int row = 0; for (Setting setting : settings) { Node node = setting.getGui(); @@ -198,11 +211,9 @@ public class Preferences { } private void visit(Category cat, Consumer visitor) { - if (cat.hasGroups()) { - for (Group group : cat.getGroups()) { - for (Setting setting : group.getSettings()) { - visitor.accept(setting); - } + for (Group group : cat.getGroups()) { + for (Setting setting : group.getSettings()) { + visitor.accept(setting); } } if (cat.hasSubCategories()) { diff --git a/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java b/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java new file mode 100644 index 00000000..3db557a0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java @@ -0,0 +1,21 @@ +package ctbrec.ui.tabs; + +import java.io.IOException; + +import ctbrec.Config; +import ctbrec.Recording; +import ctbrec.recorder.RecordingManager; +import ctbrec.recorder.postprocessing.AbstractPlaceholderAwarePostProcessor; + +public class DownloadPostprocessor extends AbstractPlaceholderAwarePostProcessor { + + @Override + public String getName() { + return "download renamer"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + // nothing really to do in here, we just inherit from AbstractPlaceholderAwarePostProcessor to use fillInPlaceHolders + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java index 2521d99b..6c4d6613 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java @@ -228,7 +228,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { @Override public String get() { - String modelNotes = Config.getInstance().getSettings().modelNotes.getOrDefault(m.getUrl(), ""); + String modelNotes = Config.getInstance().getModelNotes(m); return modelNotes; } }; diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java index 5fff704d..d905f303 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java @@ -559,9 +559,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private void onOpenDirectory(JavaFxRecording first) { - String recordingsDir = Config.getInstance().getSettings().recordingsDir; - String path = first.getPath(); - File tsFile = new File(recordingsDir, path); + File tsFile = first.getAbsoluteFile(); new Thread(() -> DesktopIntegration.open(tsFile.getParent())).start(); } @@ -579,19 +577,19 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private void download(Recording recording) { - LOG.debug("Path {}", recording.getPath()); + LOG.debug("Path {}", recording.getAbsoluteFile()); String filename = proposeTargetFilename(recording); FileChooser chooser = new FileChooser(); chooser.setInitialFileName(filename); - if(config.getSettings().lastDownloadDir != null && !config.getSettings().lastDownloadDir.equals("")) { + if (config.getSettings().lastDownloadDir != null && !config.getSettings().lastDownloadDir.equals("")) { File dir = new File(config.getSettings().lastDownloadDir); - while(!dir.exists()) { + while (!dir.exists()) { dir = dir.getParentFile(); } chooser.setInitialDirectory(dir); } File target = chooser.showSaveDialog(null); - if(target != null) { + if (target != null) { config.getSettings().lastDownloadDir = target.getParent(); startDownloadThread(target, recording); recording.setStatus(DOWNLOADING); @@ -600,12 +598,12 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private String proposeTargetFilename(Recording recording) { - String path = recording.getPath().substring(1); if(recording.isSingleFile()) { - return new File(path).getName(); + return recording.getAbsoluteFile().getName(); } else { + String downloadFilename = config.getSettings().downloadFilename; String fileSuffix = config.getSettings().ffmpegFileSuffix; - String filename = path.replace("/", "-").replace(".mp4", "") + '.' + fileSuffix; + String filename = new DownloadPostprocessor().fillInPlaceHolders(downloadFilename, recording, config) + '.' + fileSuffix; return filename; } } @@ -615,11 +613,11 @@ public class RecordingsTab extends Tab implements TabSelectionListener { try { String hlsBase = config.getServerUrl() + "/hls"; if (recording.isSingleFile()) { - URL url = new URL(hlsBase + recording.getPath()); + URL url = new URL(hlsBase + '/' + recording.getId()); FileDownload download = new FileDownload(CamrecApplication.httpClient, createDownloadListener(recording)); download.start(url, target); } else { - URL url = new URL(hlsBase + recording.getPath() + "/playlist.m3u8"); + URL url = new URL(hlsBase + '/' + recording.getId() + "/playlist.m3u8"); MergedFfmpegHlsDownload download = new MergedFfmpegHlsDownload(CamrecApplication.httpClient); download.init(config, recording.getModel(), Instant.now()); LOG.info("Downloading {}", url); @@ -641,7 +639,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } }); t.setDaemon(true); - t.setName("Download Thread " + recording.getPath()); + t.setName("Download Thread " + recording.getAbsoluteFile().toString()); t.start(); } @@ -650,7 +648,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { if (progress == 100) { recording.setStatus(FINISHED); recording.setProgress(-1); - LOG.debug("Download finished for recording {}", recording.getPath()); + LOG.debug("Download finished for recording {} - {}", recording.getId(), recording.getAbsoluteFile()); } else { recording.setStatus(DOWNLOADING); recording.setProgress(progress); diff --git a/common/pom.xml b/common/pom.xml index eaf358d7..10e5b090 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -8,7 +8,7 @@ ctbrec master - 3.9.0 + 3.10.0 ../master @@ -50,6 +50,10 @@ com.google.guava guava + + commons-io + commons-io + org.openjfx javafx-controls @@ -71,6 +75,11 @@ junit test + + org.mockito + mockito-inline + test + javax.xml.bind jaxb-api diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java index a7caec1e..fa08993c 100644 --- a/common/src/main/java/ctbrec/Config.java +++ b/common/src/main/java/ctbrec/Config.java @@ -23,7 +23,10 @@ import org.slf4j.LoggerFactory; import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; +import ctbrec.io.FileJsonAdapter; import ctbrec.io.ModelJsonAdapter; +import ctbrec.io.PostProcessorJsonAdapter; +import ctbrec.recorder.postprocessing.PostProcessor; import ctbrec.sites.Site; public class Config { @@ -55,6 +58,8 @@ public class Config { private void load() throws IOException { Moshi moshi = new Moshi.Builder() .add(Model.class, new ModelJsonAdapter(sites)) + .add(PostProcessor.class, new PostProcessorJsonAdapter()) + .add(File.class, new FileJsonAdapter()) .build(); JsonAdapter adapter = moshi.adapter(Settings.class).lenient(); File configFile = new File(configDir, filename); @@ -125,6 +130,8 @@ public class Config { public void save() throws IOException { Moshi moshi = new Moshi.Builder() .add(Model.class, new ModelJsonAdapter()) + .add(PostProcessor.class, new PostProcessorJsonAdapter()) + .add(File.class, new FileJsonAdapter()) .build(); JsonAdapter adapter = moshi.adapter(Settings.class).indent(" "); String json = adapter.toJson(settings); @@ -186,4 +193,8 @@ public class Config { } return context; } + + public String getModelNotes(Model m) { + return Config.getInstance().getSettings().modelNotes.getOrDefault(m.getUrl(), ""); + } } diff --git a/common/src/main/java/ctbrec/NotImplementedExcetion.java b/common/src/main/java/ctbrec/NotImplementedExcetion.java index 4fb3d3c9..9ecfda88 100644 --- a/common/src/main/java/ctbrec/NotImplementedExcetion.java +++ b/common/src/main/java/ctbrec/NotImplementedExcetion.java @@ -3,7 +3,7 @@ package ctbrec; public class NotImplementedExcetion extends RuntimeException { public NotImplementedExcetion() { - super(); + super("Not implemented"); } public NotImplementedExcetion(String mesg) { diff --git a/common/src/main/java/ctbrec/Recording.java b/common/src/main/java/ctbrec/Recording.java index e6f60bf8..b95a10a3 100644 --- a/common/src/main/java/ctbrec/Recording.java +++ b/common/src/main/java/ctbrec/Recording.java @@ -17,6 +17,8 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,6 +30,7 @@ import ctbrec.recorder.download.Download; public class Recording implements Serializable { private static final transient Logger LOG = LoggerFactory.getLogger(Recording.class); + private String id; private Model model; private transient Download download; private Instant startDate; @@ -39,6 +42,9 @@ public class Recording implements Serializable { private boolean singleFile = false; private boolean pinned = false; private String note; + private Set associatedFiles = new HashSet<>(); + private File absoluteFile = null; + private File postProcessedFile = null; public enum State { RECORDING("recording"), @@ -64,6 +70,14 @@ public class Recording implements Serializable { } } + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + public Instant getStartDate() { return startDate; } @@ -93,18 +107,38 @@ public class Recording implements Serializable { this.progress = progress; } - public String getPath() { - return path; - } + // public String getPath() { + // return path; + // } public void setPath(String path) { this.path = path; } public File getAbsoluteFile() { - String recordingsDir = Config.getInstance().getSettings().recordingsDir; - File recordingsFile = new File(recordingsDir, getPath()); - return recordingsFile; + if (absoluteFile == null) { + String recordingsDir = Config.getInstance().getSettings().recordingsDir; + File recordingsFile = new File(recordingsDir, path); + absoluteFile = recordingsFile; + return absoluteFile; + } else { + return absoluteFile; + } + } + + public void setAbsoluteFile(File absoluteFile) { + this.absoluteFile = absoluteFile; + } + + public File getPostProcessedFile() { + if (postProcessedFile == null) { + setPostProcessedFile(getAbsoluteFile()); + } + return postProcessedFile; + } + + public void setPostProcessedFile(File postProcessedFile) { + this.postProcessedFile = postProcessedFile; } public long getSizeInByte() { @@ -186,7 +220,7 @@ public class Recording implements Serializable { int result = 1; result = prime * result + ((getStartDate() == null) ? 0 : (int) (getStartDate().toEpochMilli() ^ (getStartDate().toEpochMilli() >>> 32))); result = prime * result + ((model == null) ? 0 : model.hashCode()); - result = prime * result + ((path == null) ? 0 : path.hashCode()); + result = prime * result + ((getAbsoluteFile() == null) ? 0 : getAbsoluteFile().hashCode()); return result; } @@ -207,11 +241,7 @@ public class Recording implements Serializable { } else if (!getModel().equals(other.getModel())) { return false; } - if (getPath() == null) { - if (other.getPath() != null) { - return false; - } - } else if (!getPath().equals(other.getPath())) { + if (!getAbsoluteFile().equals(other.getAbsoluteFile())) { return false; } if (getStartDate() == null) { @@ -232,7 +262,7 @@ public class Recording implements Serializable { } private long getSize() { - File rec = new File(Config.getInstance().getSettings().recordingsDir, getPath()); + File rec = getAbsoluteFile(); if (rec.isDirectory()) { return getDirectorySize(rec); } else { @@ -278,4 +308,8 @@ public class Recording implements Serializable { public boolean canBePostProcessed() { return getStatus() == FAILED || getStatus() == WAITING || getStatus() == FINISHED; } + + public Set getAssociatedFiles() { + return associatedFiles; + } } diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index e6c7e5fc..2fc2f41c 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import ctbrec.event.EventHandlerConfiguration; +import ctbrec.recorder.postprocessing.PostProcessor; public class Settings { @@ -47,6 +48,7 @@ public class Settings { public int concurrentRecordings = 0; public boolean determineResolution = false; public List disabledSites = new ArrayList<>(); + public String downloadFilename = "${modelSanitizedName}-${localDateTime}"; public List eventHandlers = new ArrayList<>(); public String fc2livePassword = ""; public String fc2liveUsername = ""; @@ -83,7 +85,6 @@ public class Settings { public String mfcModelsTableSortType = ""; public String mfcPassword = ""; public String mfcUsername = ""; - public int minimumLengthInSeconds = 0; public long minimumSpaceLeftInBytes = 0; public Map modelNotes = new HashMap<>(); public List models = new ArrayList<>(); @@ -92,8 +93,8 @@ public class Settings { public boolean onlineCheckSkipsPausedModels = false; public int overviewUpdateIntervalInSecs = 10; public String password = ""; // chaturbate password TODO maybe rename this onetime - public String postProcessing = ""; public int postProcessingThreads = 2; + public List postProcessors = new ArrayList<>(); public String proxyHost; public String proxyPassword; public String proxyPort; @@ -110,7 +111,6 @@ public class Settings { public String recordingsSortColumn = ""; public String recordingsSortType = ""; public boolean recordSingleFile = false; - public boolean removeRecordingAfterPostProcessing = false; public boolean requireAuthentication = false; public String servletContext = ""; public boolean showPlayerStarting = false; diff --git a/common/src/main/java/ctbrec/StringUtil.java b/common/src/main/java/ctbrec/StringUtil.java index 589e296b..236fcca4 100644 --- a/common/src/main/java/ctbrec/StringUtil.java +++ b/common/src/main/java/ctbrec/StringUtil.java @@ -60,4 +60,14 @@ public class StringUtil { } return hex; } + + // @formatter:off + public static String sanitize(String input) { + return input + .replace(' ', '_') + .replace('\\', '_') + .replace('/', '_') + .replace('\'', '_') + .replace('"', '_'); + } // @formatter:on } diff --git a/common/src/main/java/ctbrec/io/DevNull.java b/common/src/main/java/ctbrec/io/DevNull.java new file mode 100644 index 00000000..b40772b9 --- /dev/null +++ b/common/src/main/java/ctbrec/io/DevNull.java @@ -0,0 +1,18 @@ +package ctbrec.io; + +import java.io.IOException; +import java.io.OutputStream; + +public class DevNull extends OutputStream { + @Override + public void write(int b) throws IOException { + } + + @Override + public void write(byte[] b) throws IOException { + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + } +} diff --git a/common/src/main/java/ctbrec/io/FileJsonAdapter.java b/common/src/main/java/ctbrec/io/FileJsonAdapter.java new file mode 100644 index 00000000..e3d38733 --- /dev/null +++ b/common/src/main/java/ctbrec/io/FileJsonAdapter.java @@ -0,0 +1,30 @@ +package ctbrec.io; + +import java.io.File; +import java.io.IOException; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; + +public class FileJsonAdapter extends JsonAdapter { + + @Override + public File fromJson(JsonReader reader) throws IOException { + String path = reader.nextString(); + if (path != null) { + return new File(path); + } else { + return null; + } + } + + @Override + public void toJson(JsonWriter writer, File value) throws IOException { + if (value != null) { + writer.value(value.getCanonicalPath()); + } else { + writer.nullValue(); + } + } +} diff --git a/common/src/main/java/ctbrec/io/IoUtils.java b/common/src/main/java/ctbrec/io/IoUtils.java new file mode 100644 index 00000000..644e540b --- /dev/null +++ b/common/src/main/java/ctbrec/io/IoUtils.java @@ -0,0 +1,53 @@ +package ctbrec.io; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; + +public class IoUtils { + + private static final Logger LOG = LoggerFactory.getLogger(IoUtils.class); + + private IoUtils() {} + + public static void deleteEmptyParents(File parent) throws IOException { + File recDir = new File(Config.getInstance().getSettings().recordingsDir); + while (parent != null && (parent.list() != null && parent.list().length == 0 || !parent.exists()) ) { + if (parent.equals(recDir)) { + return; + } + if(parent.exists()) { + LOG.debug("Deleting empty directory {}", parent.getAbsolutePath()); + Files.delete(parent.toPath()); + } + parent = parent.getParentFile(); + } + } + + public static void deleteDirectory(File directory) throws IOException { + if (!directory.exists()) { + return; + } + + File[] files = directory.listFiles(); + boolean deletedAllFiles = true; + for (File file : files) { + try { + LOG.trace("Deleting {}", file.getAbsolutePath()); + Files.delete(file.toPath()); + } catch (Exception e) { + deletedAllFiles = false; + LOG.debug("Couldn't delete {}", file, e); + } + } + + if (!deletedAllFiles) { + throw new IOException("Couldn't delete all files in " + directory); + } + } +} diff --git a/common/src/main/java/ctbrec/io/PostProcessorJsonAdapter.java b/common/src/main/java/ctbrec/io/PostProcessorJsonAdapter.java new file mode 100644 index 00000000..d5e1f937 --- /dev/null +++ b/common/src/main/java/ctbrec/io/PostProcessorJsonAdapter.java @@ -0,0 +1,62 @@ +package ctbrec.io; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.util.Map.Entry; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonReader.Token; +import com.squareup.moshi.JsonWriter; + +import ctbrec.recorder.postprocessing.PostProcessor; + +public class PostProcessorJsonAdapter extends JsonAdapter { + + @Override + public PostProcessor fromJson(JsonReader reader) throws IOException { + reader.beginObject(); + Object type = null; + PostProcessor postProcessor = null; + while(reader.hasNext()) { + try { + Token token = reader.peek(); + if(token == Token.NAME) { + String key = reader.nextName(); + if(key.equals("type")) { + type = reader.readJsonValue(); + Class modelClass = Class.forName(type.toString()); + postProcessor = (PostProcessor) modelClass.getDeclaredConstructor().newInstance(); + } else if(key.equals("config")) { + reader.beginObject(); + } else { + String value = reader.nextString(); + postProcessor.getConfig().put(key, value); + } + } else { + reader.skipValue(); + } + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) { + throw new IOException("Couldn't instantiate post-processor class [" + type + "]", e); + } + } + reader.endObject(); + reader.endObject(); + + return postProcessor; + } + + @Override + public void toJson(JsonWriter writer, PostProcessor pp) throws IOException { + writer.beginObject(); + writer.name("type").value(pp.getClass().getName()); + writer.name("config"); + writer.beginObject(); + for (Entry entry : pp.getConfig().entrySet()) { + writer.name(entry.getKey()).value(entry.getValue()); + } + writer.endObject(); + writer.endObject(); + } + +} diff --git a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java index f337c7c6..136e9e16 100644 --- a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java @@ -9,7 +9,6 @@ import java.nio.file.FileStore; import java.nio.file.Files; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.util.ArrayList; @@ -53,6 +52,7 @@ import ctbrec.event.NoSpaceLeftEvent; import ctbrec.event.RecordingStateChangedEvent; import ctbrec.io.HttpClient; import ctbrec.recorder.download.Download; +import ctbrec.recorder.postprocessing.PostProcessor; import ctbrec.sites.Site; public class NextGenLocalRecorder implements Recorder { @@ -161,13 +161,14 @@ public class NextGenLocalRecorder implements Recorder { setRecordingStatus(recording, State.POST_PROCESSING); recordingManager.saveRecording(recording); recording.postprocess(); + List postProcessors = config.getSettings().postProcessors; + for (PostProcessor postProcessor : postProcessors) { + LOG.debug("Running post-processor: {}", postProcessor.getName()); + postProcessor.postprocess(recording, recordingManager, config); + } setRecordingStatus(recording, State.FINISHED); recordingManager.saveRecording(recording); - deleteIfTooShort(recording); LOG.info("Post-processing finished for {}", recording.getModel().getName()); - if (config.getSettings().removeRecordingAfterPostProcessing) { - recordingManager.remove(recording); - } } catch (Exception e) { if (e instanceof InterruptedException) { // NOSONAR Thread.currentThread().interrupt(); @@ -276,8 +277,11 @@ public class NextGenLocalRecorder implements Recorder { private Recording createRecording(Download download) throws IOException { Model model = download.getModel(); Recording rec = new Recording(); + rec.setId(UUID.randomUUID().toString()); rec.setDownload(download); - rec.setPath(download.getPath(model).replaceAll("\\\\", "/")); + String recordingFile = download.getPath(model).replaceAll("\\\\", "/"); + File absoluteFile = new File(config.getSettings().recordingsDir, recordingFile); + rec.setAbsoluteFile(absoluteFile); rec.setModel(model); rec.setStartDate(download.getStartTime()); rec.setSingleFile(download.isSingleFile()); @@ -298,22 +302,6 @@ public class NextGenLocalRecorder implements Recorder { } } - private boolean deleteIfTooShort(Recording rec) throws IOException, InvalidKeyException, NoSuchAlgorithmException { - Duration minimumLengthInSeconds = Duration.ofSeconds(Config.getInstance().getSettings().minimumLengthInSeconds); - if (minimumLengthInSeconds.getSeconds() <= 0) { - return false; - } - - Duration recordingLength = rec.getLength(); - if (recordingLength.compareTo(minimumLengthInSeconds) < 0) { - LOG.info("Deleting too short recording {} [{} < {}]", rec, recordingLength, minimumLengthInSeconds); - delete(rec); - return true; - } - - return false; - } - @Override public void stopRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { recorderLock.lock(); @@ -636,12 +624,14 @@ public class NextGenLocalRecorder implements Recorder { @Override public void rerunPostProcessing(Recording recording) { + recording.setPostProcessedFile(null); List recordings = recordingManager.getAll(); for (Recording other : recordings) { if(other.equals(recording)) { Download download = other.getModel().createDownload(); download.init(Config.getInstance(), other.getModel(), other.getStartDate()); other.setDownload(download); + other.setPostProcessedFile(null); submitPostProcessingJob(other); return; } diff --git a/common/src/main/java/ctbrec/recorder/OnlineMonitor.java b/common/src/main/java/ctbrec/recorder/OnlineMonitor.java index d3f10744..166fdf32 100644 --- a/common/src/main/java/ctbrec/recorder/OnlineMonitor.java +++ b/common/src/main/java/ctbrec/recorder/OnlineMonitor.java @@ -139,7 +139,7 @@ public class OnlineMonitor extends Thread { } private void suspendUntilNextIteration(List models, Duration timeCheckTook) { - LOG.trace("Online check for {} models took {} seconds", models.size(), timeCheckTook.getSeconds()); + LOG.debug("Online check for {} models took {} seconds", models.size(), timeCheckTook.getSeconds()); long sleepTime = config.getSettings().onlineCheckIntervalInSecs; if(timeCheckTook.getSeconds() < sleepTime) { try { diff --git a/common/src/main/java/ctbrec/recorder/RecordingFileMonitor.java b/common/src/main/java/ctbrec/recorder/RecordingFileMonitor.java deleted file mode 100644 index 0a5fe253..00000000 --- a/common/src/main/java/ctbrec/recorder/RecordingFileMonitor.java +++ /dev/null @@ -1,236 +0,0 @@ -package ctbrec.recorder; - -import static java.nio.file.StandardWatchEventKinds.*; - -import java.io.File; -import java.io.IOException; -import java.nio.file.ClosedWatchServiceException; -import java.nio.file.FileSystems; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.LinkOption; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.WatchEvent; -import java.nio.file.WatchKey; -import java.nio.file.WatchService; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.regex.Pattern; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import ctbrec.Config; -import ctbrec.Recording; - -public class RecordingFileMonitor { - - private static final transient Logger LOG = LoggerFactory.getLogger(RecordingFileMonitor.class); - private WatchService watcher; - private Map keys; - private boolean running = true; - private RecordingManager manager; - - public RecordingFileMonitor(RecordingManager manager) throws IOException { - this.manager = manager; - this.watcher = FileSystems.getDefault().newWatchService(); - this.keys = new HashMap<>(); - registerAll(new File(Config.getInstance().getSettings().recordingsDir).toPath()); - } - - void processEvents() { - while (running) { - // wait for key to be signalled - WatchKey key; - try { - key = watcher.take(); - } catch (InterruptedException | ClosedWatchServiceException x) { - return; - } - - Path dir = keys.get(key); - if (dir == null) { - LOG.error("WatchKey not recognized!!"); - continue; - } - - List> events = key.pollEvents(); - LOG.debug("Size: {}", events.size()); - if (isRenameProcess(events)) { - handleRename(dir, events); - } else { - for (WatchEvent event : events) { - WatchEvent.Kind kind = event.kind(); - - // TBD - provide example of how OVERFLOW event is handled - if (kind == OVERFLOW) { - continue; - } - - // Context for directory entry event is the file name of entry - WatchEvent ev = cast(event); - Path name = ev.context(); - Path child = dir.resolve(name); - - if(Files.isRegularFile(child)) { - if (kind == ENTRY_CREATE) { - handleFileCreation(child); - } else if (kind == ENTRY_DELETE) { - handleFileDeletion(child); - } - } else { - if (kind == ENTRY_CREATE) { - handleDirCreation(child); - } else if (kind == ENTRY_DELETE) { - handleDirDeletion(child); - } - } - } - } - - // reset key and remove from set if directory no longer accessible - boolean valid = key.reset(); - if (!valid) { - keys.remove(key); - - // all directories are inaccessible - if (keys.isEmpty()) { - break; - } - } - } - } - - private void handleRename(Path dir, List> events) { - WatchEvent deleteEvent = cast(events.get(0)); - WatchEvent createEvent = cast(events.get(1)); - Path from = dir.resolve(deleteEvent.context()); - Path to = dir.resolve(createEvent.context()); - LOG.debug("{} -> {}", from, to); - List affectedRecordings = getAffectedRecordings(from); - adjustPaths(affectedRecordings, from, to); - if (Files.isDirectory(to, LinkOption.NOFOLLOW_LINKS)) { - unregister(from); - try { - registerAll(to); - } catch (IOException e) { - e.printStackTrace(); - } - } - } - - private List getAffectedRecordings(Path from) { - String f = from.toAbsolutePath().toString(); - List affected = new ArrayList<>(); - for (Recording rec : manager.getAll()) { - String r = rec.getAbsoluteFile().getAbsolutePath(); - if (r.startsWith(f)) { - affected.add(rec); - } - } - return affected; - } - - private void adjustPaths(List affectedRecordings, Path from, Path to) { - for (Recording rec : affectedRecordings) { - String oldPath = rec.getAbsoluteFile().getAbsolutePath(); - String newPath = oldPath.replace(from.toString(), to.toString()); - String recordingsDir = Config.getInstance().getSettings().recordingsDir; - String relativePath = newPath.replaceFirst(Pattern.quote(recordingsDir), ""); - LOG.debug("Recording path has changed {} -> {}", rec.getPath(), relativePath); - rec.setPath(relativePath); - try { - manager.saveRecording(rec); - } catch (IOException e) { - LOG.error("Couldn't update recording path in meta data file", e); - } - } - } - - private void handleFileCreation(Path child) { - LOG.trace("File created {}", child); - } - - private void handleFileDeletion(Path child) { - LOG.trace("File deleted {}", child); - } - - private void handleDirCreation(Path dir) { - try { - registerAll(dir); - LOG.trace("Directory added {}", dir); - } catch (IOException x) { - // ignore to keep sample readbale - } - } - - private void handleDirDeletion(Path dir) { - // TODO unregister key ?!? - - // only delete directories, which have actually been deleted - if(Files.notExists(dir, LinkOption.NOFOLLOW_LINKS)) { - LOG.trace("Directory Deleted {}", dir); - } - } - - private boolean isRenameProcess(List> events) { - if(events.size() == 2) { - boolean deleteFirst = events.get(0).kind() == ENTRY_DELETE; - boolean createSecond = events.get(1).kind() == ENTRY_CREATE; - return deleteFirst && createSecond; - } else { - return false; - } - } - - /** - * Register the given directory, and all its sub-directories, with the - * WatchService. - */ - private void registerAll(final Path start) throws IOException { - // register directory and sub-directories - Files.walkFileTree(start, new SimpleFileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { - register(dir); - return FileVisitResult.CONTINUE; - } - }); - } - - /** - * Register the given directory with the WatchService - */ - void register(Path dir) { - try { - WatchKey key = dir.register(watcher, ENTRY_CREATE, ENTRY_DELETE); - keys.put(key, dir); - LOG.debug("Monitor {}", dir); - } catch(IOException e) { - LOG.warn("Couldn't register directory monitor for directory {}", dir, e); - } - } - - public void unregister(Path path) { - - } - - @SuppressWarnings("unchecked") - static WatchEvent cast(WatchEvent event) { - return (WatchEvent) event; - } - - public void addDirectory(Path dir) throws IOException { - LOG.info("Adding monitor for {}", dir); - registerAll(dir); - } - - public void stop() throws IOException { - running = false; - watcher.close(); - } -} diff --git a/common/src/main/java/ctbrec/recorder/RecordingManager.java b/common/src/main/java/ctbrec/recorder/RecordingManager.java index a710344d..cc7dda57 100644 --- a/common/src/main/java/ctbrec/recorder/RecordingManager.java +++ b/common/src/main/java/ctbrec/recorder/RecordingManager.java @@ -1,6 +1,7 @@ package ctbrec.recorder; import static ctbrec.Recording.State.*; +import static ctbrec.io.IoUtils.*; import static java.nio.charset.StandardCharsets.*; import static java.nio.file.StandardOpenOption.*; @@ -13,6 +14,7 @@ import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; @@ -25,6 +27,7 @@ import ctbrec.Config; import ctbrec.Model; import ctbrec.Recording; import ctbrec.Recording.State; +import ctbrec.io.FileJsonAdapter; import ctbrec.io.InstantJsonAdapter; import ctbrec.io.ModelJsonAdapter; import ctbrec.sites.Site; @@ -43,6 +46,7 @@ public class RecordingManager { moshi = new Moshi.Builder() .add(Model.class, new ModelJsonAdapter(sites)) .add(Instant.class, new InstantJsonAdapter()) + .add(File.class, new FileJsonAdapter()) .build(); adapter = moshi.adapter(Recording.class).indent(" "); @@ -50,6 +54,10 @@ public class RecordingManager { } public void add(Recording rec) throws IOException { + File recordingsMetaDir = getDir(); + String filename = rec.toString() + ".json"; + File recordingMetaData = new File(recordingsMetaDir, filename); + rec.setMetaDataFile(recordingMetaData.getCanonicalPath()); saveRecording(rec); recordingsLock.lock(); try { @@ -60,13 +68,13 @@ public class RecordingManager { } public void saveRecording(Recording rec) throws IOException { - String json = adapter.toJson(rec); - File recordingsMetaDir = getDir(); - String filename = rec.toString() + ".json"; - File recordingMetaData = new File(recordingsMetaDir, filename); - rec.setMetaDataFile(recordingMetaData.getAbsolutePath()); - Files.createDirectories(recordingsMetaDir.toPath()); - Files.write(recordingMetaData.toPath(), json.getBytes(UTF_8), CREATE, WRITE, TRUNCATE_EXISTING); + if (rec.getMetaDataFile() != null) { + File recordingMetaData = new File(rec.getMetaDataFile()); + String json = adapter.toJson(rec); + rec.setMetaDataFile(recordingMetaData.getAbsolutePath()); + Files.createDirectories(recordingMetaData.getParentFile().toPath()); + Files.write(recordingMetaData.toPath(), json.getBytes(UTF_8), CREATE, WRITE, TRUNCATE_EXISTING); + } } private void loadRecordings() throws IOException { @@ -80,6 +88,10 @@ public class RecordingManager { if (recording.getStatus() == RECORDING || recording.getStatus() == GENERATING_PLAYLIST || recording.getStatus() == POST_PROCESSING) { recording.setStatus(WAITING); } + if (recording.getId() == null) { + recording.setId(UUID.randomUUID().toString()); + saveRecording(recording); + } if (recordingExists(recording)) { recordings.add(recording); } else { @@ -94,8 +106,7 @@ public class RecordingManager { } private boolean recordingExists(Recording recording) { - File rec = new File(config.getSettings().recordingsDir, recording.getPath()); - return rec.exists(); + return recording.getAbsoluteFile().exists(); } private File getDir() { @@ -120,22 +131,39 @@ public class RecordingManager { recording = recordings.get(idx); recording.setStatus(State.DELETING); - File recordingsDir = new File(config.getSettings().recordingsDir); - File path = new File(recordingsDir, recording.getPath()); + File path = recording.getAbsoluteFile(); + boolean isFile = path.isFile(); LOG.debug("Deleting {}", path); // delete the video files - if (path.isFile()) { + if (isFile) { Files.delete(path.toPath()); - deleteEmptyParents(path.getParentFile()); } else { deleteDirectory(path); - deleteEmptyParents(path); + } + + // delete files associated with this recording + for (String associated : recording.getAssociatedFiles()) { + File f = new File(associated); + if (f.isFile()) { + Files.delete(f.toPath()); + deleteEmptyParents(f.getParentFile()); + } else { + deleteDirectory(f); + deleteEmptyParents(f); + } } // delete the meta data Files.deleteIfExists(new File(recording.getMetaDataFile()).toPath()); + // delete empty parent files + if (isFile) { + deleteEmptyParents(path.getParentFile()); + } else { + deleteEmptyParents(path); + } + // remove from data structure recordings.remove(recording); recording.setStatus(State.DELETED); @@ -154,8 +182,7 @@ public class RecordingManager { try { int idx = recordings.indexOf(recording); recording = recordings.get(idx); - File recordingsDir = new File(config.getSettings().recordingsDir); - File path = new File(recordingsDir, recording.getPath()); + File path = recording.getAbsoluteFile(); deleteEmptyParents(path.getParentFile()); // delete the meta data Files.deleteIfExists(new File(recording.getMetaDataFile()).toPath()); @@ -182,40 +209,6 @@ public class RecordingManager { } } - public static void deleteEmptyParents(File parent) throws IOException { - File recDir = new File(Config.getInstance().getSettings().recordingsDir); - while (parent != null && parent.list() != null && parent.list().length == 0) { - if (parent.equals(recDir)) { - return; - } - LOG.debug("Deleting empty directory {}", parent.getAbsolutePath()); - Files.delete(parent.toPath()); - parent = parent.getParentFile(); - } - } - - private void deleteDirectory(File directory) throws IOException { - if (!directory.exists()) { - return; - } - - File[] files = directory.listFiles(); - boolean deletedAllFiles = true; - for (File file : files) { - try { - LOG.trace("Deleting {}", file.getAbsolutePath()); - Files.delete(file.toPath()); - } catch (Exception e) { - deletedAllFiles = false; - LOG.debug("Couldn't delete {}", file, e); - } - } - - if (!deletedAllFiles) { - throw new IOException("Couldn't delete all files in " + directory); - } - } - public void pin(Recording recording) throws IOException { recordingsLock.lock(); try { diff --git a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java index 5cec3296..4b473772 100644 --- a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java +++ b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java @@ -27,6 +27,7 @@ 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; @@ -45,7 +46,11 @@ public class RemoteRecorder implements Recorder { private static final Logger LOG = LoggerFactory.getLogger(RemoteRecorder.class); public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); - private Moshi moshi = new Moshi.Builder().add(Instant.class, new InstantJsonAdapter()).add(Model.class, new ModelJsonAdapter()).build(); + private Moshi moshi = new Moshi.Builder() + .add(Instant.class, new InstantJsonAdapter()) + .add(Model.class, new ModelJsonAdapter()) + .add(File.class, new FileJsonAdapter()) + .build(); private JsonAdapter modelListResponseAdapter = moshi.adapter(ModelListResponse.class); private JsonAdapter recordingListResponseAdapter = moshi.adapter(RecordingListResponse.class); private JsonAdapter modelRequestAdapter = moshi.adapter(ModelRequest.class); @@ -325,7 +330,7 @@ public class RemoteRecorder implements Recorder { int idx = newRecordings.indexOf(recording); Recording newRecording = newRecordings.get(idx); if (newRecording.getStatus() != recording.getStatus()) { - File file = new File(recording.getPath()); + File file = recording.getAbsoluteFile(); RecordingStateChangedEvent evt = new RecordingStateChangedEvent(file, newRecording.getStatus(), recording.getModel(), recording.getStartDate()); EventBusHolder.BUS.post(evt); @@ -337,7 +342,7 @@ public class RemoteRecorder implements Recorder { justStarted.removeAll(recordings); for (Recording recording : justStarted) { if (recording.getStatus() == Recording.State.RECORDING) { - File file = new File(recording.getPath()); + File file = recording.getAbsoluteFile(); RecordingStateChangedEvent evt = new RecordingStateChangedEvent(file, recording.getStatus(), recording.getModel(), recording.getStartDate()); EventBusHolder.BUS.post(evt); @@ -345,6 +350,19 @@ public class RemoteRecorder implements Recorder { } recordings = newRecordings; + + // assign a site to the model + for (Site site : sites) { + for (Recording recording : recordings) { + Model m = recording.getModel(); + if (m.getSite() == null) { + if (site.isSiteForModel(m)) { + m.setSite(site); + continue; + } + } + } + } } else { LOG.error(SERVER_RETURNED_ERROR, resp.status, resp.msg); } diff --git a/common/src/main/java/ctbrec/recorder/download/AbstractDownload.java b/common/src/main/java/ctbrec/recorder/download/AbstractDownload.java index 3e522da3..c1f1a939 100644 --- a/common/src/main/java/ctbrec/recorder/download/AbstractDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/AbstractDownload.java @@ -1,59 +1,11 @@ package ctbrec.recorder.download; -import java.io.File; -import java.io.IOException; import java.time.Instant; -import java.util.Arrays; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import ctbrec.Config; -import ctbrec.OS; -import ctbrec.Recording; -import ctbrec.io.StreamRedirectThread; public abstract class AbstractDownload implements Download { - private static final Logger LOG = LoggerFactory.getLogger(AbstractDownload.class); - protected Instant startTime; - protected void runPostProcessingScript(Recording recording) throws IOException, InterruptedException { - String postProcessing = Config.getInstance().getSettings().postProcessing; - if (postProcessing != null && !postProcessing.isEmpty()) { - File target = recording.getAbsoluteFile(); - Runtime rt = Runtime.getRuntime(); - String[] args = new String[] { - postProcessing, - target.getParentFile().getAbsolutePath(), - target.getAbsolutePath(), - getModel().getName(), - getModel().getSite().getName(), - Long.toString(recording.getStartDate().getEpochSecond()) - }; - if(LOG.isDebugEnabled()) { - LOG.debug("Running {}", Arrays.toString(args)); - } - Process process = rt.exec(args, OS.getEnvironment()); - // TODO maybe write these to a separate log file, e.g. recname.ts.pp.log - Thread std = new Thread(new StreamRedirectThread(process.getInputStream(), System.out)); - std.setName("Process stdout pipe"); - std.setDaemon(true); - std.start(); - Thread err = new Thread(new StreamRedirectThread(process.getErrorStream(), System.err)); - err.setName("Process stderr pipe"); - err.setDaemon(true); - err.start(); - - int exitCode = process.waitFor(); - LOG.debug("Process finished with exit code {}", exitCode); - if (exitCode != 0) { - throw new ProcessExitedUncleanException("Post-Processing finished with exit code " + exitCode); - } - } - } - @Override public Instant getStartTime() { return startTime; diff --git a/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java b/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java index a5c21743..e3f1a170 100644 --- a/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java @@ -391,13 +391,13 @@ public class DashDownload extends AbstractDownload { try { Thread.currentThread().setName("PP " + model.getName()); recording.setStatus(POST_PROCESSING); - String path = recording.getPath(); + // FIXME this was recording.getPath() before and is currently not working. This has to be fixed once DASH is used for a download again + String path = recording.getAbsoluteFile().getAbsolutePath(); File dir = new File(Config.getInstance().getSettings().recordingsDir, path); File file = new File(dir.getParentFile(), dir.getName().substring(0, dir.getName().length() - 5)); new FfmpegMuxer(dir, file); targetFile = file; recording.setPath(path.substring(0, path.length() - 5)); - runPostProcessingScript(recording); } catch (Exception e) { throw new PostProcessingException(e); } diff --git a/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java index 2e25a9f9..52ed297c 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java @@ -125,12 +125,6 @@ public class FFmpegDownload extends AbstractHlsDownload { @Override public void postprocess(Recording recording) { - Thread.currentThread().setName("PP " + model.getName()); - try { - runPostProcessingScript(recording); - } catch (Exception e) { - throw new PostProcessingException(e); - } } @Override diff --git a/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java index b7f8435e..1d2f034b 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java @@ -215,7 +215,6 @@ public class HlsDownload extends AbstractHlsDownload { try { generatePlaylist(recording); recording.setStatusWithEvent(State.POST_PROCESSING); - runPostProcessingScript(recording); } catch (Exception e) { throw new PostProcessingException(e); } diff --git a/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java index f6a92328..a3d263cd 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java @@ -492,12 +492,6 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { @Override public void postprocess(Recording recording) { - Thread.currentThread().setName("PP " + model.getName()); - try { - runPostProcessingScript(recording); - } catch (Exception e) { - throw new PostProcessingException(e); - } } public void downloadFinishedRecording(String segmentPlaylistUri, File target, ProgressListener progressListener, long sizeInBytes) throws Exception { diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java b/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java new file mode 100644 index 00000000..3cf306e5 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java @@ -0,0 +1,104 @@ +package ctbrec.recorder.postprocessing; + +import static ctbrec.StringUtil.*; +import static java.util.Optional.*; + +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import ctbrec.Config; +import ctbrec.Recording; +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}" + }; + + 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()) + ; + + output = replaceUtcDateTime(rec, output); + output = replaceLocalDateTime(rec, output); + + 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"; + Matcher m = Pattern.compile("\\$\\{" + placeHolder + "(?:\\((.*?)\\))?\\}").matcher(filename); + if (m.find()) { + String p = m.group(1); + if (p != null) { + pattern = p; + } + } + String formattedDate = getDateTime(rec, pattern, zone); + return m.replaceAll(formattedDate); + } + + 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); + } else { + return ""; + } + } + + private CharSequence getSanitizedSiteName(Recording rec) { + return sanitize(ofNullable(rec.getModel().getSite()).map(Site::getName).orElse("")); + } + + private CharSequence getSanitizedRecordingNotes(Recording rec) { + return sanitize(ofNullable(rec.getNote()).orElse("")); + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPostProcessor.java b/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPostProcessor.java new file mode 100644 index 00000000..4d26953c --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/AbstractPostProcessor.java @@ -0,0 +1,24 @@ +package ctbrec.recorder.postprocessing; + +import java.util.HashMap; +import java.util.Map; + +public abstract class AbstractPostProcessor implements PostProcessor { + + private Map config = new HashMap<>(); + + @Override + public Map getConfig() { + return config; + } + + @Override + public void setConfig(Map conf) { + this.config = conf; + } + + @Override + public String toString() { + return getName(); + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Copy.java b/common/src/main/java/ctbrec/recorder/postprocessing/Copy.java new file mode 100644 index 00000000..a5fe0c54 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Copy.java @@ -0,0 +1,49 @@ +package ctbrec.recorder.postprocessing; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +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 { + + private static final transient Logger LOG = LoggerFactory.getLogger(Copy.class); + + @Override + public String getName() { + return "create a copy"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + File orig = rec.getPostProcessedFile(); + String copyFilename = getFilenameForCopy(orig); + File copy = new File(orig.getParentFile(), copyFilename); + LOG.info("Creating a copy {}", copy); + if (orig.isFile()) { + Files.copy(rec.getPostProcessedFile().toPath(), copy.toPath()); + } else { + FileUtils.copyDirectory(orig, copy, true); + } + rec.setPostProcessedFile(copy); + rec.getAssociatedFiles().add(copy.getCanonicalPath()); + } + + private String getFilenameForCopy(File orig) { + String filename = orig.getName(); + if (orig.isFile()) { + String name = filename.substring(0, filename.lastIndexOf('.')); + String ext = filename.substring(filename.lastIndexOf('.') + 1); + return name + "_copy." + ext; + } else { + return filename + "_copy"; + } + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java b/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java new file mode 100644 index 00000000..d28ed652 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java @@ -0,0 +1,21 @@ +package ctbrec.recorder.postprocessing; + +import java.io.IOException; + +import ctbrec.Config; +import ctbrec.NotImplementedExcetion; +import ctbrec.Recording; +import ctbrec.recorder.RecordingManager; + +public class CreateContactSheet extends AbstractPlaceholderAwarePostProcessor { + + @Override + public String getName() { + return "create contact sheet"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + throw new NotImplementedExcetion(); + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/CreateTimelineThumbs.java b/common/src/main/java/ctbrec/recorder/postprocessing/CreateTimelineThumbs.java new file mode 100644 index 00000000..1adc5f49 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/CreateTimelineThumbs.java @@ -0,0 +1,21 @@ +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 { + + @Override + public String getName() { + return "create timeline thumbnails"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + 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 new file mode 100644 index 00000000..e73a57a8 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/DeleteOriginal.java @@ -0,0 +1,31 @@ +package ctbrec.recorder.postprocessing; + +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 { + + @Override + public String getName() { + return "delete original"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + if (rec.getAbsoluteFile().isFile()) { + Files.deleteIfExists(rec.getAbsoluteFile().toPath()); + deleteEmptyParents(rec.getAbsoluteFile().getParentFile()); + } else { + deleteDirectory(rec.getAbsoluteFile()); + deleteEmptyParents(rec.getAbsoluteFile()); + } + rec.setAbsoluteFile(rec.getPostProcessedFile()); + rec.getAssociatedFiles().remove(rec.getAbsoluteFile().getCanonicalPath()); + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/DeleteTooShort.java b/common/src/main/java/ctbrec/recorder/postprocessing/DeleteTooShort.java new file mode 100644 index 00000000..bdf38b4f --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/DeleteTooShort.java @@ -0,0 +1,34 @@ +package ctbrec.recorder.postprocessing; + +import java.io.IOException; +import java.time.Duration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Recording; +import ctbrec.recorder.RecordingManager; + +public class DeleteTooShort extends AbstractPostProcessor { + + private static final transient Logger LOG = LoggerFactory.getLogger(DeleteTooShort.class); + public static final String MIN_LEN_IN_SECS = "minimumLengthInSeconds"; + + @Override + public String getName() { + return "delete too short"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException { + Duration minimumLengthInSeconds = Duration.ofSeconds(Integer.parseInt(getConfig().getOrDefault(MIN_LEN_IN_SECS, "0"))); + if (minimumLengthInSeconds.getSeconds() > 0) { + Duration recordingLength = rec.getLength(); + if (recordingLength.compareTo(minimumLengthInSeconds) < 0) { + LOG.info("Deleting too short recording {} [{} < {}]", rec, recordingLength, minimumLengthInSeconds); + recordingManager.delete(rec); + } + } + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Move.java b/common/src/main/java/ctbrec/recorder/postprocessing/Move.java new file mode 100644 index 00000000..894faee3 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Move.java @@ -0,0 +1,61 @@ +package ctbrec.recorder.postprocessing; +import static ctbrec.io.IoUtils.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Recording; +import ctbrec.recorder.RecordingManager; + +public class Move extends AbstractPlaceholderAwarePostProcessor { + + private static final Logger LOG = LoggerFactory.getLogger(Rename.class); + public static final String PATH_TEMPLATE = "path.template"; + public static final String DEFAULT = "${modelSanitizedName}" + File.separatorChar + "${localDateTime}"; + + @Override + public String getName() { + return "move"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException { + String pathTemplate = getConfig().getOrDefault(PATH_TEMPLATE, DEFAULT); + String path = fillInPlaceHolders(pathTemplate, rec, config); + File src = rec.getPostProcessedFile(); + boolean isFile = src.isFile(); + File target = new File(path, src.getName()); + if (Objects.equals(src, target)) { + return; + } + LOG.info("Moving {} to {}", src.getName(), target.getParentFile().getCanonicalPath()); + Files.createDirectories(target.getParentFile().toPath()); + Files.move(rec.getPostProcessedFile().toPath(), target.toPath()); + rec.setPostProcessedFile(target); + if (Objects.equals(src, rec.getAbsoluteFile())) { + rec.setAbsoluteFile(target); + } + rec.getAssociatedFiles().remove(src.getCanonicalPath()); + rec.getAssociatedFiles().add(target.getCanonicalPath()); + if (isFile) { + deleteEmptyParents(src.getParentFile()); + } else { + deleteEmptyParents(src); + } + } + + @Override + public String toString() { + String s = getName(); + if (getConfig().containsKey(PATH_TEMPLATE)) { + s += " [" + getConfig().get(PATH_TEMPLATE) + ']'; + } + return s; + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessor.java b/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessor.java new file mode 100644 index 00000000..fe484e2f --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/PostProcessor.java @@ -0,0 +1,18 @@ +package ctbrec.recorder.postprocessing; + +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(); + + void postprocess(Recording rec, RecordingManager recordingManager, Config config) 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 new file mode 100644 index 00000000..55fda725 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/RemoveKeepFile.java @@ -0,0 +1,21 @@ +package ctbrec.recorder.postprocessing; + +import java.io.IOException; + +import ctbrec.Config; +import ctbrec.Recording; +import ctbrec.recorder.RecordingManager; + +public class RemoveKeepFile extends AbstractPostProcessor { + + @Override + public String getName() { + return "remove recording, but keep the files"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + recordingManager.remove(rec); + rec.setMetaDataFile(null); + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java b/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java new file mode 100644 index 00000000..61128568 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Remux.java @@ -0,0 +1,107 @@ +package ctbrec.recorder.postprocessing; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +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.io.StreamRedirectThread; +import ctbrec.recorder.RecordingManager; +import ctbrec.recorder.download.ProcessExitedUncleanException; + +public class Remux extends AbstractPostProcessor { + + private static final Logger LOG = LoggerFactory.getLogger(Remux.class); + + public static final String FFMPEG_ARGS = "ffmpeg.args"; + public static final String FILE_EXT = "file.ext"; + + @Override + public String getName() { + return "remux / transcode"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + String fileExt = getConfig().get(FILE_EXT); + String[] args = getConfig().get(FFMPEG_ARGS).split(" "); + String[] argsPlusFile = new String[args.length + 3]; + File inputFile = rec.getPostProcessedFile(); + if (inputFile.isDirectory()) { + inputFile = new File(inputFile, "playlist.m3u8"); + } + int i = 0; + argsPlusFile[i++] = "-i"; + argsPlusFile[i++] = inputFile.getCanonicalPath(); + System.arraycopy(args, 0, argsPlusFile, i, args.length); + File remuxedFile = new File(rec.getPostProcessedFile().getAbsolutePath() + '.' + fileExt); + argsPlusFile[argsPlusFile.length - 1] = remuxedFile.getAbsolutePath(); + String[] cmdline = OS.getFFmpegCommand(argsPlusFile); + LOG.info(Arrays.toString(cmdline)); + Process ffmpeg = Runtime.getRuntime().exec(cmdline, new String[0], rec.getPostProcessedFile().getParentFile()); + setupLogging(ffmpeg, rec); + rec.setPostProcessedFile(remuxedFile); + if (inputFile.getName().equals("playlist.m3u8")) { + IoUtils.deleteDirectory(inputFile.getParentFile()); + if (Objects.equals(inputFile.getParentFile(), rec.getAbsoluteFile())) { + rec.setAbsoluteFile(remuxedFile); + } + } else { + Files.deleteIfExists(inputFile.toPath()); + if (Objects.equals(inputFile, rec.getAbsoluteFile())) { + rec.setAbsoluteFile(remuxedFile); + } + } + rec.setSingleFile(true); + rec.setSizeInByte(remuxedFile.length()); + IoUtils.deleteEmptyParents(inputFile.getParentFile()); + rec.getAssociatedFiles().remove(inputFile.getCanonicalPath()); + rec.getAssociatedFiles().add(remuxedFile.getCanonicalPath()); + } + + private void setupLogging(Process ffmpeg, Recording rec) throws IOException, InterruptedException { + int exitCode = 1; + File video = rec.getPostProcessedFile(); + File ffmpegLog = new File(video.getParentFile(), video.getName() + ".ffmpeg.log"); + rec.getAssociatedFiles().add(ffmpegLog.getCanonicalPath()); + try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) { + Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream)); + Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream)); + stdout.start(); + stderr.start(); + exitCode = ffmpeg.waitFor(); + LOG.debug("FFmpeg exited with code {}", exitCode); + stdout.join(); + stderr.join(); + mergeLogStream.flush(); + } + if (exitCode != 1) { + if (ffmpegLog.exists()) { + Files.delete(ffmpegLog.toPath()); + rec.getAssociatedFiles().remove(ffmpegLog.getCanonicalPath()); + } + } else { + rec.getAssociatedFiles().add(ffmpegLog.getAbsolutePath()); + LOG.info("FFmpeg exit code was {}. Logfile: {}", exitCode, ffmpegLog.getAbsolutePath()); + throw new ProcessExitedUncleanException("FFmpeg exit code was " + exitCode); + } + } + + @Override + public String toString() { + String s = getName(); + if(getConfig().containsKey(FFMPEG_ARGS)) { + s += " [" + getConfig().get(FFMPEG_ARGS) + ']'; + } + return s; + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Rename.java b/common/src/main/java/ctbrec/recorder/postprocessing/Rename.java new file mode 100644 index 00000000..28e44b86 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Rename.java @@ -0,0 +1,54 @@ +package ctbrec.recorder.postprocessing; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +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 { + + private static final Logger LOG = LoggerFactory.getLogger(Rename.class); + public static final String FILE_NAME_TEMPLATE = "filename.template"; + public static final String DEFAULT = "${modelSanitizedName}_${localDateTime}.${fileSuffix}"; + public static final String DEFAULT_DIR = "${modelSanitizedName}_${localDateTime}"; + + @Override + public String getName() { + return "rename"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException { + String defaultTemplate = rec.isSingleFile() ? DEFAULT : DEFAULT_DIR; + String filenameTemplate = getConfig().getOrDefault(FILE_NAME_TEMPLATE, defaultTemplate); + String filename = fillInPlaceHolders(filenameTemplate, rec, config); + File src = rec.getPostProcessedFile(); + File target = new File(src.getParentFile(), filename); + if (Objects.equals(src, target)) { + return; + } + LOG.info("Renaming {} to {}", src.getName(), target.getName()); + Files.move(rec.getPostProcessedFile().toPath(), target.toPath()); + rec.setPostProcessedFile(target); + if (Objects.equals(src, rec.getAbsoluteFile())) { + rec.setAbsoluteFile(target); + } + rec.getAssociatedFiles().remove(src.getCanonicalPath()); + rec.getAssociatedFiles().add(target.getCanonicalPath()); + } + + @Override + public String toString() { + String s = getName(); + if (getConfig().containsKey(FILE_NAME_TEMPLATE)) { + s += " [" + getConfig().get(FILE_NAME_TEMPLATE) + ']'; + } + return s; + } +} diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Script.java b/common/src/main/java/ctbrec/recorder/postprocessing/Script.java new file mode 100644 index 00000000..a07b9bfc --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Script.java @@ -0,0 +1,73 @@ +package ctbrec.recorder.postprocessing; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.OS; +import ctbrec.Recording; +import ctbrec.io.StreamRedirectThread; +import ctbrec.recorder.RecordingManager; +import ctbrec.recorder.download.ProcessExitedUncleanException; + +public class Script extends AbstractPlaceholderAwarePostProcessor { + + private static final Logger LOG = LoggerFactory.getLogger(Script.class); + public static final String SCRIPT_EXECUTABLE = "script.executable"; + public static final String SCRIPT_PARAMS = "script.params"; + + @Override + public String getName() { + return "execute script"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + List cmdline = buildCommandLine(rec, config); + Runtime rt = Runtime.getRuntime(); + String[] args = cmdline.toArray(new String[0]); + if (LOG.isDebugEnabled()) { + LOG.debug("Running {}", Arrays.toString(args)); + } + Process process = rt.exec(args, OS.getEnvironment()); + startLogging(process); + int exitCode = process.waitFor(); + LOG.debug("Process finished with exit code {}", exitCode); + if (exitCode != 0) { + throw new ProcessExitedUncleanException("Script finished with exit code " + exitCode); + } + } + + private List buildCommandLine(Recording rec, Config config) throws IOException { + 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); + Arrays.stream(replacedParams.split(" ")).forEach(cmdline::add); + return cmdline; + } + + private void startLogging(Process process) { + // TODO maybe write these to a separate log file, e.g. recname.ts.script.log + Thread std = new Thread(new StreamRedirectThread(process.getInputStream(), System.out)); + std.setName("Process stdout pipe"); + std.setDaemon(true); + std.start(); + Thread err = new Thread(new StreamRedirectThread(process.getErrorStream(), System.err)); + err.setName("Process stderr pipe"); + err.setDaemon(true); + err.start(); + } + + @Override + public String toString() { + return (getName() + " " + getConfig().getOrDefault(Script.SCRIPT_EXECUTABLE, "")).trim(); + } +} + diff --git a/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java b/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java new file mode 100644 index 00000000..819d2f0b --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java @@ -0,0 +1,37 @@ +package ctbrec.recorder.postprocessing; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.NotImplementedExcetion; +import ctbrec.Recording; +import ctbrec.recorder.RecordingManager; + +public class Webhook extends AbstractPlaceholderAwarePostProcessor { + + private static final Logger LOG = LoggerFactory.getLogger(Webhook.class); + public static final String URL = "webhook.url"; + public static final String HEADERS = "webhook.headers"; + public static final String METHOD = "webhook.method"; + public static final String DATA = "webhook.data"; + public static final String SECRET = "webhook.secret"; + + @Override + public String getName() { + return "webhook"; + } + + @Override + public void postprocess(Recording rec, RecordingManager recordingManager, Config config) throws IOException, InterruptedException { + throw new NotImplementedExcetion(); + } + + @Override + public String toString() { + return (getName() + " " + getConfig().getOrDefault(Webhook.URL, "")).trim(); + } +} + diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java new file mode 100644 index 00000000..04e4e13d --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java @@ -0,0 +1,125 @@ +package ctbrec.recorder.postprocessing; + +import static org.junit.Assert.*; + +import java.io.IOException; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +import org.junit.Before; +import org.junit.Test; + +import ctbrec.Config; +import ctbrec.Recording; + +public class AbstractPlaceholderAwarePostProcessorTest extends AbstractPpTest { + + Recording rec; + Config config; + Move placeHolderAwarePp; + + @Override + @Before + public void setup() throws IOException { + super.setup(); + rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(original); + rec.setPostProcessedFile(postProcessed); + rec.setStartDate(now); + rec.setSingleFile(true); + config = mockConfig(); + placeHolderAwarePp = new Move(); + } + + @Test + public void testModelNameReplacement() { + String input = "asdf_${modelName}_asdf"; + assertEquals("asdf_Mockita Boobilicious_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + input = "asdf_${modelDisplayName}_asdf"; + assertEquals("asdf_Mockita Boobilicious_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + input = "asdf_${modelSanitizedName}_asdf"; + assertEquals("asdf_Mockita_Boobilicious_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + } + + @Test + public void testSiteNameReplacement() { + String input = "asdf_${siteName}_asdf"; + assertEquals("asdf_Chaturbate_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + input = "asdf_${siteSanitizedName}_asdf"; + assertEquals("asdf_Chaturbate_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + } + + @Test + public void testUtcTimeReplacement() { + String date = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") + .withLocale(Locale.US) + .withZone(ZoneOffset.UTC) + .format(rec.getStartDate()); + String input = "asdf_${utcDateTime}_asdf"; + assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + + date = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") + .withLocale(Locale.US) + .withZone(ZoneOffset.UTC) + .format(rec.getStartDate()); + input = "asdf_${utcDateTime(yyyyMMdd-HHmmss)}_asdf"; + assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + } + + @Test + public void testLocalTimeReplacement() { + String date = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") + .withLocale(Locale.US) + .withZone(ZoneId.systemDefault()) + .format(rec.getStartDate()); + String input = "asdf_${localDateTime}_asdf"; + assertEquals("asdf_" + date + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, 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)); + } + + @Test + public void testEpochReplacement() { + long epoch = now.toEpochMilli() / 1000; + String input = "asdf_${epochSecond}_asdf"; + assertEquals("asdf_" + epoch + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + } + + @Test + public void testFileSuffixReplacement() { + String input = "asdf_${fileSuffix}_asdf"; + assertEquals("asdf_ts_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + } + + @Test + public void testRecordingsDirReplacement() { + String input = "asdf_${recordingsDir}_asdf"; + assertEquals("asdf_" + recDir.toString() + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + } + + @Test + public void testAbsolutePathReplacement() { + String input = "asdf_${absolutePath}_asdf"; + assertEquals("asdf_" + postProcessed.getAbsolutePath().toString() + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + } + + @Test + public void testAbsoluteParentPathReplacement() { + String input = "asdf_${absoluteParentPath}_asdf"; + assertEquals("asdf_" + postProcessed.getParentFile().toString() + "_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + } + + @Test + public void testModelNotesReplacement() { + String input = "asdf_${modelNotes}_asdf"; + assertEquals("asdf_tag,_foo,_bar_asdf", placeHolderAwarePp.fillInPlaceHolders(input, rec, config)); + } +} diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPpTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPpTest.java new file mode 100644 index 00000000..0d9e65e7 --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/AbstractPpTest.java @@ -0,0 +1,84 @@ +package ctbrec.recorder.postprocessing; + +import static java.nio.charset.StandardCharsets.*; +import static java.nio.file.StandardOpenOption.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; + +import org.apache.commons.io.FileUtils; +import org.junit.After; +import org.junit.Before; +import org.mockito.MockedStatic; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.Settings; +import ctbrec.recorder.RecordingManager; +import ctbrec.sites.Site; +import ctbrec.sites.chaturbate.Chaturbate; + +public abstract class AbstractPpTest { + Path baseDir; + Path recDir; + File original; + File postProcessed; + File originalDir; + File postProcessedDir; + Instant now = Instant.now(); + RecordingManager recordingManager; + + MockedStatic configStatic; + + @Before + public void setup() throws IOException { + baseDir = Files.createTempDirectory("ctbrec_test_"); + recDir = baseDir.resolve("recordings"); + original = new File(recDir.toFile(), "original.ts"); + postProcessed = new File(recDir.toFile(), "postProcessed.ts"); + originalDir = new File(recDir.toFile(), "original"); + postProcessedDir = new File(recDir.toFile(), "postProcessed"); + Files.createDirectories(original.getParentFile().toPath()); + Files.write(original.toPath(), "foobar".getBytes(UTF_8), CREATE_NEW, WRITE, TRUNCATE_EXISTING); + Files.write(postProcessed.toPath(), "foobar".getBytes(UTF_8), CREATE_NEW, WRITE, TRUNCATE_EXISTING); + Files.createDirectories(originalDir.toPath()); + FileUtils.touch(new File(originalDir, "playlist.m3u8")); + } + + @After + public void teardown() throws IOException { + FileUtils.deleteDirectory(baseDir.toFile()); + if (configStatic != null) { + configStatic.close(); + configStatic = null; + } + } + + Config mockConfig() { + Config config = mock(Config.class); + when(config.getSettings()).thenReturn(mockSettings()); + when(config.getModelNotes(any())).thenReturn("tag, foo, bar"); + when(config.getConfigDir()).thenReturn(new File(baseDir.toFile(), "config")); + configStatic = mockStatic(Config.class); + configStatic.when(Config::getInstance).thenReturn(config); + return config; + } + + Model mockModel() { + Site site = new Chaturbate(); + Model model = site.createModel("Mockita Boobilicious"); + model.setDisplayName("Mockita Boobilicious"); + return model; + } + + Settings mockSettings() { + Settings settings = new Settings(); + settings.recordingsDir = recDir.toString(); + return settings; + } +} diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/CopyTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/CopyTest.java new file mode 100644 index 00000000..f28f4e26 --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/CopyTest.java @@ -0,0 +1,50 @@ +package ctbrec.recorder.postprocessing; + +import static org.junit.Assert.*; + +import java.io.IOException; + +import org.junit.Test; + +import ctbrec.Config; +import ctbrec.Recording; + +public class CopyTest extends AbstractPpTest { + + @Test + public void testCopySingleFile() throws IOException, InterruptedException { + Config config = mockConfig(); + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(original); + rec.setStartDate(now); + rec.setSingleFile(false); + Copy pp = new Copy(); + pp.postprocess(rec, recordingManager, config); + + assertNotEquals(rec.getAbsoluteFile(), rec.getPostProcessedFile()); + assertTrue(original.exists()); + assertTrue(rec.getPostProcessedFile().exists()); + } + + @Test + public void testCopyDirectory() throws IOException, InterruptedException { + Config config = mockConfig(); + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(originalDir); + rec.setStartDate(now); + rec.setSingleFile(false); + Copy pp = new Copy(); + pp.postprocess(rec, recordingManager, config); + + assertNotEquals(rec.getAbsoluteFile(), rec.getPostProcessedFile()); + assertTrue(originalDir.exists()); + assertTrue(rec.getPostProcessedFile().exists()); + } + + @Test + public void testGetName() { + assertEquals("create a copy", new Copy().getName()); + } +} diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/DeleteOriginalTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/DeleteOriginalTest.java new file mode 100644 index 00000000..48e54386 --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/DeleteOriginalTest.java @@ -0,0 +1,57 @@ +package ctbrec.recorder.postprocessing; + +import static org.junit.Assert.*; + +import java.io.IOException; +import java.nio.file.Files; + +import org.junit.Test; + +import ctbrec.Config; +import ctbrec.Recording; + +public class DeleteOriginalTest extends AbstractPpTest { + + @Test + public void testPostProcessWithSingleFile() throws IOException, InterruptedException { + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(original); + rec.setPostProcessedFile(postProcessed); + rec.setStartDate(now); + rec.setSingleFile(true); + + Config config = mockConfig(); + DeleteOriginal pp = new DeleteOriginal(); + pp.postprocess(rec, null, config); + + assertEquals(postProcessed, rec.getAbsoluteFile()); + assertTrue(rec.getAbsoluteFile().exists()); + assertFalse(original.exists()); + } + + @Test + public void testPostProcessWithDirectory() throws IOException, InterruptedException { + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(originalDir); + rec.setPostProcessedFile(postProcessedDir); + rec.setStartDate(now); + rec.setSingleFile(true); + + Config config = mockConfig(); + Files.createDirectories(postProcessedDir.toPath()); + DeleteOriginal pp = new DeleteOriginal(); + pp.postprocess(rec, null, config); + + assertEquals(postProcessedDir, rec.getAbsoluteFile()); + assertTrue(rec.getAbsoluteFile().exists()); + assertFalse(originalDir.exists()); + } + + @Test + public void testGetName() { + assertEquals("delete original", new DeleteOriginal().getName()); + } + +} diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/DeleteTooShortTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/DeleteTooShortTest.java new file mode 100644 index 00000000..6c3a5e08 --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/DeleteTooShortTest.java @@ -0,0 +1,98 @@ +package ctbrec.recorder.postprocessing; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.Collections; + +import org.junit.Test; + +import ctbrec.Config; +import ctbrec.Recording; +import ctbrec.recorder.RecordingManager; +import ctbrec.recorder.download.Download; + +public class DeleteTooShortTest extends AbstractPpTest { + + @Test + public void tooShortSingleFileRecShouldBeDeleted() throws IOException, InterruptedException { + testProcess(original); + } + + @Test + public void tooShortDirectoryRecShouldBeDeleted() throws IOException, InterruptedException { + testProcess(originalDir); + } + + private void testProcess(File original) throws IOException { + Recording rec = createRec(original); + Config config = mockConfig(); + RecordingManager recordingManager = new RecordingManager(config, Collections.emptyList()); + recordingManager.add(rec); + + assertEquals(1, recordingManager.getAll().size()); + + DeleteTooShort pp = new DeleteTooShort(); + pp.getConfig().put(DeleteTooShort.MIN_LEN_IN_SECS, "10"); + pp.postprocess(rec, recordingManager, config); + + assertFalse(rec.getAbsoluteFile().exists()); + assertFalse(original.exists()); + assertEquals(0, recordingManager.getAll().size()); + } + + @Test + public void testGetName() { + assertEquals("delete too short", new DeleteTooShort().getName()); + } + + @Test + public void testDisabledWithSingleFile() throws IOException, InterruptedException { + Recording rec = createRec(original); + Config config = mockConfig(); + RecordingManager recordingManager = new RecordingManager(config, Collections.emptyList()); + recordingManager.add(rec); + assertEquals(1, recordingManager.getAll().size()); + + DeleteTooShort pp = new DeleteTooShort(); + pp.getConfig().put(DeleteTooShort.MIN_LEN_IN_SECS, "0"); + pp.postprocess(rec, recordingManager, config); + + assertTrue(rec.getAbsoluteFile().exists()); + assertTrue(original.exists()); + assertEquals(1, recordingManager.getAll().size()); + } + + @Test + public void longEnoughVideoShouldStay() throws IOException, InterruptedException { + Recording rec = createRec(original); + Config config = mockConfig(); + RecordingManager recordingManager = new RecordingManager(config, Collections.emptyList()); + recordingManager.add(rec); + assertEquals(1, recordingManager.getAll().size()); + + DeleteTooShort pp = new DeleteTooShort(); + pp.getConfig().put(DeleteTooShort.MIN_LEN_IN_SECS, "1"); + pp.postprocess(rec, recordingManager, config); + + assertTrue(rec.getAbsoluteFile().exists()); + assertTrue(original.exists()); + assertEquals(1, recordingManager.getAll().size()); + } + + private Recording createRec(File original) { + Download download = mock(Download.class); + when(download.getLength()).thenReturn(Duration.ofSeconds(5)); + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(original); + rec.setPostProcessedFile(original); + rec.setStartDate(now); + rec.setSingleFile(true); + rec.setDownload(download); + return rec; + } +} diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/MoveDirectoryTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/MoveDirectoryTest.java new file mode 100644 index 00000000..93508e66 --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/MoveDirectoryTest.java @@ -0,0 +1,55 @@ +package ctbrec.recorder.postprocessing; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.Test; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.Recording; + +public class MoveDirectoryTest extends AbstractPpTest { + + @Test + public void testOriginalFileReplacement() throws IOException { + Config config = mockConfig(); + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(originalDir); + rec.setStartDate(now); + 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); + + 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()); + assertEquals(rec.getAbsoluteFile(), rec.getPostProcessedFile()); + assertNotEquals(rec.getAbsoluteFile(), original); + } + + @Test + public void absoluteFileShouldKeepBeingOriginalIfFilesDiffer() throws IOException { + Model model = mockModel(); + Recording rec = mock(Recording.class); + when(rec.getModel()).thenReturn(model); + when(rec.getAbsoluteFile()).thenReturn(originalDir); + when(rec.getPostProcessedFile()).thenReturn(postProcessedDir); + when(rec.getStartDate()).thenReturn(now); + doThrow(new RuntimeException("Unexpected call of setAbsoluteFile")).when(rec).setAbsoluteFile(any()); + + Files.createDirectories(postProcessedDir.toPath()); + 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); + } +} diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/MoveSingleFileTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/MoveSingleFileTest.java new file mode 100644 index 00000000..b2199b80 --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/MoveSingleFileTest.java @@ -0,0 +1,77 @@ +package ctbrec.recorder.postprocessing; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.Test; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.Recording; + +public class MoveSingleFileTest extends AbstractPpTest { + + @Test + public void testOriginalFileReplacement() throws IOException { + Config config = mockConfig(); + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(original); + rec.setStartDate(now); + rec.setSingleFile(true); + + Move pp = new Move(); + pp.getConfig().put(Move.PATH_TEMPLATE, new File(baseDir.toFile(), Move.DEFAULT).getAbsolutePath()); + pp.postprocess(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()); + assertEquals(rec.getAbsoluteFile(), rec.getPostProcessedFile()); + assertNotEquals(rec.getAbsoluteFile(), original); + } + + @Test + public void testEarlyExit() throws IOException { + Model model = mockModel(); + Recording rec = mock(Recording.class); + when(rec.getModel()).thenReturn(model); + when(rec.getAbsoluteFile()).thenReturn(original); + when(rec.getPostProcessedFile()).thenReturn(original); + when(rec.getStartDate()).thenReturn(now); + doThrow(new RuntimeException("Unexpected call of setAbsoluteFile")).when(rec).setAbsoluteFile(any()); + Move pp = new Move(); + Config config = mockConfig(); + pp.getConfig().put(Move.PATH_TEMPLATE, original.getParentFile().getCanonicalPath()); + pp.postprocess(rec, recordingManager, config); + } + + @Test + public void absoluteFileShouldKeepBeingOriginalIfFilesDiffer() throws IOException { + Model model = mockModel(); + Recording rec = mock(Recording.class); + when(rec.getModel()).thenReturn(model); + when(rec.getAbsoluteFile()).thenReturn(original); + when(rec.getPostProcessedFile()).thenReturn(postProcessed); + when(rec.getStartDate()).thenReturn(now); + doThrow(new RuntimeException("Unexpected call of setAbsoluteFile")).when(rec).setAbsoluteFile(any()); + 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); + } + + @Test + public void testToString() { + Move pp = new Move(); + assertEquals("move", pp.toString()); + + pp.getConfig().put(Move.PATH_TEMPLATE, Move.DEFAULT); + assertEquals("move ["+Move.DEFAULT+"]", pp.toString()); + } +} diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/RemoveKeepFileTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/RemoveKeepFileTest.java new file mode 100644 index 00000000..8909091f --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/RemoveKeepFileTest.java @@ -0,0 +1,42 @@ +package ctbrec.recorder.postprocessing; + +import static org.junit.Assert.*; + +import java.io.IOException; +import java.util.Collections; + +import org.junit.Test; + +import ctbrec.Config; +import ctbrec.Recording; +import ctbrec.recorder.RecordingManager; + +public class RemoveKeepFileTest extends AbstractPpTest { + + @Test + public void testPostProcessWithSingleFile() throws IOException, InterruptedException { + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(original); + rec.setPostProcessedFile(postProcessed); + rec.setStartDate(now); + rec.setSingleFile(true); + + Config config = mockConfig(); + RecordingManager rm = new RecordingManager(config, Collections.emptyList()); + rm.add(rec); + assertTrue(rm.getAll().size() == 1); + RemoveKeepFile pp = new RemoveKeepFile(); + pp.postprocess(rec, rm, config); + + assertTrue(rec.getAbsoluteFile().exists()); + assertTrue(rec.getPostProcessedFile().exists()); + assertTrue(rm.getAll().isEmpty()); + } + + @Test + public void testGetName() { + assertEquals("remove recording, but keep the files", new RemoveKeepFile().getName()); + } + +} diff --git a/common/src/test/java/ctbrec/recorder/postprocessing/RenameDirectoryTest.java b/common/src/test/java/ctbrec/recorder/postprocessing/RenameDirectoryTest.java new file mode 100644 index 00000000..cdd9cfb2 --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/RenameDirectoryTest.java @@ -0,0 +1,52 @@ +package ctbrec.recorder.postprocessing; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.Test; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.Recording; + +public class RenameDirectoryTest extends AbstractPpTest { + + @Test + public void testOriginalFileReplacement() throws IOException { + Config config = mockConfig(); + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(originalDir); + rec.setStartDate(now); + rec.setSingleFile(false); + Rename pp = new Rename(); + pp.postprocess(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()); + assertEquals(rec.getAbsoluteFile(), rec.getPostProcessedFile()); + assertNotEquals(rec.getAbsoluteFile(), original); + } + + @Test + public void absoluteFileShouldKeepBeingOriginalIfFilesDiffer() throws IOException { + Config config = mockConfig(); + Model model = mockModel(); + Recording rec = mock(Recording.class); + when(rec.getModel()).thenReturn(model); + when(rec.getAbsoluteFile()).thenReturn(originalDir); + when(rec.getPostProcessedFile()).thenReturn(postProcessedDir); + when(rec.getStartDate()).thenReturn(now); + doThrow(new RuntimeException("Unexpected call of setAbsoluteFile")).when(rec).setAbsoluteFile(any()); + + Files.createDirectories(postProcessedDir.toPath()); + Rename pp = new Rename(); + pp.postprocess(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 new file mode 100644 index 00000000..de739cd5 --- /dev/null +++ b/common/src/test/java/ctbrec/recorder/postprocessing/RenameSingleFileTest.java @@ -0,0 +1,73 @@ +package ctbrec.recorder.postprocessing; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.Test; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.Recording; + +public class RenameSingleFileTest extends AbstractPpTest { + + @Test + public void testOriginalFileReplacement() throws IOException { + Config config = mockConfig(); + Recording rec = new Recording(); + rec.setModel(mockModel()); + rec.setAbsoluteFile(original); + rec.setStartDate(now); + rec.setSingleFile(true); + Rename pp = new Rename(); + pp.postprocess(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()); + assertEquals(rec.getAbsoluteFile(), rec.getPostProcessedFile()); + assertNotEquals(rec.getAbsoluteFile(), original); + } + + @Test + public void testEarlyExit() throws IOException { + Config config = mockConfig(); + Model model = mockModel(); + Recording rec = mock(Recording.class); + when(rec.getModel()).thenReturn(model); + when(rec.getAbsoluteFile()).thenReturn(original); + when(rec.getPostProcessedFile()).thenReturn(original); + when(rec.getStartDate()).thenReturn(now); + 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); + } + + @Test + public void absoluteFileShouldKeepBeingOriginalIfFilesDiffer() throws IOException { + Config config = mockConfig(); + Model model = mockModel(); + Recording rec = mock(Recording.class); + when(rec.getModel()).thenReturn(model); + when(rec.getAbsoluteFile()).thenReturn(original); + when(rec.getPostProcessedFile()).thenReturn(postProcessed); + 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); + } + + @Test + public void testToString() { + Rename pp = new Rename(); + assertEquals("rename", pp.toString()); + + pp.getConfig().put(Rename.FILE_NAME_TEMPLATE, Rename.DEFAULT); + assertEquals("rename [${modelSanitizedName}_${localDateTime}.${fileSuffix}]", pp.toString()); + } +} diff --git a/master/pom.xml b/master/pom.xml index 40e91c83..9eb04530 100644 --- a/master/pom.xml +++ b/master/pom.xml @@ -6,7 +6,7 @@ ctbrec master pom - 3.9.0 + 3.10.0 ../common @@ -26,6 +26,14 @@ maven-assembly-plugin 3.1.0 + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + true + + @@ -103,12 +111,23 @@ guava 17.0 + + commons-io + commons-io + 2.8.0 + junit junit 4.12 test + + org.mockito + mockito-inline + 3.5.11 + test + org.eclipse.jetty jetty-server @@ -121,4 +140,8 @@ + + + + diff --git a/server/pom.xml b/server/pom.xml index 9dd10055..d7413299 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -8,7 +8,7 @@ ctbrec master - 3.9.0 + 3.10.0 ../master diff --git a/server/src/main/java/ctbrec/recorder/server/ConfigServlet.java b/server/src/main/java/ctbrec/recorder/server/ConfigServlet.java index 7d0e73e8..36d9e13c 100644 --- a/server/src/main/java/ctbrec/recorder/server/ConfigServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/ConfigServlet.java @@ -57,10 +57,8 @@ public class ConfigServlet extends AbstractCtbrecServlet { addParameter("generatePlaylist", "Generate Playlist", DataType.BOOLEAN, settings.generatePlaylist, json); addParameter("minimumResolution", "Minimum Resolution", DataType.INTEGER, settings.minimumResolution, json); addParameter("maximumResolution", "Maximum Resolution", DataType.INTEGER, settings.maximumResolution, json); - addParameter("minimumLengthInSeconds", "Minimum Length (secs)", DataType.INTEGER, settings.minimumLengthInSeconds, json); addParameter("minimumSpaceLeftInBytes", "Leave Space On Device (GiB)", DataType.LONG, settings.minimumSpaceLeftInBytes, json); addParameter("onlineCheckIntervalInSecs", "Online Check Interval (secs)", DataType.INTEGER, settings.onlineCheckIntervalInSecs, json); - addParameter("postProcessing", "Post-Processing", DataType.STRING, settings.postProcessing, json); addParameter("postProcessingThreads", "Post-Processing Threads", DataType.INTEGER, settings.postProcessingThreads, json); addParameter("recordingsDir", "Recordings Directory", DataType.STRING, settings.recordingsDir, json); addParameter("recordSingleFile", "Record Single File", DataType.BOOLEAN, settings.recordSingleFile, json); diff --git a/server/src/main/java/ctbrec/recorder/server/HlsServlet.java b/server/src/main/java/ctbrec/recorder/server/HlsServlet.java index 4749715e..c1776852 100644 --- a/server/src/main/java/ctbrec/recorder/server/HlsServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/HlsServlet.java @@ -9,6 +9,8 @@ import java.nio.file.Paths; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Enumeration; +import java.util.Objects; +import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -21,6 +23,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.Config; +import ctbrec.Recording; +import ctbrec.recorder.Recorder; public class HlsServlet extends AbstractCtbrecServlet { @@ -28,8 +32,11 @@ public class HlsServlet extends AbstractCtbrecServlet { private final Config config; - public HlsServlet(Config config) { + private Recorder recorder; + + public HlsServlet(Config config, Recorder recorder) { this.config = config; + this.recorder = recorder; } @Override @@ -39,39 +46,56 @@ public class HlsServlet extends AbstractCtbrecServlet { Path recordingsDirPath = Paths.get(config.getSettings().recordingsDir).toAbsolutePath().normalize(); Path requestedFilePath = recordingsDirPath.resolve(request).toAbsolutePath().normalize(); - boolean isValidRequestedPath = requestedFilePath.startsWith(recordingsDirPath); - if (isValidRequestedPath) { - File requestedFile = requestedFilePath.toFile(); + File requestedFile = requestedFilePath.toFile(); + try { if (requestedFile.getName().equals("playlist.m3u8")) { - try { - boolean isRequestAuthenticated = checkAuthentication(req, req.getRequestURI()); - if (!isRequestAuthenticated) { - writeResponse(resp, SC_UNAUTHORIZED, "{\"status\": \"error\", \"msg\": \"HMAC does not match\"}"); - return; - } - } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { - writeResponse(resp, SC_UNAUTHORIZED, "{\"status\": \"error\", \"msg\": \"Authentication failed\"}"); + boolean isRequestAuthenticated = checkAuthentication(req, req.getRequestURI()); + if (!isRequestAuthenticated) { + writeResponse(resp, SC_UNAUTHORIZED, "{\"status\": \"error\", \"msg\": \"HMAC does not match\"}"); return; } - servePlaylist(req, resp, requestedFile); - } else { - if (requestedFile.exists()) { - Enumeration headerNames = req.getHeaderNames(); - while(headerNames.hasMoreElements()) { - String header = headerNames.nextElement(); - LOG.trace("{}: {}", header, req.getHeader(header)); - } - serveSegment(req, resp, requestedFile); + String id = request.substring(0, request.indexOf('/')); + Optional rec = getRecordingById(id); + if (rec.isPresent()) { + servePlaylist(req, resp, rec.get().getAbsoluteFile()); } else { error404(req, resp); + return; + } + } else { + String id = request.split("/")[0]; + Optional rec = getRecordingById(id); + if (rec.isPresent()) { + File path = rec.get().getAbsoluteFile(); + if (!path.isFile()) { + path = new File(path, requestedFile.getName()); + } + if (LOG.isTraceEnabled()) { + Enumeration headerNames = req.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String header = headerNames.nextElement(); + LOG.trace("{}: {}", header, req.getHeader(header)); + } + } + serveSegment(req, resp, path); + } else { + error404(req, resp); + return; } } - } else { - writeResponse(resp, SC_FORBIDDEN, "Stop it!"); + } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + writeResponse(resp, SC_UNAUTHORIZED, "{\"status\": \"error\", \"msg\": \"Authentication failed\"}"); + return; } } + private Optional getRecordingById(String id) throws InvalidKeyException, NoSuchAlgorithmException, IOException { + return recorder.getRecordings().stream() + .filter(r -> Objects.equals(id, r.getId())) + .findFirst(); + } + private void writeResponse(HttpServletResponse resp, int code, String body) { try { resp.setStatus(code); @@ -82,6 +106,7 @@ public class HlsServlet extends AbstractCtbrecServlet { } private void error404(HttpServletRequest req, HttpServletResponse resp) { + writeResponse(resp, SC_NOT_FOUND, "{\"status\": \"error\", \"msg\": \"Recording not found\"}"); resp.setStatus(HttpServletResponse.SC_NOT_FOUND); } @@ -94,7 +119,9 @@ public class HlsServlet extends AbstractCtbrecServlet { } private void servePlaylist(HttpServletRequest req, HttpServletResponse resp, File requestedFile) throws IOException { - serveFile(req, resp, requestedFile, "application/x-mpegURL"); + LOG.debug("Serving playlist {}", requestedFile); + File playlist = new File(requestedFile, "playlist.m3u8"); + serveFile(req, resp, playlist, "application/x-mpegURL"); } private void serveFile(HttpServletRequest req, HttpServletResponse resp, File file, String contentType) throws IOException { @@ -107,6 +134,7 @@ public class HlsServlet extends AbstractCtbrecServlet { byte[] buffer = new byte[1024 * 100]; long bytesLeft = range.to - range.from; resp.setContentLengthLong(bytesLeft); + resp.addHeader("Content-Disposition", "attachment; filename=\"" + file.getName() + "\""); if (range.set) { resp.setHeader("Content-Range", "bytes " + range.from + '-' + range.to + '/' + file.length()); } diff --git a/server/src/main/java/ctbrec/recorder/server/HttpServer.java b/server/src/main/java/ctbrec/recorder/server/HttpServer.java index 56126c9b..fc354de5 100644 --- a/server/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/server/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -213,7 +213,7 @@ public class HttpServer { holder = new ServletHolder(configServlet); defaultContext.addServlet(holder, "/config"); - HlsServlet hlsServlet = new HlsServlet(this.config); + HlsServlet hlsServlet = new HlsServlet(this.config, recorder); holder = new ServletHolder(hlsServlet); defaultContext.addServlet(holder, "/hls/*"); diff --git a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java index d42478bd..a7919586 100644 --- a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java @@ -2,6 +2,7 @@ package ctbrec.recorder.server; import static javax.servlet.http.HttpServletResponse.*; +import java.io.File; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; @@ -25,6 +26,7 @@ import ctbrec.Config; import ctbrec.Model; import ctbrec.Recording; import ctbrec.io.BandwidthMeter; +import ctbrec.io.FileJsonAdapter; import ctbrec.io.InstantJsonAdapter; import ctbrec.io.ModelJsonAdapter; import ctbrec.recorder.Recorder; @@ -63,6 +65,7 @@ public class RecorderServlet extends AbstractCtbrecServlet { Moshi moshi = new Moshi.Builder() .add(Instant.class, new InstantJsonAdapter()) .add(Model.class, new ModelJsonAdapter(sites)) + .add(File.class, new FileJsonAdapter()) .build(); JsonAdapter requestAdapter = moshi.adapter(Request.class); Request request = requestAdapter.fromJson(json); diff --git a/server/src/main/resources/html/static/recordings.js b/server/src/main/resources/html/static/recordings.js index 6c446355..f41a28e7 100644 --- a/server/src/main/resources/html/static/recordings.js +++ b/server/src/main/resources/html/static/recordings.js @@ -1,5 +1,5 @@ function play(recording) { - let src = recording.singleFile ? '/hls' + recording.path : recording.playlist; + let src = recording.singleFile ? '/hls/' + recording.id : recording.playlist; let hmacOfPath = CryptoJS.HmacSHA256(src, hmac); src = '..' + src; if(console) console.log("Path", src, "HMAC", hmacOfPath); @@ -48,7 +48,7 @@ function play(recording) { } function download(recording) { - let src = recording.singleFile ? '/hls' + recording.path : recording.playlist; + let src = recording.singleFile ? '/hls/' + recording.id : recording.playlist; let hmacOfPath = CryptoJS.HmacSHA256(src, hmac); src = '..' + src; if(console) console.log("Path", src, "HMAC", hmacOfPath); @@ -77,7 +77,7 @@ function calculateSize(sizeInByte) { function isRecordingInArray(array, recording) { for ( let idx in array) { let r = array[idx]; - if (r.path === recording.path) { + if (r.id === recording.id) { return true; } } @@ -115,10 +115,10 @@ function syncRecordings(recordings) { recording.ko_progressString = ko.observable(recording.progress === -1 ? '' : recording.progress); recording.ko_size = ko.observable(calculateSize(recording.sizeInByte)); recording.ko_status = ko.observable(recording.status); - if (recording.path.endsWith('.mp4')) { - recording.playlist = '/hls' + recording.path; + if (recording.singleFile) { + recording.playlist = '/hls/' + recording.id; } else { - recording.playlist = '/hls' + recording.path + '/playlist.m3u8'; + recording.playlist = '/hls/' + recording.id + '/playlist.m3u8'; } observableRecordingsArray.push(recording); } @@ -129,7 +129,7 @@ function syncRecordings(recordings) { let recording = recordings[i]; for ( let j in observableRecordingsArray()) { let r = observableRecordingsArray()[j]; - if (recording.path === r.path) { + if (recording.id === r.id) { r.progress = recording.progress; r.sizeInByte = recording.sizeInByte; r.status = recording.status;