forked from j62/ctbrec
1
0
Fork 0

Merge branch 'pp' into dev

This commit is contained in:
0xb00bface 2020-09-27 15:25:49 +02:00
commit 8b6d246732
68 changed files with 2476 additions and 470 deletions

View File

@ -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.

View File

@ -8,7 +8,7 @@
<parent>
<groupId>ctbrec</groupId>
<artifactId>master</artifactId>
<version>3.9.0</version>
<version>3.10.0</version>
<relativePath>../master</relativePath>
</parent>

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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<Property<?>> 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<Number> prop = setting.getProperty();
ctrl.textProperty().bindBidirectional(prop, new NumberStringConverter());
return ctrl;
}
@SuppressWarnings("unchecked")
private Node createLongProperty(Setting setting) {
TextField ctrl = new TextField();
Property<Number> 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<Object> 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;
}
}

View File

@ -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)
)
);
}
}

View File

@ -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)
)
);
}
}

View File

@ -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<?>, 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<PostProcessor> stepList) {
openDialog(pp, config, scene, stepList, true);
}
public static void openEditDialog(PostProcessor pp, Config config, Scene scene, ObservableList<PostProcessor> stepList) {
openDialog(pp, config, scene, stepList, false);
}
private static void openDialog(PostProcessor pp, Config config, Scene scene, ObservableList<PostProcessor> stepList, boolean newEntry) {
boolean ok;
try {
Optional<Preferences> 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<Preferences> 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();
}
}
}

View File

@ -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<PostProcessor> stepListView;
ObservableList<PostProcessor> 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<PostProcessor>) 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<PostProcessor> 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<PostProcessor> 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;
}
}

View File

@ -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)
)
);
}
}

View File

@ -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)
)
);
}
}

View File

@ -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)
)
);
}
}

View File

@ -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<Site> 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<Category> 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) {

View File

@ -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<Category> 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<Category> categoryTreeItems = createCategoryTree(categories, new TreeItem<>(), null);
@ -76,7 +81,9 @@ public class Preferences {
VBox.setMargin(categoryTree, new Insets(2));
BorderPane main = new BorderPane();
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<? extends String> obs, String oldV, String newV) {
String q = ofNullable(newV).orElse("").toLowerCase().trim();
TreeItem<Category> 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,13 +211,11 @@ public class Preferences {
}
private void visit(Category cat, Consumer<Setting> visitor) {
if (cat.hasGroups()) {
for (Group group : cat.getGroups()) {
for (Setting setting : group.getSettings()) {
visitor.accept(setting);
}
}
}
if (cat.hasSubCategories()) {
for (Category subcat : cat.getSubCategories()) {
visit(subcat, visitor);

View File

@ -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
}
}

View File

@ -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;
}
};

View File

@ -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);

View File

@ -8,7 +8,7 @@
<parent>
<groupId>ctbrec</groupId>
<artifactId>master</artifactId>
<version>3.9.0</version>
<version>3.10.0</version>
<relativePath>../master</relativePath>
</parent>
@ -50,6 +50,10 @@
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
@ -71,6 +75,11 @@
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>

View File

@ -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<Settings> 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<Settings> 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(), "");
}
}

View File

@ -3,7 +3,7 @@ package ctbrec;
public class NotImplementedExcetion extends RuntimeException {
public NotImplementedExcetion() {
super();
super("Not implemented");
}
public NotImplementedExcetion(String mesg) {

View File

@ -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<String> 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() {
if (absoluteFile == null) {
String recordingsDir = Config.getInstance().getSettings().recordingsDir;
File recordingsFile = new File(recordingsDir, getPath());
return recordingsFile;
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<String> getAssociatedFiles() {
return associatedFiles;
}
}

View File

@ -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<String> disabledSites = new ArrayList<>();
public String downloadFilename = "${modelSanitizedName}-${localDateTime}";
public List<EventHandlerConfiguration> 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<String, String> modelNotes = new HashMap<>();
public List<Model> 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<PostProcessor> 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;

View File

@ -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
}

View File

@ -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 {
}
}

View File

@ -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<File> {
@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();
}
}
}

View File

@ -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);
}
}
}

View File

@ -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<PostProcessor> {
@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<String, String> entry : pp.getConfig().entrySet()) {
writer.name(entry.getKey()).value(entry.getValue());
}
writer.endObject();
writer.endObject();
}
}

View File

@ -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<PostProcessor> 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<Recording> 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;
}

View File

