diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a9af11..98ef9b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,16 @@ -NEXT +3.12.1 +======================== +* Fix: "Resume all" started the recordings of models marked for later recording + +3.12.0 ======================== * Added "record later" tab to "bookmark" models * Added config option to show the total number of models in the title bar +* Added support for hlsdl. Some sites (MV Live, LiveJasmin, Showup) are + excluded, because they need some special behavior while the download is + running. hlsdl can be activated in the settings under "Advanced" or with + the config properties "useHlsdl", "hlsdlExecutable" and "loghlsdlOutput". + The used bandwidth calculation does not work with hlsdl. * Fixed problem with Cam4 playlist URLs, thanks @gohufrapoc 3.11.0 diff --git a/client/pom.xml b/client/pom.xml index 3b8e2750..7586fdbc 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -106,7 +106,7 @@ com.akathist.maven.plugins.launch4j launch4j-maven-plugin - 1.7.22 + 1.7.25 l4j-win @@ -132,7 +132,9 @@ true 15 512 - -Dfile.encoding=utf-8 + + -Dfile.encoding=utf-8 + 4.0.0.0 diff --git a/client/src/main/java/ctbrec/ui/controls/PausedIndicator.java b/client/src/main/java/ctbrec/ui/controls/PausedIndicator.java deleted file mode 100644 index 482d8d08..00000000 --- a/client/src/main/java/ctbrec/ui/controls/PausedIndicator.java +++ /dev/null @@ -1,34 +0,0 @@ -package ctbrec.ui.controls; - -import ctbrec.ui.PauseIcon; -import javafx.scene.Cursor; -import javafx.scene.control.Tooltip; -import javafx.scene.layout.StackPane; -import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; -import javafx.scene.shape.Rectangle; - -public class PausedIndicator extends StackPane { - - private PauseIcon pausedIcon; - private Rectangle clickPanel; - - public PausedIndicator(int size, Color color) { - setMaxSize(size, size); - - pausedIcon = new PauseIcon(color, size); - pausedIcon.setVisible(false); - clickPanel = new Rectangle(size, size); - clickPanel.setCursor(Cursor.HAND); - clickPanel.setFill(Paint.valueOf("#00000000")); - getChildren().add(pausedIcon); - getChildren().add(clickPanel); - - pausedIcon.visibleProperty().bindBidirectional(visibleProperty()); - clickPanel.onMouseClickedProperty().bindBidirectional(onMouseClickedProperty()); - - Tooltip tooltip = new Tooltip("Resume Recording"); - Tooltip.install(clickPanel, tooltip); - } -} - diff --git a/client/src/main/java/ctbrec/ui/controls/RecordingIndicator.java b/client/src/main/java/ctbrec/ui/controls/RecordingIndicator.java new file mode 100644 index 00000000..ed07aa1b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/RecordingIndicator.java @@ -0,0 +1,37 @@ +package ctbrec.ui.controls; + +import javafx.scene.Cursor; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Paint; +import javafx.scene.shape.Rectangle; + +public class RecordingIndicator extends StackPane { + + private ImageView icon; + private Rectangle clickPanel; + + public RecordingIndicator(int size) { + setMaxSize(size, size); + + icon = new ImageView(); + icon.setVisible(false); + icon.prefHeight(size); + icon.prefWidth(size); + icon.maxHeight(size); + icon.maxWidth(size); + clickPanel = new Rectangle(size, size); + clickPanel.setCursor(Cursor.HAND); + clickPanel.setFill(Paint.valueOf("#00000000")); + getChildren().add(icon); + getChildren().add(clickPanel); + + icon.visibleProperty().bindBidirectional(visibleProperty()); + } + + public void setImage(Image img) { + icon.setImage(img); + } +} + diff --git a/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java b/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java index e330b56b..3423bfda 100644 --- a/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java +++ b/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java @@ -290,6 +290,7 @@ public class CtbrecPreferencesStorage implements PreferencesStorage { ComboBox comboBox = new ComboBox(listProp); Field field = Settings.class.getField(setting.getKey()); Object value = field.get(settings); + LOG.debug("{} {} {}", setting.getName(), value, setting.getProperty().getValue()); if (StringUtil.isNotBlank(value.toString())) { if (setting.getConverter() != null) { comboBox.getSelectionModel().select(setting.getConverter().convertTo(value)); diff --git a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java index 2c2605af..846a87c9 100644 --- a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java +++ b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java @@ -9,6 +9,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -117,6 +118,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { private SimpleLongProperty leaveSpaceOnDevice; private SimpleStringProperty ffmpegParameters; private SimpleBooleanProperty logFFmpegOutput; + private SimpleBooleanProperty loghlsdlOutput; private SimpleStringProperty fileExtension; private SimpleStringProperty server; private SimpleIntegerProperty port; @@ -126,6 +128,8 @@ public class SettingsTab extends Tab implements TabSelectionListener { private SimpleBooleanProperty totalModelCountInTitle; private SimpleBooleanProperty transportLayerSecurity; private SimpleBooleanProperty fastScrollSpeed; + private SimpleBooleanProperty useHlsdl; + private SimpleFileProperty hlsdlExecutable; private ExclusiveSelectionProperty recordLocal; private SimpleIntegerProperty postProcessingThreads; private IgnoreList ignoreList; @@ -170,6 +174,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { leaveSpaceOnDevice = new SimpleLongProperty(null, "minimumSpaceLeftInBytes", (long) new GigabytesConverter().convertTo(settings.minimumSpaceLeftInBytes)); ffmpegParameters = new SimpleStringProperty(null, "ffmpegMergedDownloadArgs", settings.ffmpegMergedDownloadArgs); logFFmpegOutput = new SimpleBooleanProperty(null, "logFFmpegOutput", settings.logFFmpegOutput); + loghlsdlOutput = new SimpleBooleanProperty(null, "loghlsdlOutput", settings.loghlsdlOutput); fileExtension = new SimpleStringProperty(null, "ffmpegFileSuffix", settings.ffmpegFileSuffix); server = new SimpleStringProperty(null, "httpServer", settings.httpServer); port = new SimpleIntegerProperty(null, "httpPort", settings.httpPort); @@ -184,6 +189,8 @@ public class SettingsTab extends Tab implements TabSelectionListener { onlineCheckSkipsPausedModels = new SimpleBooleanProperty(null, "onlineCheckSkipsPausedModels", settings.onlineCheckSkipsPausedModels); fastScrollSpeed = new SimpleBooleanProperty(null, "fastScrollSpeed", settings.fastScrollSpeed); confirmationDialogs = new SimpleBooleanProperty(null, "confirmationForDangerousActions", settings.confirmationForDangerousActions); + useHlsdl = new SimpleBooleanProperty(null, "useHlsdl", settings.useHlsdl); + hlsdlExecutable = new SimpleFileProperty(null, "hlsdlExecutable", settings.hlsdlExecutable); } private void createGui() { @@ -269,6 +276,11 @@ public class SettingsTab extends Tab implements TabSelectionListener { Category.of("Advanced / Devtools", Group.of("Logging", Setting.of("Log FFmpeg output", logFFmpegOutput, "Log FFmpeg output to files in the system's temp directory") + ), + Group.of("hlsdl (experimental)", + Setting.of("Use hlsdl (if possible)", useHlsdl, "Use hlsdl to record the live streams. Some features might not work correctly."), + Setting.of("hlsdl executable", hlsdlExecutable, "Path to the hlsdl executable"), + Setting.of("Log hlsdl output", loghlsdlOutput, "Log hlsdl output to files in the system's temp directory") ) ) ); @@ -320,13 +332,17 @@ public class SettingsTab extends Tab implements TabSelectionListener { 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)); + prefs.getSetting("hlsdlExecutable").ifPresent(s -> bindEnabledProperty(s, useHlsdl.not())); + prefs.getSetting("loghlsdlOutput").ifPresent(s -> bindEnabledProperty(s, useHlsdl.not())); postProcessingStepPanel.disableProperty().bind(recordLocal.not()); variablesHelpButton.disableProperty().bind(recordLocal); } private void splitValuesChanged(ObservableValue value, Object oldV, Object newV) { boolean splitAfterSet = settings.splitRecordingsAfterSecs > 0; + LOG.debug("after {}", settings.splitRecordingsAfterSecs); boolean splitBiggerThanSet = settings.splitRecordingsBiggerThanBytes > 0; + LOG.debug("bigger {}", settings.splitRecordingsBiggerThanBytes); if (splitAfterSet && splitBiggerThanSet) { settings.splitStrategy = TIME_OR_SIZE; } else if (splitAfterSet) { @@ -336,6 +352,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { } else { settings.splitStrategy = DONT; } + LOG.debug("strats {}", settings.splitStrategy); saveConfig(); } @@ -422,11 +439,13 @@ public class SettingsTab extends Tab implements TabSelectionListener { } public void saveConfig() { - try { - Config.getInstance().save(); - } catch (IOException e) { - LOG.error("Couldn't save config", e); - } + CompletableFuture.runAsync(() -> { + try { + Config.getInstance().save(); + } catch (IOException e) { + LOG.error("Couldn't save config", e); + } + }); } @Override diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java index 99555d6f..eea5a1b3 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java @@ -1,13 +1,58 @@ package ctbrec.ui.tabs; +import static ctbrec.Recording.State.*; +import static ctbrec.ui.UnicodeEmoji.*; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import ctbrec.Config; import ctbrec.Model; import ctbrec.Recording; import ctbrec.StringUtil; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; -import ctbrec.ui.*; -import ctbrec.ui.action.*; +import ctbrec.ui.AutosizeAlert; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.PreviewPopupHandler; +import ctbrec.ui.StreamSourceSelectionDialog; +import ctbrec.ui.action.CheckModelAccountAction; +import ctbrec.ui.action.EditNotesAction; +import ctbrec.ui.action.FollowAction; +import ctbrec.ui.action.IgnoreModelsAction; +import ctbrec.ui.action.OpenRecordingsDir; +import ctbrec.ui.action.PauseAction; +import ctbrec.ui.action.PlayAction; +import ctbrec.ui.action.RemoveTimeLimitAction; +import ctbrec.ui.action.ResumeAction; +import ctbrec.ui.action.SetStopDateAction; +import ctbrec.ui.action.StartRecordingAction; +import ctbrec.ui.action.StopRecordingAction; +import ctbrec.ui.action.ToggleRecordingAction; import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; import ctbrec.ui.controls.DateTimeCellFactory; import ctbrec.ui.controls.Dialogs; @@ -26,13 +71,33 @@ import javafx.concurrent.WorkerStateEvent; import javafx.event.ActionEvent; import javafx.geometry.Insets; import javafx.geometry.Pos; -import javafx.scene.control.*; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.Tab; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; import javafx.scene.control.TableColumn.CellEditEvent; import javafx.scene.control.TableColumn.SortType; +import javafx.scene.control.TableRow; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.Tooltip; import javafx.scene.control.cell.CheckBoxTableCell; import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.control.cell.TextFieldTableCell; -import javafx.scene.input.*; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.FlowPane; import javafx.scene.layout.HBox; @@ -41,30 +106,6 @@ import javafx.util.Callback; import javafx.util.Duration; import javafx.util.StringConverter; import javafx.util.converter.NumberStringConverter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.*; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.stream.Collectors; - -import static ctbrec.Recording.State.RECORDING; -import static ctbrec.ui.UnicodeEmoji.CLOCK; -import static ctbrec.ui.UnicodeEmoji.HEAVY_CHECK_MARK; public class RecordedModelsTab extends Tab implements TabSelectionListener { private static final Logger LOG = LoggerFactory.getLogger(RecordedModelsTab.class); @@ -409,14 +450,16 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { private void pauseAll(ActionEvent evt) { boolean yes = Dialogs.showConfirmDialog("Pause all models", "", "Pause the recording of all models?", getTabPane().getScene()); if (yes) { - new PauseAction(getTabPane(), recorder.getModels(), recorder).execute(); + List models = recorder.getModels().stream().filter(Predicate.not(Model::isMarkedForLaterRecording)).collect(Collectors.toList()); + new PauseAction(getTabPane(), models, recorder).execute(); } } private void resumeAll(ActionEvent evt) { boolean yes = Dialogs.showConfirmDialog("Resume all models", "", "Resume the recording of all models?", getTabPane().getScene()); if (yes) { - new ResumeAction(getTabPane(), recorder.getModels(), recorder).execute(); + List models = recorder.getModels().stream().filter(Predicate.not(Model::isMarkedForLaterRecording)).collect(Collectors.toList()); + new ResumeAction(getTabPane(), models, recorder).execute(); } } diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java index 8a76eea0..25a36772 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java @@ -31,7 +31,7 @@ import ctbrec.ui.SiteUiFactory; import ctbrec.ui.StreamSourceSelectionDialog; import ctbrec.ui.action.PlayAction; import ctbrec.ui.controls.Dialogs; -import ctbrec.ui.controls.PausedIndicator; +import ctbrec.ui.controls.RecordingIndicator; import ctbrec.ui.controls.StreamPreview; import javafx.animation.FadeTransition; import javafx.animation.FillTransition; @@ -51,6 +51,7 @@ import javafx.scene.control.ContextMenu; import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; @@ -72,6 +73,11 @@ public class ThumbCell extends StackPane { private static final Logger LOG = LoggerFactory.getLogger(ThumbCell.class); private static final Duration ANIMATION_DURATION = new Duration(250); + private static Image imgRecordIndicator = new Image(ThumbCell.class.getResource("/media-record-16.png").toExternalForm()); + private static Image imgPauseIndicator = new Image(ThumbCell.class.getResource("/media-playback-pause-16.png").toExternalForm()); + private static Image imgBookmarkIndicator = new Image(ThumbCell.class.getResource("/bookmark-new-16.png").toExternalForm()); + + private ModelRecordingState modelRecordingState = ModelRecordingState.NOT; private Model model; private StreamPreview streamPreview; private ImageView iv; @@ -85,8 +91,8 @@ public class ThumbCell extends StackPane { private Text topic; private Text resolutionTag; private Recorder recorder; - private Circle recordingIndicator; - private PausedIndicator pausedIndicator; + private RecordingIndicator recordingIndicator; + private Tooltip recordingIndicatorTooltip; private StackPane previewTrigger; private int index = 0; ContextMenu popup; @@ -178,22 +184,15 @@ public class ThumbCell extends StackPane { StackPane.setMargin(resolutionTag, new Insets(2, 4, 2, 2)); getChildren().add(resolutionTag); - recordingIndicator = new Circle(8); - recordingIndicator.setFill(colorRecording); + recordingIndicator = new RecordingIndicator(16); recordingIndicator.setCursor(Cursor.HAND); - recordingIndicator.setOnMouseClicked(e -> pauseResumeAction(true)); - Tooltip tooltip = new Tooltip("Pause Recording"); - Tooltip.install(recordingIndicator, tooltip); + recordingIndicator.setOnMouseClicked(this::recordingInidicatorClicked); + recordingIndicatorTooltip = new Tooltip("Pause Recording"); + Tooltip.install(recordingIndicator, recordingIndicatorTooltip); StackPane.setMargin(recordingIndicator, new Insets(3)); StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT); getChildren().add(recordingIndicator); - pausedIndicator = new PausedIndicator(16, colorRecording); - pausedIndicator.setOnMouseClicked(e -> pauseResumeAction(false)); - StackPane.setMargin(pausedIndicator, new Insets(3)); - StackPane.setAlignment(pausedIndicator, Pos.TOP_LEFT); - getChildren().add(pausedIndicator); - if (Config.getInstance().getSettings().livePreviews) { getChildren().add(createPreviewTrigger()); } @@ -231,6 +230,21 @@ public class ThumbCell extends StackPane { update(); } + private void recordingInidicatorClicked(MouseEvent evt) { + switch(modelRecordingState) { + case RECORDING: + pauseResumeAction(true); + break; + case PAUSED: + pauseResumeAction(false); + break; + case BOOKMARKED: + recordLater(false); + break; + default: + } + } + private Node createPreviewTrigger() { int s = 32; previewTrigger = new StackPane(); @@ -293,7 +307,6 @@ public class ThumbCell extends StackPane { streamPreview.stop(); } recordingIndicator.setVisible(!visible); - pausedIndicator.setVisible(!visible); if (!visible) { updateRecordingIndicator(); } @@ -445,17 +458,32 @@ public class ThumbCell extends StackPane { c = mouseHovering ? colorHighlight : colorNormal; } nameBackground.setFill(c); - updateRecordingIndicator(); } private void updateRecordingIndicator() { if (recording) { - recordingIndicator.setVisible(!model.isSuspended()); - pausedIndicator.setVisible(model.isSuspended()); + recordingIndicator.setVisible(true); + if (model.isSuspended()) { + modelRecordingState = ModelRecordingState.PAUSED; + recordingIndicator.setImage(imgPauseIndicator); + recordingIndicatorTooltip.setText("Resume Recording"); + } else { + modelRecordingState = ModelRecordingState.RECORDING; + recordingIndicator.setImage(imgRecordIndicator); + recordingIndicatorTooltip.setText("Pause Recording"); + } } else { - recordingIndicator.setVisible(false); - pausedIndicator.setVisible(false); + if (model.isMarkedForLaterRecording()) { + recordingIndicator.setVisible(true); + modelRecordingState = ModelRecordingState.BOOKMARKED; + recordingIndicator.setImage(imgBookmarkIndicator); + recordingIndicatorTooltip.setText("Forget Model"); + } else { + recordingIndicator.setVisible(false); + modelRecordingState = ModelRecordingState.NOT; + recordingIndicator.setImage(null); + } } } @@ -562,9 +590,9 @@ public class ThumbCell extends StackPane { }); } - void recordLater() { - model.setMarkedForLaterRecording(true); - startStopAction(true); + void recordLater(boolean recordLater) { + model.setMarkedForLaterRecording(recordLater); + startStopAction(recordLater); } public Model getModel() { @@ -577,7 +605,6 @@ public class ThumbCell extends StackPane { this.model.setPreview(model.getPreview()); this.model.setTags(model.getTags()); this.model.setUrl(model.getUrl()); - this.model.setSuspended(recorder.isSuspended(model)); update(); } @@ -591,9 +618,11 @@ public class ThumbCell extends StackPane { private void update() { model.setSuspended(recorder.isSuspended(model)); + model.setMarkedForLaterRecording(recorder.isMarkedForLaterRecording(model)); setRecording(recorder.isTracked(model)); + updateRecordingIndicator(); setImage(model.getPreview()); - String txt = recording ? " " : ""; + String txt = (modelRecordingState != ModelRecordingState.NOT) ? " " : ""; txt += model.getDescription() != null ? model.getDescription() : ""; topic.setText(txt); @@ -693,4 +722,11 @@ public class ThumbCell extends StackPane { model.setMarkedForLaterRecording(false); startStopAction(true); } + + private enum ModelRecordingState { + RECORDING, + PAUSED, + BOOKMARKED, + NOT + } } diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java index ae0a1b9b..25bd867d 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java @@ -1,5 +1,31 @@ package ctbrec.ui.tabs; +import static ctbrec.ui.controls.Dialogs.*; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import ctbrec.Config; import ctbrec.Model; import ctbrec.event.EventBusHolder; @@ -7,12 +33,24 @@ import ctbrec.recorder.Recorder; import ctbrec.sites.Site; import ctbrec.sites.mfc.MyFreeCamsClient; import ctbrec.sites.mfc.MyFreeCamsModel; -import ctbrec.ui.*; +import ctbrec.ui.AutosizeAlert; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.TipDialog; +import ctbrec.ui.TokenLabel; import ctbrec.ui.action.IgnoreModelsAction; import ctbrec.ui.action.OpenRecordingsDir; import ctbrec.ui.action.SetStopDateAction; -import ctbrec.ui.controls.*; -import javafx.animation.*; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.FasterVerticalScrollPaneSkin; +import ctbrec.ui.controls.SearchBox; +import ctbrec.ui.controls.SearchPopover; +import ctbrec.ui.controls.SearchPopoverTreeList; +import javafx.animation.FadeTransition; +import javafx.animation.Interpolator; +import javafx.animation.ParallelTransition; +import javafx.animation.ScaleTransition; +import javafx.animation.TranslateTransition; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; @@ -28,24 +66,34 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.Parent; -import javafx.scene.control.*; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; import javafx.scene.image.ImageView; -import javafx.scene.input.*; -import javafx.scene.layout.*; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; import javafx.scene.transform.Transform; import javafx.util.Duration; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.net.SocketTimeoutException; -import java.text.DecimalFormat; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; - -import static ctbrec.ui.controls.Dialogs.showError; public class ThumbOverviewTab extends Tab implements TabSelectionListener { private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTab.class); @@ -441,7 +489,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { MenuItem addPaused = new MenuItem("Add in paused state"); addPaused.setOnAction(e -> addPaused(getSelectedThumbCells(cell))); MenuItem recordLater = new MenuItem("Record Later"); - recordLater.setOnAction(e -> recordLater(getSelectedThumbCells(cell))); + recordLater.setOnAction(e -> recordLater(getSelectedThumbCells(cell), true)); + MenuItem removeRecordLater = new MenuItem("Forget Model"); + removeRecordLater.setOnAction(e -> recordLater(getSelectedThumbCells(cell), false)); + MenuItem addRemoveBookmark = recorder.isMarkedForLaterRecording(model) ? removeRecordLater : recordLater; MenuItem pause = new MenuItem("Pause Recording"); pause.setOnAction(e -> pauseResumeAction(getSelectedThumbCells(cell), true)); @@ -476,10 +527,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { if (modelIsTrackedByRecorder) { contextMenu.getItems().addAll(pauseResume, recordLater); } else { - contextMenu.getItems().addAll(recordUntil, addPaused); - if (!recorder.isMarkedForLaterRecording(model)) { - contextMenu.getItems().add(recordLater); - } + contextMenu.getItems().addAll(recordUntil, addPaused, addRemoveBookmark); } contextMenu.getItems().add(new SeparatorMenuItem()); if (site.supportsFollow()) { @@ -500,9 +548,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { return contextMenu; } - private void recordLater(List list) { + private void recordLater(List list, boolean recordLater) { for (ThumbCell cell : list) { - cell.recordLater(); + cell.recordLater(recordLater); } } diff --git a/client/src/main/resources/bookmark-new-16.png b/client/src/main/resources/bookmark-new-16.png new file mode 100644 index 00000000..d50f515a Binary files /dev/null and b/client/src/main/resources/bookmark-new-16.png differ diff --git a/client/src/main/resources/bookmark-new.png b/client/src/main/resources/bookmark-new.png new file mode 100644 index 00000000..90b9daad Binary files /dev/null and b/client/src/main/resources/bookmark-new.png differ diff --git a/client/src/main/resources/html/docs/ConfigurationFile.md b/client/src/main/resources/html/docs/ConfigurationFile.md index f35fa26f..a864b781 100644 --- a/client/src/main/resources/html/docs/ConfigurationFile.md +++ b/client/src/main/resources/html/docs/ConfigurationFile.md @@ -28,6 +28,8 @@ until a recording is finished. 0 means unlimited. - **generatePlaylist** (server only) - [`true`,`false`] Generate a playlist once a recording terminates. +- **hlsdlExecutable** - Path to the hlsdl executable, which is used, if `useHlsdl` is set to true + - **httpPort** - [1 - 65536] The TCP port, the server listens on. In the server config, this will tell the server, which port to use. In the application this will set the port ctbrec tries to connect to, if it is run in remote mode. @@ -43,7 +45,9 @@ the port ctbrec tries to connect to, if it is run in remote mode. - **livePreviews** (app only) - Enables the live preview feature in the app. -- **logFFmpegOutput** - The output from FFmpeg (from recordings or post-processing steps) will be logged in temporary files. +- **logFFmpegOutput** - [`true`,`false`] The output from FFmpeg (from recordings or post-processing steps) will be logged in temporary files. + +- **loghlsdlOutput** - [`true`,`false`] The output from hlsdl will be logged in temporary files. Only in effect, if `useHlsdl` is set to true - **minimumResolution** - [1 - 2147483647]. Sets the minimum video height for a recording. ctbrec tries to find a stream quality, which is higher than or equal to this value. If the only provided stream quality is below this threshold, ctbrec won't record the stream. @@ -73,5 +77,7 @@ which have the defined length (roughly). Has to be activated with `splitStrategy - **splitRecordingsBiggerThanBytes** - [0 - 9223372036854775807] in bytes. Split recordings, if the size on disk exceeds this value. The recordings are split up into several individual recordings, which have the defined size (roughly). Has to be activated with `splitStrategy`. +- **useHlsdl** - [`true`,`false`] Use hlsdl to record the live streams. You also have to set `hlsdlExecutable`, if hlsdl is not globally available on your system. hlsdl won't be used for MV Live, LiveJasmin and Showup. + - **webinterface** (server only) - [`true`,`false`] Enables the webinterface for the server. You can access it with http://host:port/static/index.html Don't activate this on a machine, which can be accessed from the internet, because this is totally unprotected at the moment. diff --git a/client/src/main/resources/media-playback-pause-16.png b/client/src/main/resources/media-playback-pause-16.png new file mode 100644 index 00000000..91c5549f Binary files /dev/null and b/client/src/main/resources/media-playback-pause-16.png differ diff --git a/client/src/main/resources/media-playback-pause.png b/client/src/main/resources/media-playback-pause.png new file mode 100644 index 00000000..9d486a08 Binary files /dev/null and b/client/src/main/resources/media-playback-pause.png differ diff --git a/client/src/main/resources/media-record-16.png b/client/src/main/resources/media-record-16.png new file mode 100644 index 00000000..aec3241b Binary files /dev/null and b/client/src/main/resources/media-record-16.png differ diff --git a/client/src/main/resources/media-record.png b/client/src/main/resources/media-record.png new file mode 100644 index 00000000..b4a507cb Binary files /dev/null and b/client/src/main/resources/media-record.png differ diff --git a/common/src/main/java/ctbrec/AbstractModel.java b/common/src/main/java/ctbrec/AbstractModel.java index b0ab9814..216f4c72 100644 --- a/common/src/main/java/ctbrec/AbstractModel.java +++ b/common/src/main/java/ctbrec/AbstractModel.java @@ -17,6 +17,7 @@ import ctbrec.recorder.download.Download; import ctbrec.recorder.download.HttpHeaderFactory; import ctbrec.recorder.download.HttpHeaderFactoryImpl; import ctbrec.recorder.download.hls.HlsDownload; +import ctbrec.recorder.download.hls.HlsdlDownload; import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; import ctbrec.sites.Site; import okhttp3.Request; @@ -275,10 +276,14 @@ public abstract class AbstractModel implements Model { @Override public Download createDownload() { - if (Config.isServerMode() && !Config.getInstance().getSettings().recordSingleFile) { - return new HlsDownload(getSite().getHttpClient()); + if (Config.getInstance().getSettings().useHlsdl) { + return new HlsdlDownload(); } else { - return new MergedFfmpegHlsDownload(getSite().getHttpClient()); + if (Config.isServerMode() && !Config.getInstance().getSettings().recordSingleFile) { + return new HlsDownload(getSite().getHttpClient()); + } else { + return new MergedFfmpegHlsDownload(getSite().getHttpClient()); + } } } diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java index 793c4cb0..76aba0c9 100644 --- a/common/src/main/java/ctbrec/Config.java +++ b/common/src/main/java/ctbrec/Config.java @@ -213,7 +213,7 @@ public class Config { return settings; } - public void save() throws IOException { + public synchronized void save() throws IOException { Moshi moshi = new Moshi.Builder() .add(Model.class, new ModelJsonAdapter()) .add(PostProcessor.class, new PostProcessorJsonAdapter()) diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 73ed55bb..70e59939 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -69,6 +69,7 @@ public class Settings { public String flirt4freePassword; public String flirt4freeUsername; public boolean generatePlaylist = true; + public String hlsdlExecutable = "hlsdl"; public int httpPort = 8080; public int httpSecurePort = 8443; public String httpServer = "localhost"; @@ -84,6 +85,7 @@ public class Settings { public boolean livePreviews = false; public boolean localRecording = true; public boolean logFFmpegOutput = false; + public boolean loghlsdlOutput = false; public int minimumResolution = 0; public int maximumResolution = 8640; public int maximumResolutionPlayer = 0; @@ -156,6 +158,7 @@ public class Settings { public boolean transportLayerSecurity = true; public int thumbWidth = 180; public boolean updateThumbnails = true; + public boolean useHlsdl = false; @Deprecated public String username = ""; public int windowHeight = 800; diff --git a/common/src/main/java/ctbrec/io/FfmpegStreamRedirector.java b/common/src/main/java/ctbrec/io/ProcessStreamRedirector.java similarity index 75% rename from common/src/main/java/ctbrec/io/FfmpegStreamRedirector.java rename to common/src/main/java/ctbrec/io/ProcessStreamRedirector.java index 2be0e63f..2b68b8c8 100644 --- a/common/src/main/java/ctbrec/io/FfmpegStreamRedirector.java +++ b/common/src/main/java/ctbrec/io/ProcessStreamRedirector.java @@ -9,15 +9,15 @@ import java.util.concurrent.ScheduledExecutorService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class FfmpegStreamRedirector implements Runnable { - private static final Logger LOG = LoggerFactory.getLogger(FfmpegStreamRedirector.class); +public class ProcessStreamRedirector implements Runnable { + private static final Logger LOG = LoggerFactory.getLogger(ProcessStreamRedirector.class); private InputStream in; private OutputStream out; private boolean keepGoing = true; private ScheduledExecutorService executor; - public FfmpegStreamRedirector(ScheduledExecutorService executor, InputStream in, OutputStream out) { + public ProcessStreamRedirector(ScheduledExecutorService executor, InputStream in, OutputStream out) { super(); this.executor = executor; this.in = in; @@ -37,7 +37,7 @@ public class FfmpegStreamRedirector implements Runnable { executor.schedule(this, 100, MILLISECONDS); } } catch (Exception e) { - LOG.debug("Error while reading from FFmpeg output stream: {}", e.getLocalizedMessage()); + LOG.debug("Error while reading from process output stream: {}", e.getLocalizedMessage()); keepGoing = false; } } diff --git a/common/src/main/java/ctbrec/recorder/FFmpeg.java b/common/src/main/java/ctbrec/recorder/FFmpeg.java index 77eeea6c..1c7d80a8 100644 --- a/common/src/main/java/ctbrec/recorder/FFmpeg.java +++ b/common/src/main/java/ctbrec/recorder/FFmpeg.java @@ -15,7 +15,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.io.DevNull; -import ctbrec.io.FfmpegStreamRedirector; +import ctbrec.io.ProcessStreamRedirector; import ctbrec.recorder.download.ProcessExitedUncleanException; public class FFmpeg { @@ -30,8 +30,8 @@ public class FFmpeg { private Consumer exitCallback; private File ffmpegLog = null; private OutputStream ffmpegLogStream; - private FfmpegStreamRedirector stdoutRedirector; - private FfmpegStreamRedirector stderrRedirector; + private ProcessStreamRedirector stdoutRedirector; + private ProcessStreamRedirector stderrRedirector; private FFmpeg() {} @@ -45,7 +45,7 @@ public class FFmpeg { }; } - public void exec(String[] cmdline, String[] env, File executionDir) throws IOException, InterruptedException { + public void exec(String[] cmdline, String[] env, File executionDir) throws IOException { LOG.debug("FFmpeg command line: {}", Arrays.toString(cmdline)); process = Runtime.getRuntime().exec(cmdline, env, executionDir); afterStart(); @@ -83,8 +83,8 @@ public class FFmpeg { } else { ffmpegLogStream = new DevNull(); } - stdoutRedirector = new FfmpegStreamRedirector(processOutputReader, process.getInputStream(), ffmpegLogStream); - stderrRedirector = new FfmpegStreamRedirector(processOutputReader, process.getErrorStream(), ffmpegLogStream); + stdoutRedirector = new ProcessStreamRedirector(processOutputReader, process.getInputStream(), ffmpegLogStream); + stderrRedirector = new ProcessStreamRedirector(processOutputReader, process.getErrorStream(), ffmpegLogStream); processOutputReader.submit(stdoutRedirector); processOutputReader.submit(stderrRedirector); } diff --git a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java index 78521347..ce0bdd3a 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java @@ -143,9 +143,9 @@ public abstract class AbstractHlsDownload extends AbstractDownload { segmentDownloadFinished(result.get()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - e.printStackTrace(); + LOG.error("Error in segmentDownloadFinished", e); } catch (ExecutionException e) { - e.printStackTrace(); + LOG.error("Error in segmentDownloadFinished", e); } }); } @@ -173,7 +173,7 @@ public abstract class AbstractHlsDownload extends AbstractDownload { running = false; } } catch (ExecutionException e1) { - modelState = ctbrec.Model.State.UNKNOWN; + modelState = State.UNKNOWN; } LOG.info(errorMsg, model, modelState); waitSomeTime(TEN_SECONDS); diff --git a/common/src/main/java/ctbrec/recorder/download/hls/Hlsdl.java b/common/src/main/java/ctbrec/recorder/download/hls/Hlsdl.java new file mode 100644 index 00000000..d4abfc61 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/hls/Hlsdl.java @@ -0,0 +1,154 @@ +package ctbrec.recorder.download.hls; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.function.Consumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.io.DevNull; +import ctbrec.io.ProcessStreamRedirector; +import ctbrec.recorder.download.ProcessExitedUncleanException; + +public class Hlsdl { + + private static final Logger LOG = LoggerFactory.getLogger(Hlsdl.class); + + private static ScheduledExecutorService processOutputReader = Executors.newScheduledThreadPool(2, createThreadFactory("hlsdl output stream reader")); + + private Process process; + private boolean logOutput = false; + private Consumer startCallback; + private Consumer exitCallback; + private File processLog = null; + private OutputStream processLogStream; + private ProcessStreamRedirector stdoutRedirector; + private ProcessStreamRedirector stderrRedirector; + + private Hlsdl() {} + + private static ThreadFactory createThreadFactory(String name) { + return r -> { + Thread t = new Thread(r); + t.setName(name); + t.setDaemon(true); + t.setPriority(Thread.MIN_PRIORITY); + return t; + }; + } + + public void exec(String[] cmdline, String[] env, File executionDir) throws IOException { + LOG.debug("hlsdl command line: {}", Arrays.toString(cmdline)); + process = Runtime.getRuntime().exec(cmdline, env, executionDir); + afterStart(); + } + + private void afterStart() throws IOException { + notifyStartCallback(process); + setupLogging(); + } + + public void shutdown(int exitCode) throws IOException { + LOG.debug("hlsdl exit code was {}", exitCode); + processLogStream.flush(); + processLogStream.close(); + stdoutRedirector.setKeepGoing(false); + stderrRedirector.setKeepGoing(false); + notifyExitCallback(exitCode); + if (exitCode != 1) { + if (processLog != null && processLog.exists()) { + Files.delete(processLog.toPath()); + } + } else { + throw new ProcessExitedUncleanException("hlsdl exit code was " + exitCode); + } + } + + private void setupLogging() throws IOException { + if (logOutput) { + if (processLog == null) { + processLog = File.createTempFile("hlsdl_", ".log"); + } + LOG.debug("Logging hlsdl output to {}", processLog); + processLog.deleteOnExit(); + processLogStream = new FileOutputStream(processLog); + } else { + processLogStream = new DevNull(); + } + stdoutRedirector = new ProcessStreamRedirector(processOutputReader, process.getInputStream(), processLogStream); + stderrRedirector = new ProcessStreamRedirector(processOutputReader, process.getErrorStream(), processLogStream); + processOutputReader.submit(stdoutRedirector); + processOutputReader.submit(stderrRedirector); + } + + private void notifyStartCallback(Process process) { + try { + startCallback.accept(process); + } catch(Exception e) { + LOG.error("Exception in onStart callback", e); + } + } + + private void notifyExitCallback(int exitCode) { + try { + exitCallback.accept(exitCode); + } catch(Exception e) { + LOG.error("Exception in onExit callback", e); + } + } + + public int waitFor() throws InterruptedException { + int exitCode = process.waitFor(); + try { + shutdown(exitCode); + } catch (IOException e) { + LOG.error("Error while shutting down hlsdl process", e); + } + return exitCode; + } + + public static class Builder { + private boolean logOutput = false; + private File logFile; + private Consumer startCallback; + private Consumer exitCallback; + + public Builder logOutput(boolean logOutput) { + this.logOutput = logOutput; + return this; + } + + public Builder logFile(File logFile) { + this.logFile = logFile; + return this; + } + + public Builder onStarted(Consumer callback) { + this.startCallback = callback; + return this; + } + + public Builder onExit(Consumer callback) { + this.exitCallback = callback; + return this; + } + + public Hlsdl build() { + Hlsdl instance = new Hlsdl(); + instance.logOutput = logOutput; + instance.startCallback = startCallback != null ? startCallback : p -> {}; + instance.exitCallback = exitCallback != null ? exitCallback : exitCode -> {}; + instance.processLog = logFile; + return instance; + } + } + +} diff --git a/common/src/main/java/ctbrec/recorder/download/hls/HlsdlDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/HlsdlDownload.java new file mode 100644 index 00000000..51d002aa --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/hls/HlsdlDownload.java @@ -0,0 +1,205 @@ +package ctbrec.recorder.download.hls; + +import static ctbrec.recorder.download.StreamSource.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.xml.bind.JAXBException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.OS; +import ctbrec.Recording; +import ctbrec.recorder.download.AbstractDownload; +import ctbrec.recorder.download.Download; +import ctbrec.recorder.download.ProcessExitedUncleanException; +import ctbrec.recorder.download.StreamSource; + +public class HlsdlDownload extends AbstractDownload { + + private static final transient Logger LOG = LoggerFactory.getLogger(HlsdlDownload.class); + + protected File targetFile; + + private transient Hlsdl hlsdl; + protected transient Process hlsdlProcess; + protected transient boolean running = true; + + @Override + public void init(Config config, Model model, Instant startTime, ExecutorService executorService) throws IOException { + super.init(config, model, startTime, executorService); + String fileSuffix = config.getSettings().ffmpegFileSuffix; + targetFile = config.getFileForRecording(model, fileSuffix, startTime); + + createTargetDirectory(); + startHlsdlProcess(); + if (hlsdlProcess == null) { + throw new ProcessExitedUncleanException("Couldn't spawn FFmpeg"); + } + } + + protected void createTargetDirectory() throws IOException { + Files.createDirectories(targetFile.getParentFile().toPath()); + } + + @Override + public Download call() throws Exception { + try { + if (running && !hlsdlProcess.isAlive()) { + running = false; + int exitValue = hlsdlProcess.exitValue(); + hlsdl.shutdown(exitValue); + } + + rescheduleTime = Instant.now().plusSeconds(1); + + if (splittingStrategy.splitNecessary(this)) { + stop(); + } + } catch (ProcessExitedUncleanException e) { + LOG.error("hlsdl exited unclean", e); + } + return this; + } + + private void startHlsdlProcess() { + try { + String[] cmdline = prepareCommandLine(); + hlsdl = new Hlsdl.Builder() + .logOutput(config.getSettings().logFFmpegOutput) + .onStarted(p -> hlsdlProcess = p) + .build(); + hlsdl.exec(cmdline, OS.getEnvironment(), targetFile.getParentFile()); + } catch (Exception e) { + LOG.error("Error starting hlsdl", e); + } + } + + private String[] prepareCommandLine() throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException { + String playlistUrl = getSegmentPlaylistUrl(model); + Map headers = model.getHttpHeaderFactory().createSegmentPlaylistHeaders(); + String[] cmdline = new String[9 + headers.size() * 2]; + int idx = 0; + cmdline[idx++] = config.getSettings().hlsdlExecutable; + cmdline[idx++] = "-c"; + cmdline[idx++] = "-r"; + cmdline[idx++] = "3"; + cmdline[idx++] = "-w"; + cmdline[idx++] = "3"; + for (Entry header : headers.entrySet()) { + cmdline[idx++] = "-h"; + cmdline[idx++] = header.getKey() + ": " + header.getValue(); + } + cmdline[idx++] = "-o"; + cmdline[idx++] = targetFile.getCanonicalPath(); + cmdline[idx] = playlistUrl; + return cmdline; + } + + protected String getSegmentPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException { + LOG.debug("{} stream idx: {}", model.getName(), model.getStreamUrlIndex()); + List streamSources = model.getStreamSources(); + Collections.sort(streamSources); + for (StreamSource streamSource : streamSources) { + LOG.debug("{} src {}", model.getName(), streamSource); + } + String url = null; + if (model.getStreamUrlIndex() >= 0 && model.getStreamUrlIndex() < streamSources.size()) { + // TODO don't use the index, but the bandwidth. if the bandwidth does not match, take the closest one + LOG.debug("{} selected {}", model.getName(), streamSources.get(model.getStreamUrlIndex())); + url = streamSources.get(model.getStreamUrlIndex()).getMediaPlaylistUrl(); + } else { + // filter out stream resolutions, which are out of range of the configured min and max + int minRes = Config.getInstance().getSettings().minimumResolution; + int maxRes = Config.getInstance().getSettings().maximumResolution; + List filteredStreamSources = streamSources.stream() + .filter(src -> src.height == 0 || src.height == UNKNOWN || minRes <= src.height) + .filter(src -> src.height == 0 || src.height == UNKNOWN || maxRes >= src.height) + .collect(Collectors.toList()); + + if (filteredStreamSources.isEmpty()) { + throw new ExecutionException(new RuntimeException("No stream left in playlist")); + } else { + LOG.debug("{} selected {}", model.getName(), filteredStreamSources.get(filteredStreamSources.size() - 1)); + url = filteredStreamSources.get(filteredStreamSources.size() - 1).getMediaPlaylistUrl(); + } + } + LOG.debug("Segment playlist url {}", url); + return url; + } + + @Override + public void stop() { + if (running) { + running = false; + if (hlsdlProcess != null) { + hlsdlProcess.destroy(); + if (hlsdlProcess.isAlive()) { + LOG.info("hlsdl didn't terminate. Destroying the process with force!"); + hlsdlProcess.destroyForcibly(); + hlsdlProcess = null; + } + } + } + } + + @Override + public void postprocess(Recording recording) { + // nothing to do + } + + @Override + public File getTarget() { + return targetFile; + } + + @Override + public String getPath(Model model) { + String absolutePath = targetFile.getAbsolutePath(); + String recordingsDir = Config.getInstance().getSettings().recordingsDir; + String relativePath = absolutePath.replaceFirst(Pattern.quote(recordingsDir), ""); + return relativePath; + } + + @Override + public boolean isSingleFile() { + return true; + } + + @Override + public long getSizeInByte() { + return getTarget().length(); + } + + @Override + public Model getModel() { + return model; + } + + @Override + public void finalizeDownload() { + // nothing to do + } + + @Override + public boolean isRunning() { + return running; + } +} diff --git a/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java index 109d82e6..fc01767d 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java @@ -127,11 +127,6 @@ public class MergedFfmpegHlsDownload extends AbstractHlsDownload { ffmpeg.exec(cmdline, new String[0], target.getParentFile()); } catch (IOException | ProcessExitedUncleanException e) { LOG.error("Error in FFmpeg thread", e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - if (running) { - LOG.info("Interrupted while waiting for ffmpeg", e); - } } } diff --git a/common/src/main/java/ctbrec/sites/fc2live/Fc2HlsdlDownload.java b/common/src/main/java/ctbrec/sites/fc2live/Fc2HlsdlDownload.java new file mode 100644 index 00000000..0b57166d --- /dev/null +++ b/common/src/main/java/ctbrec/sites/fc2live/Fc2HlsdlDownload.java @@ -0,0 +1,37 @@ +package ctbrec.sites.fc2live; + +import java.io.IOException; +import java.time.Instant; +import java.util.concurrent.ExecutorService; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.recorder.download.hls.HlsdlDownload; + +public class Fc2HlsdlDownload extends HlsdlDownload { + + private static final Logger LOG = LoggerFactory.getLogger(Fc2HlsdlDownload.class); + + + @Override + public void init(Config config, Model model, Instant startTime, ExecutorService executorService) throws IOException { + super.init(config, model, startTime, executorService); + Fc2Model fc2Model = (Fc2Model) model; + try { + fc2Model.openWebsocket(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.error("Couldn't start download for {}", model, e); + } + } + + @Override + public void finalizeDownload() { + Fc2Model fc2Model = (Fc2Model) model; + fc2Model.closeWebsocket(); + super.finalizeDownload(); + } +} diff --git a/common/src/main/java/ctbrec/sites/fc2live/Fc2Model.java b/common/src/main/java/ctbrec/sites/fc2live/Fc2Model.java index a686d0eb..71ec8825 100644 --- a/common/src/main/java/ctbrec/sites/fc2live/Fc2Model.java +++ b/common/src/main/java/ctbrec/sites/fc2live/Fc2Model.java @@ -381,10 +381,14 @@ public class Fc2Model extends AbstractModel { @Override public Download createDownload() { - if(Config.isServerMode() && !Config.getInstance().getSettings().recordSingleFile) { - return new Fc2HlsDownload(getSite().getHttpClient()); + if (Config.getInstance().getSettings().useHlsdl) { + return new Fc2HlsdlDownload(); } else { - return new Fc2MergedHlsDownload(getSite().getHttpClient()); + if (Config.isServerMode() && !Config.getInstance().getSettings().recordSingleFile) { + return new Fc2HlsDownload(getSite().getHttpClient()); + } else { + return new Fc2MergedHlsDownload(getSite().getHttpClient()); + } } }