@ -139,7 +139,7 @@ public class OnlineMonitor extends Thread {
}
private void suspendUntilNextIteration(List<Model> 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 {

View File

@ -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<WatchKey, Path> 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<WatchEvent<?>> 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<Path> 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<WatchEvent<?>> events) {
WatchEvent<Path> deleteEvent = cast(events.get(0));
WatchEvent<Path> createEvent = cast(events.get(1));
Path from = dir.resolve(deleteEvent.context());
Path to = dir.resolve(createEvent.context());
LOG.debug("{} -> {}", from, to);
List<Recording> 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<Recording> getAffectedRecordings(Path from) {
String f = from.toAbsolutePath().toString();
List<Recording> 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<Recording> 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<WatchEvent<?>> 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<Path>() {
@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 <T> WatchEvent<T> cast(WatchEvent<?> event) {
return (WatchEvent<T>) 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();
}
}

View File

@ -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,14 +68,14 @@ public class RecordingManager {
}
public void saveRecording(Recording rec) throws IOException {
if (rec.getMetaDataFile() != null) {
File recordingMetaData = new File(rec.getMetaDataFile());
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.createDirectories(recordingMetaData.getParentFile().toPath());
Files.write(recordingMetaData.toPath(), json.getBytes(UTF_8), CREATE, WRITE, TRUNCATE_EXISTING);
}
}
private void loadRecordings() throws IOException {
File recordingsMetaDir = getDir();
@ -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 {

View File

@ -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<ModelListResponse> modelListResponseAdapter = moshi.adapter(ModelListResponse.class);
private JsonAdapter<RecordingListResponse> recordingListResponseAdapter = moshi.adapter(RecordingListResponse.class);
private JsonAdapter<ModelRequest> 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);
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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

View File

@ -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);
}

View File

@ -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 {

View File

@ -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(""));
}
}

View File

@ -0,0 +1,24 @@
package ctbrec.recorder.postprocessing;
import java.util.HashMap;
import java.util.Map;
public abstract class AbstractPostProcessor implements PostProcessor {
private Map<String, String> config = new HashMap<>();
@Override
public Map<String, String> getConfig() {
return config;
}
@Override
public void setConfig(Map<String, String> conf) {
this.config = conf;
}
@Override
public String toString() {
return getName();
}
}

View File

@ -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";
}
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}
}
}

View File

@ -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;
}
}

View File

@ -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<String, String> getConfig();
void setConfig(Map<String, String> conf);
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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<String> buildCommandLine(Recording rec, Config config) throws IOException {
String script = getConfig().getOrDefault(SCRIPT_EXECUTABLE, "somescript");
String params = getConfig().getOrDefault(SCRIPT_PARAMS, "${absolutePath}");
List<String> 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();
}
}

View File

@ -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();
}
}

View File

@ -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));
}
}

View File

@ -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<Config> 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;
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -6,7 +6,7 @@
<groupId>ctbrec</groupId>
<artifactId>master</artifactId>
<packaging>pom</packaging>
<version>3.9.0</version>
<version>3.10.0</version>
<modules>
<module>../common</module>
@ -26,6 +26,14 @@
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
</plugins>
</pluginManagement>
<plugins>
@ -103,12 +111,23 @@
<artifactId>guava</artifactId>
<version>17.0</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>3.5.11</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
@ -121,4 +140,8 @@
</dependency>
</dependencies>
</dependencyManagement>
</project>

View File

@ -8,7 +8,7 @@
<parent>
<groupId>ctbrec</groupId>
<artifactId>master</artifactId>
<version>3.9.0</version>
<version>3.10.0</version>
<relativePath>../master</relativePath>
</parent>

View File

@ -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);

View File

@ -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,37 +46,54 @@ 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();
if (requestedFile.getName().equals("playlist.m3u8")) {
try {
if (requestedFile.getName().equals("playlist.m3u8")) {
boolean isRequestAuthenticated = checkAuthentication(req, req.getRequestURI());
if (!isRequestAuthenticated) {
writeResponse(resp, SC_UNAUTHORIZED, "{\"status\": \"error\", \"msg\": \"HMAC does not match\"}");
return;
}
String id = request.substring(0, request.indexOf('/'));
Optional<Recording> rec = getRecordingById(id);
if (rec.isPresent()) {
servePlaylist(req, resp, rec.get().getAbsoluteFile());
} else {
error404(req, resp);
return;
}
} else {
String id = request.split("/")[0];
Optional<Recording> rec = getRecordingById(id);
if (rec.isPresent()) {
File path = rec.get().getAbsoluteFile();
if (!path.isFile()) {
path = new File(path, requestedFile.getName());
}
if (LOG.isTraceEnabled()) {
Enumeration<String> 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;
}
}
} catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
writeResponse(resp, SC_UNAUTHORIZED, "{\"status\": \"error\", \"msg\": \"Authentication failed\"}");
return;
}
}
servePlaylist(req, resp, requestedFile);
} else {
if (requestedFile.exists()) {
Enumeration<String> headerNames = req.getHeaderNames();
while(headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
LOG.trace("{}: {}", header, req.getHeader(header));
}
serveSegment(req, resp, requestedFile);
} else {
error404(req, resp);
}
}
} else {
writeResponse(resp, SC_FORBIDDEN, "Stop it!");
}
private Optional<Recording> 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) {
@ -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());
}

View File

@ -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/*");

View File

@ -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<Request> requestAdapter = moshi.adapter(Request.class);
Request request = requestAdapter.fromJson(json);

View File

@ -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;