diff --git a/CHANGELOG.md b/CHANGELOG.md index 9235ca88..773b637e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +1.16.0 +======================== +* Thumbnails can show a live preview. Can be switched on in the settings. +* Live preview is experimental for now, because I noticed some funky behavior + of the the internal media player. You can use it on your own risk. +* Added Streamate (metcams, xhamstercams, pornhublive) +* Maximum resolution can be an arbitrary value now +* Added setting for minimal recording length. Recordings, which are shorter + than this value, get deleted automatically. +* Double-click in Recording tab starts the player +* Fix: BongaCams friends tab not working +* Fix: BongaCams search fails with JSON exception +* Fix: In some cases MFC models got confused + 1.15.0 ======================== * Fix: BongaCams overview didn't work anymore diff --git a/client/pom.xml b/client/pom.xml index 9ea0e01e..435967b8 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -8,7 +8,7 @@ ctbrec master - 1.15.0 + 1.16.0 ../master diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index d698dbdb..b2913d98 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -38,6 +38,7 @@ import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.sites.streamate.Streamate; import ctbrec.ui.settings.SettingsTab; import javafx.application.Application; import javafx.application.HostServices; @@ -76,6 +77,7 @@ public class CamrecApplication extends Application { sites.add(new Camsoda()); sites.add(new Chaturbate()); sites.add(new MyFreeCams()); + sites.add(new Streamate()); loadConfig(); registerAlertSystem(); createHttpClient(); @@ -176,9 +178,13 @@ public class CamrecApplication extends Application { try { Config.getInstance().save(); LOG.info("Shutdown complete. Goodbye!"); - Platform.exit(); - // This is needed, because OkHttp?! seems to block the shutdown with its writer threads. They are not daemon threads :( - System.exit(0); + Platform.runLater(() -> { + primaryStage.close(); + shutdownInfo.close(); + Platform.exit(); + // This is needed, because OkHttp?! seems to block the shutdown with its writer threads. They are not daemon threads :( + System.exit(0); + }); } catch (IOException e1) { Platform.runLater(() -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); diff --git a/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java b/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java index e6ffc72a..16de2224 100644 --- a/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java +++ b/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java @@ -1,39 +1,23 @@ package ctbrec.ui; -import java.io.InterruptedIOException; -import java.util.Collections; -import java.util.List; import java.util.Objects; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import ctbrec.Config; -import ctbrec.io.HttpException; -import ctbrec.recorder.download.StreamSource; +import ctbrec.ui.controls.StreamPreview; import javafx.application.Platform; import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.geometry.Point2D; import javafx.scene.Node; -import javafx.scene.control.ProgressIndicator; import javafx.scene.control.TableColumn; import javafx.scene.control.TableRow; import javafx.scene.control.TableView; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; -import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; -import javafx.scene.media.Media; -import javafx.scene.media.MediaPlayer; -import javafx.scene.media.MediaView; import javafx.stage.Popup; public class PreviewPopupHandler implements EventHandler { @@ -44,53 +28,24 @@ public class PreviewPopupHandler implements EventHandler { private long timeForPopupClose = 400; private Popup popup = new Popup(); private Node parent; - private ImageView preview = new ImageView(); - private MediaView videoPreview; - private MediaPlayer videoPlayer; - private Media video; + private StreamPreview streamPreview; private JavaFxModel model; private volatile long openCountdown = -1; private volatile long closeCountdown = -1; private volatile long lastModelChange = -1; private volatile boolean changeModel = false; - private ExecutorService executor = Executors.newSingleThreadExecutor(); - private Future future; - private ProgressIndicator progressIndicator; - private StackPane pane; public PreviewPopupHandler(Node parent) { this.parent = parent; - videoPreview = new MediaView(); - videoPreview.setFitWidth(Config.getInstance().getSettings().thumbWidth); - videoPreview.setFitHeight(videoPreview.getFitWidth() * 9 / 16); - videoPreview.setPreserveRatio(true); - StackPane.setMargin(videoPreview, new Insets(5)); - - preview.setFitWidth(Config.getInstance().getSettings().thumbWidth); - preview.setPreserveRatio(true); - preview.setSmooth(true); - preview.setStyle("-fx-background-radius: 10px, 10px, 10px, 10px;"); - preview.visibleProperty().bind(videoPreview.visibleProperty().not()); - StackPane.setMargin(preview, new Insets(5)); - - progressIndicator = new ProgressIndicator(); - progressIndicator.setVisible(false); - progressIndicator.prefWidthProperty().bind(videoPreview.fitWidthProperty()); - - Region veil = new Region(); - veil.setStyle("-fx-background-color: rgba(0, 0, 0, 0.8)"); - veil.visibleProperty().bind(progressIndicator.visibleProperty()); - StackPane.setMargin(veil, new Insets(5)); - - pane = new StackPane(); - pane.getChildren().addAll(preview, videoPreview, veil, progressIndicator); - pane.setStyle("-fx-background-color: -fx-outer-border, -fx-inner-border, -fx-base;"+ + streamPreview = new StreamPreview(); + streamPreview.setStyle("-fx-background-color: -fx-outer-border, -fx-inner-border, -fx-base;"+ "-fx-background-insets: 0 0 -1 0, 0, 1, 2;" + "-fx-background-radius: 10px, 10px, 10px, 10px;" + "-fx-padding: 1;" + "-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.8), 20, 0, 0, 0);"); - popup.getContent().add(pane); + popup.getContent().add(streamPreview); + StackPane.setMargin(streamPreview, new Insets(5)); createTimerThread(); } @@ -121,8 +76,7 @@ public class PreviewPopupHandler implements EventHandler { if(modelChanged) { lastModelChange = System.currentTimeMillis(); changeModel = true; - future.cancel(true); - progressIndicator.setVisible(true); + streamPreview.stop(); } } else { openCountdown = timeForPopupOpen; @@ -173,121 +127,19 @@ public class PreviewPopupHandler implements EventHandler { } private void startStream(JavaFxModel model) { - if(future != null && !future.isDone()) { - future.cancel(true); - } - future = executor.submit(() -> { - try { - Platform.runLater(() -> { - progressIndicator.setVisible(true); - popup.show(parent.getScene().getWindow()); - }); - List sources = model.getStreamSources(); - Collections.sort(sources); - StreamSource best = sources.get(0); - checkInterrupt(); - LOG.debug("Preview url for {} is {}", model.getName(), best.getMediaPlaylistUrl()); - video = new Media(best.getMediaPlaylistUrl()); - if(videoPlayer != null) { - videoPlayer.dispose(); - } - videoPlayer = new MediaPlayer(video); - videoPlayer.setMute(true); - checkInterrupt(); - videoPlayer.setOnReady(() -> { - if(!future.isCancelled()) { - Platform.runLater(() -> { - double aspect = (double)video.getWidth() / video.getHeight(); - double w = Config.getInstance().getSettings().thumbWidth; - double h = w / aspect; - resize(w, h); - progressIndicator.setVisible(false); - videoPreview.setVisible(true); - videoPreview.setMediaPlayer(videoPlayer); - videoPlayer.play(); - }); - } - }); - videoPlayer.setOnError(() -> onError(videoPlayer)); - } catch (IllegalStateException e) { - if(e.getMessage().equals("Stream url unknown")) { - // fine hls url for mfc not known yet - } else { - LOG.warn("Couldn't start preview video: {}", e.getMessage()); - } - showTestImage(); - } catch (HttpException e) { - if(e.getResponseCode() != 404) { - LOG.warn("Couldn't start preview video: {}", e.getMessage()); - } - showTestImage(); - } catch (InterruptedException | InterruptedIOException e) { - // future has been canceled, that's fine - } catch (ExecutionException e) { - if(e.getCause() instanceof InterruptedException || e.getCause() instanceof InterruptedIOException) { - // future has been canceled, that's fine - } else { - LOG.warn("Couldn't start preview video: {}", e.getMessage()); - showTestImage(); - } - } catch (Exception e) { - LOG.warn("Couldn't start preview video: {}", e.getMessage()); - showTestImage(); - } - }); - } - - private void onError(MediaPlayer videoPlayer) { - LOG.error("Error while starting preview stream", videoPlayer.getError()); - if(videoPlayer.getError().getCause() != null) { - LOG.error("Error while starting preview stream root cause:", videoPlayer.getError().getCause()); - } - videoPlayer.dispose(); Platform.runLater(() -> { - showTestImage(); + streamPreview.startStream(model); + popup.show(parent.getScene().getWindow()); }); - } - private void resize(double w, double h) { - preview.setFitWidth(w); - preview.setFitHeight(h); - videoPreview.setFitWidth(w); - videoPreview.setFitHeight(h); - pane.setPrefSize(w, h); - popup.setWidth(w); - popup.setHeight(h); - } - - private void checkInterrupt() throws InterruptedException { - if(Thread.interrupted()) { - throw new InterruptedException(); - } - } - - private void showTestImage() { - Platform.runLater(() -> { - videoPreview.setVisible(false); - Image img = new Image(getClass().getResource("/image_not_found.png").toString(), true); - preview.setImage(img); - double aspect = img.getWidth() / img.getHeight(); - double w = Config.getInstance().getSettings().thumbWidth; - double h = w / aspect; - resize(w, h); - progressIndicator.setVisible(false); - }); } private void hidePopup() { - if(future != null && !future.isDone()) { - future.cancel(true); - } Platform.runLater(() -> { popup.setX(-1000); popup.setY(-1000); popup.hide(); - if(videoPlayer != null) { - videoPlayer.dispose(); - } + streamPreview.stop(); }); } diff --git a/client/src/main/java/ctbrec/ui/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/RecordedModelsTab.java index ae41af76..020512c5 100644 --- a/client/src/main/java/ctbrec/ui/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/RecordedModelsTab.java @@ -58,6 +58,7 @@ 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; @@ -117,6 +118,9 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { preview.setCellValueFactory(cdf -> new SimpleStringProperty(" ▶ ")); preview.setEditable(false); preview.setId("preview"); + if(!Config.getInstance().getSettings().livePreviews) { + preview.setVisible(false); + } TableColumn name = new TableColumn<>("Model"); name.setPrefWidth(200); name.setCellValueFactory(new PropertyValueFactory("displayName")); @@ -149,6 +153,14 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } event.consume(); }); + table.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + JavaFxModel model = table.getSelectionModel().getSelectedItem(); + if(model != null) { + new PlayAction(table, model).execute(); + } + } + }); table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { if (popup != null) { popup.hide(); diff --git a/client/src/main/java/ctbrec/ui/SiteUiFactory.java b/client/src/main/java/ctbrec/ui/SiteUiFactory.java index 8ef694d1..7475868c 100644 --- a/client/src/main/java/ctbrec/ui/SiteUiFactory.java +++ b/client/src/main/java/ctbrec/ui/SiteUiFactory.java @@ -6,11 +6,13 @@ import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.sites.streamate.Streamate; import ctbrec.ui.sites.bonga.BongaCamsSiteUi; import ctbrec.ui.sites.cam4.Cam4SiteUi; import ctbrec.ui.sites.camsoda.CamsodaSiteUi; import ctbrec.ui.sites.chaturbate.ChaturbateSiteUi; import ctbrec.ui.sites.myfreecams.MyFreeCamsSiteUi; +import ctbrec.ui.sites.streamate.StreamateSiteUi; public class SiteUiFactory { @@ -19,6 +21,7 @@ public class SiteUiFactory { private static CamsodaSiteUi camsodaSiteUi; private static ChaturbateSiteUi ctbSiteUi; private static MyFreeCamsSiteUi mfcSiteUi; + private static StreamateSiteUi streamateSiteUi; public static synchronized SiteUI getUi(Site site) { if (site instanceof BongaCams) { @@ -46,6 +49,11 @@ public class SiteUiFactory { mfcSiteUi = new MyFreeCamsSiteUi((MyFreeCams) site); } return mfcSiteUi; + } else if (site instanceof Streamate) { + if (streamateSiteUi == null) { + streamateSiteUi = new StreamateSiteUi((Streamate) site); + } + return streamateSiteUi; } throw new RuntimeException("Unknown site " + site.getName()); } diff --git a/client/src/main/java/ctbrec/ui/ThumbCell.java b/client/src/main/java/ctbrec/ui/ThumbCell.java index 898f3516..4f1cecc8 100644 --- a/client/src/main/java/ctbrec/ui/ThumbCell.java +++ b/client/src/main/java/ctbrec/ui/ThumbCell.java @@ -22,6 +22,7 @@ import ctbrec.Model; import ctbrec.io.HttpException; import ctbrec.recorder.Recorder; import ctbrec.ui.action.PlayAction; +import ctbrec.ui.controls.StreamPreview; import javafx.animation.FadeTransition; import javafx.animation.FillTransition; import javafx.animation.ParallelTransition; @@ -43,6 +44,7 @@ import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.scene.shape.Circle; +import javafx.scene.shape.Polygon; import javafx.scene.shape.Rectangle; import javafx.scene.shape.Shape; import javafx.scene.text.Font; @@ -58,6 +60,7 @@ public class ThumbCell extends StackPane { private static final Duration ANIMATION_DURATION = new Duration(250); private Model model; + private StreamPreview streamPreview; private ImageView iv; private Rectangle resolutionBackground; private final Paint resolutionOnlineColor = new Color(0.22, 0.8, 0.29, 1); @@ -87,8 +90,10 @@ public class ThumbCell extends StackPane { .expireAfterAccess(4, TimeUnit.HOURS) .maximumSize(1000) .build(); + private ThumbOverviewTab parent; public ThumbCell(ThumbOverviewTab parent, Model model, Recorder recorder) { + this.parent = parent; this.thumbCellList = parent.grid.getChildren(); this.model = model; this.recorder = recorder; @@ -96,6 +101,11 @@ public class ThumbCell extends StackPane { model.setSuspended(recorder.isSuspended(model)); this.setStyle("-fx-background-color: -fx-base"); + streamPreview = new StreamPreview(); + streamPreview.prefWidthProperty().bind(widthProperty()); + streamPreview.prefHeightProperty().bind(heightProperty()); + getChildren().add(streamPreview); + iv = new ImageView(); iv.setSmooth(true); iv.setPreserveRatio(true); @@ -164,8 +174,12 @@ public class ThumbCell extends StackPane { StackPane.setAlignment(pausedIndicator, Pos.TOP_LEFT); getChildren().add(pausedIndicator); + if(Config.getInstance().getSettings().livePreviews) { + getChildren().add(createPreviewTrigger()); + } + selectionOverlay = new Rectangle(); - selectionOverlay.setOpacity(0); + selectionOverlay.visibleProperty().bind(selectionProperty); selectionOverlay.widthProperty().bind(widthProperty()); selectionOverlay.heightProperty().bind(heightProperty()); StackPane.setAlignment(selectionOverlay, Pos.TOP_LEFT); @@ -197,6 +211,50 @@ public class ThumbCell extends StackPane { update(); } + private Node createPreviewTrigger() { + int s = 32; + StackPane previewTrigger = new StackPane(); + previewTrigger.setStyle("-fx-background-color: white;"); + previewTrigger.setOpacity(.8); + previewTrigger.setMaxSize(s, s); + + Polygon play = new Polygon(new double[] { + 16, 8, + 26, 15, + 16, 22 + }); + StackPane.setMargin(play, new Insets(0, 0, 0, 3)); + play.setStyle("-fx-background-color: black;"); + previewTrigger.getChildren().add(play); + + Circle clip = new Circle(s / 2); + clip.setTranslateX(clip.getRadius()); + clip.setTranslateY(clip.getRadius()); + previewTrigger.setClip(clip); + StackPane.setAlignment(previewTrigger, Pos.BOTTOM_LEFT); + StackPane.setMargin(previewTrigger, new Insets(0, 0, 24, 4)); + previewTrigger.setOnMouseEntered(evt -> setPreviewVisible(previewTrigger, true)); + previewTrigger.setOnMouseExited(evt -> setPreviewVisible(previewTrigger, false)); + return previewTrigger; + } + + private void setPreviewVisible(Node previewTrigger, boolean visible) { + parent.suspendUpdates(visible); + iv.setVisible(!visible); + topic.setVisible(!visible); + topicBackground.setVisible(!visible); + name.setVisible(!visible); + nameBackground.setVisible(!visible); + streamPreview.setVisible(visible); + streamPreview.startStream(model); + recordingIndicator.setVisible(!visible); + pausedIndicator.setVisible(!visible); + if(!visible) { + updateRecordingIndicator(); + } + previewTrigger.setCursor(visible ? Cursor.HAND : Cursor.DEFAULT); + } + public void setSelected(boolean selected) { selectionProperty.set(selected); selectionOverlay.getStyleClass().add("selection-background"); @@ -356,6 +414,10 @@ public class ThumbCell extends StackPane { nameBackground.setFill(c); } + updateRecordingIndicator(); + } + + private void updateRecordingIndicator() { if(recording) { recordingIndicator.setVisible(!model.isSuspended()); pausedIndicator.setVisible(model.isSuspended()); @@ -574,13 +636,15 @@ public class ThumbCell extends StackPane { nameBackground.setWidth(w); nameBackground.setHeight(20); topicBackground.setWidth(w); - topicBackground.setHeight(getHeight()-nameBackground.getHeight()); + topicBackground.setHeight(h - nameBackground.getHeight()); topic.prefHeight(getHeight()-25); topic.maxHeight(getHeight()-25); int margin = 4; topic.maxWidth(w-margin*2); topic.setWrappingWidth(w-margin*2); + streamPreview.resizeTo(w, h); + Rectangle clip = new Rectangle(w, h); clip.setArcWidth(10); clip.arcHeightProperty().bind(clip.arcWidthProperty()); diff --git a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java index aa06a2b7..6ce0cdde 100644 --- a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java +++ b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java @@ -479,6 +479,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { event.put("amount", tokens); EventBusHolder.BUS.post(event); } catch (Exception e1) { + LOG.error("An error occured while sending tip", e1); showError("Couldn't send tip", "An error occured while sending tip:", e1); } } else { diff --git a/client/src/main/java/ctbrec/ui/TipDialog.java b/client/src/main/java/ctbrec/ui/TipDialog.java index 2b4dfcf0..8aaefb93 100644 --- a/client/src/main/java/ctbrec/ui/TipDialog.java +++ b/client/src/main/java/ctbrec/ui/TipDialog.java @@ -8,7 +8,6 @@ import org.slf4j.LoggerFactory; import ctbrec.Model; import ctbrec.sites.Site; -import ctbrec.sites.chaturbate.Chaturbate; import javafx.application.Platform; import javafx.concurrent.Task; import javafx.scene.control.Alert; @@ -48,7 +47,7 @@ public class TipDialog extends TextInputDialog { int tokens = get(); Platform.runLater(() -> { if (tokens <= 0) { - String msg = "Do you want to buy tokens now?\n\nIf you agree, Chaturbate will open in a browser. " + String msg = "Do you want to buy tokens now?\n\nIf you agree, "+site.getName()+" will open in a browser. " + "The used address is an affiliate link, which supports me, but doesn't cost you anything more."; Alert buyTokens = new AutosizeAlert(Alert.AlertType.CONFIRMATION, msg, ButtonType.NO, ButtonType.YES); buyTokens.setTitle("No tokens"); @@ -56,7 +55,7 @@ public class TipDialog extends TextInputDialog { buyTokens.showAndWait(); TipDialog.this.close(); if(buyTokens.getResult() == ButtonType.YES) { - DesktopIntegration.open(Chaturbate.AFFILIATE_LINK); + DesktopIntegration.open(site.getAffiliateLink()); } } else { getEditor().setDisable(false); diff --git a/client/src/main/java/ctbrec/ui/WebbrowserTab.java b/client/src/main/java/ctbrec/ui/WebbrowserTab.java index 3def3feb..cf904a3e 100644 --- a/client/src/main/java/ctbrec/ui/WebbrowserTab.java +++ b/client/src/main/java/ctbrec/ui/WebbrowserTab.java @@ -1,15 +1,31 @@ package ctbrec.ui; +import java.io.File; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.OS; +import ctbrec.ui.controls.Dialogs; import javafx.scene.control.Tab; import javafx.scene.web.WebEngine; import javafx.scene.web.WebView; public class WebbrowserTab extends Tab { + private static final transient Logger LOG = LoggerFactory.getLogger(WebbrowserTab.class); + public WebbrowserTab(String uri) { WebView browser = new WebView(); WebEngine webEngine = browser.getEngine(); + webEngine.setUserDataDirectory(new File(OS.getConfigDir(), "webengine")); + webEngine.setJavaScriptEnabled(true); webEngine.load(uri); setContent(browser); + + webEngine.setOnError(evt -> { + LOG.error("Couldn't load {}", uri, evt.getException()); + Dialogs.showError("Error", "Couldn't load " + uri, evt.getException()); + }); } } diff --git a/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java b/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java index 556c1393..f377998b 100644 --- a/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java +++ b/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java @@ -6,9 +6,10 @@ import java.io.IOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import ctbrec.StringUtil; import ctbrec.ui.AutosizeAlert; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.ObjectPropertyBase; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; import javafx.beans.value.ChangeListener; import javafx.geometry.Point2D; import javafx.scene.Node; @@ -29,18 +30,20 @@ import javafx.stage.FileChooser; public abstract class AbstractFileSelectionBox extends HBox { private static final transient Logger LOG = LoggerFactory.getLogger(AbstractFileSelectionBox.class); - private ObjectProperty fileProperty = new ObjectPropertyBase() { - @Override - public Object getBean() { - return null; - } - - @Override - public String getName() { - return "file"; - } - }; + // private ObjectProperty fileProperty = new ObjectPropertyBase() { + // @Override + // public Object getBean() { + // return null; + // } + // + // @Override + // public String getName() { + // return "file"; + // } + // }; + private StringProperty fileProperty = new SimpleStringProperty(); protected TextField fileInput; + protected boolean allowEmptyValue = false; private Tooltip validationError = new Tooltip(); public AbstractFileSelectionBox() { @@ -67,8 +70,14 @@ public abstract class AbstractFileSelectionBox extends HBox { private ChangeListener textListener() { return (obs, o, n) -> { String input = fileInput.getText(); - File program = new File(input); - setFile(program); + if(StringUtil.isBlank(input) && allowEmptyValue) { + fileProperty.set(""); + hideValidationHints(); + return; + } else { + File program = new File(input); + setFile(program); + } }; } @@ -83,13 +92,17 @@ public abstract class AbstractFileSelectionBox extends HBox { validationError.show(getScene().getWindow(), p.getX(), p.getY() + fileInput.getHeight() + 4); } } else { - fileInput.setBorder(Border.EMPTY); - fileInput.setTooltip(null); - fileProperty.set(file); - validationError.hide(); + fileProperty.set(file.getAbsolutePath()); + hideValidationHints(); } } + private void hideValidationHints() { + fileInput.setBorder(Border.EMPTY); + fileInput.setTooltip(null); + validationError.hide(); + } + protected String validate(File file) { if (file == null || !file.exists()) { return "File does not exist"; @@ -98,6 +111,10 @@ public abstract class AbstractFileSelectionBox extends HBox { } } + public void allowEmptyValue() { + this.allowEmptyValue = true; + } + private Button createBrowseButton() { Button button = new Button("Select"); button.setOnAction((e) -> { @@ -123,7 +140,7 @@ public abstract class AbstractFileSelectionBox extends HBox { } } - public ObjectProperty fileProperty() { + public StringProperty fileProperty() { return fileProperty; } } diff --git a/client/src/main/java/ctbrec/ui/controls/Dialogs.java b/client/src/main/java/ctbrec/ui/controls/Dialogs.java index 558f6e0f..653bbc91 100644 --- a/client/src/main/java/ctbrec/ui/controls/Dialogs.java +++ b/client/src/main/java/ctbrec/ui/controls/Dialogs.java @@ -5,14 +5,14 @@ import javafx.application.Platform; import javafx.scene.control.Alert; public class Dialogs { - public static void showError(String header, String text, Exception e) { + public static void showError(String header, String text, Throwable t) { Runnable r = () -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); alert.setTitle("Error"); alert.setHeaderText(header); String content = text; - if(e != null) { - content += " " + e.getLocalizedMessage(); + if(t != null) { + content += " " + t.getLocalizedMessage(); } alert.setContentText(content); alert.showAndWait(); diff --git a/client/src/main/java/ctbrec/ui/controls/DirectorySelectionBox.java b/client/src/main/java/ctbrec/ui/controls/DirectorySelectionBox.java index f3f1a5e5..ca65a7c4 100644 --- a/client/src/main/java/ctbrec/ui/controls/DirectorySelectionBox.java +++ b/client/src/main/java/ctbrec/ui/controls/DirectorySelectionBox.java @@ -12,7 +12,7 @@ public class DirectorySelectionBox extends AbstractFileSelectionBox { @Override protected void choose() { DirectoryChooser chooser = new DirectoryChooser(); - File currentDir = fileProperty().get(); + File currentDir = new File(fileProperty().get()); if (currentDir.exists() && currentDir.isDirectory()) { chooser.setInitialDirectory(currentDir); } diff --git a/client/src/main/java/ctbrec/ui/controls/StreamPreview.java b/client/src/main/java/ctbrec/ui/controls/StreamPreview.java new file mode 100644 index 00000000..a7b5911f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/StreamPreview.java @@ -0,0 +1,191 @@ +package ctbrec.ui.controls; + +import java.io.InterruptedIOException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.recorder.download.StreamSource; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.media.Media; +import javafx.scene.media.MediaPlayer; +import javafx.scene.media.MediaView; + +public class StreamPreview extends StackPane { + private static final transient Logger LOG = LoggerFactory.getLogger(StreamPreview.class); + + private ImageView preview = new ImageView(); + private MediaView videoPreview; + private MediaPlayer videoPlayer; + private Media video; + private ProgressIndicator progressIndicator; + private static ExecutorService executor = Executors.newSingleThreadExecutor(); + private static Future future; + + public StreamPreview() { + videoPreview = new MediaView(); + videoPreview.setFitWidth(Config.getInstance().getSettings().thumbWidth); + videoPreview.setFitHeight(videoPreview.getFitWidth() * 9 / 16); + videoPreview.setPreserveRatio(true); + StackPane.setMargin(videoPreview, new Insets(5)); + + preview.setFitWidth(Config.getInstance().getSettings().thumbWidth); + preview.setPreserveRatio(true); + preview.setSmooth(true); + preview.setStyle("-fx-background-radius: 10px, 10px, 10px, 10px;"); + preview.visibleProperty().bind(videoPreview.visibleProperty().not()); + StackPane.setMargin(preview, new Insets(5)); + + progressIndicator = new ProgressIndicator(); + progressIndicator.setVisible(false); + + Region veil = new Region(); + veil.setStyle("-fx-background-color: rgba(0, 0, 0, 0.8)"); + veil.visibleProperty().bind(progressIndicator.visibleProperty()); + StackPane.setMargin(veil, new Insets(5)); + + getChildren().addAll(preview, videoPreview, veil, progressIndicator); + } + + public void startStream(Model model) { + Platform.runLater(() -> { + progressIndicator.setVisible(true); + if(model.getPreview() != null) { + try { + videoPreview.setVisible(false); + Image img = new Image(model.getPreview(), true); + preview.setImage(img); + double aspect = img.getWidth() / img.getHeight(); + double w = Config.getInstance().getSettings().thumbWidth; + double h = w / aspect; + resizeTo(w, h); + } catch (Exception e) {} + } + }); + if(future != null && !future.isDone()) { + future.cancel(true); + } + future = executor.submit(() -> { + try { + List sources = model.getStreamSources(); + Collections.sort(sources); + StreamSource best = sources.get(0); + checkInterrupt(); + LOG.debug("Preview url for {} is {}", model.getName(), best.getMediaPlaylistUrl()); + video = new Media(best.getMediaPlaylistUrl()); + video.setOnError(() -> onError(videoPlayer)); + if(videoPlayer != null) { + videoPlayer.dispose(); + } + videoPlayer = new MediaPlayer(video); + videoPlayer.setMute(true); + checkInterrupt(); + videoPlayer.setOnReady(() -> { + if(!future.isCancelled()) { + Platform.runLater(() -> { + double aspect = (double)video.getWidth() / video.getHeight(); + double w = Config.getInstance().getSettings().thumbWidth; + double h = w / aspect; + resizeTo(w, h); + progressIndicator.setVisible(false); + videoPreview.setVisible(true); + videoPreview.setMediaPlayer(videoPlayer); + videoPlayer.play(); + }); + } + }); + videoPlayer.setOnError(() -> onError(videoPlayer)); + } catch (IllegalStateException e) { + if(e.getMessage().equals("Stream url unknown")) { + // fine hls url for mfc not known yet + } else { + LOG.warn("Couldn't start preview video: {}", e.getMessage()); + } + showTestImage(); + } catch (HttpException e) { + if(e.getResponseCode() != 404) { + LOG.warn("Couldn't start preview video: {}", e.getMessage()); + } + showTestImage(); + } catch (InterruptedException | InterruptedIOException e) { + // future has been canceled, that's fine + } catch (ExecutionException e) { + if(e.getCause() instanceof InterruptedException || e.getCause() instanceof InterruptedIOException) { + // future has been canceled, that's fine + } else { + LOG.warn("Couldn't start preview video: {}", e.getMessage()); + } + showTestImage(); + } catch (Exception e) { + LOG.warn("Couldn't start preview video: {}", e.getMessage()); + showTestImage(); + } + }); + } + + public void resizeTo(double w, double h) { + preview.setFitWidth(w); + preview.setFitHeight(h); + videoPreview.setFitWidth(w); + videoPreview.setFitHeight(h); + progressIndicator.setPrefSize(w, h); + } + + public void stop() { + MediaPlayer old = videoPlayer; + Future oldFuture = future; + new Thread(() -> { + if(oldFuture != null && !oldFuture.isDone()) { + oldFuture.cancel(true); + } + if(old != null) { + old.dispose(); + } + }).start(); + } + + private void onError(MediaPlayer videoPlayer) { + LOG.error("Error while starting preview stream", videoPlayer.getError()); + Optional cause = Optional.ofNullable(videoPlayer).map(v -> v.getError()).map(e -> e.getCause()); + if(cause.isPresent()) { + LOG.error("Error while starting preview stream root cause:", cause.get()); + } + showTestImage(); + } + + private void showTestImage() { + stop(); + Platform.runLater(() -> { + videoPreview.setVisible(false); + Image img = new Image(getClass().getResource("/image_not_found.png").toString(), true); + preview.setImage(img); + double aspect = img.getWidth() / img.getHeight(); + double w = Config.getInstance().getSettings().thumbWidth; + double h = w / aspect; + resizeTo(w, h); + progressIndicator.setVisible(false); + }); + } + + private void checkInterrupt() throws InterruptedException { + if(Thread.interrupted()) { + throw new InterruptedException(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/ActionSettingsPanel.java b/client/src/main/java/ctbrec/ui/settings/ActionSettingsPanel.java index a163a19e..182ca2d5 100644 --- a/client/src/main/java/ctbrec/ui/settings/ActionSettingsPanel.java +++ b/client/src/main/java/ctbrec/ui/settings/ActionSettingsPanel.java @@ -173,7 +173,7 @@ public class ActionSettingsPanel extends TitledPane { if(playSound.isSelected()) { ActionConfiguration ac = new ActionConfiguration(); ac.setType(PlaySound.class.getName()); - File file = sound.fileProperty().get(); + File file = new File(sound.fileProperty().get()); ac.getConfiguration().put("file", file.getAbsolutePath()); ac.setName("play " + file.getName()); config.getActions().add(ac); @@ -181,7 +181,7 @@ public class ActionSettingsPanel extends TitledPane { if(executeProgram.isSelected()) { ActionConfiguration ac = new ActionConfiguration(); ac.setType(ExecuteProgram.class.getName()); - File file = program.fileProperty().get(); + File file = new File(program.fileProperty().get()); ac.getConfiguration().put("file", file.getAbsolutePath()); ac.setName("execute " + file.getName()); config.getActions().add(ac); diff --git a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java index d9630ff9..68c797b3 100644 --- a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java +++ b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java @@ -58,17 +58,19 @@ public class SettingsTab extends Tab implements TabSelectionListener { private TextField port; private TextField onlineCheckIntervalInSecs; private TextField leaveSpaceOnDevice; + private TextField minimumLengthInSecs; private CheckBox loadResolution; private CheckBox secureCommunication = new CheckBox(); private CheckBox chooseStreamQuality = new CheckBox(); private CheckBox multiplePlayers = new CheckBox(); private CheckBox updateThumbnails = new CheckBox(); + private CheckBox livePreviews = new CheckBox(); private CheckBox showPlayerStarting = new CheckBox(); private RadioButton recordLocal; private RadioButton recordRemote; private ToggleGroup recordLocation; private ProxySettingsPane proxySettingsPane; - private ComboBox maxResolution; + private TextField maxResolution; private ComboBox splitAfter; private ComboBox directoryStructure; private ComboBox startTab; @@ -236,7 +238,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { recordingsDirectory = new DirectorySelectionBox(Config.getInstance().getSettings().recordingsDir); recordingsDirectory.prefWidth(400); recordingsDirectory.fileProperty().addListener((obs, o, n) -> { - String path = n.getAbsolutePath(); + String path = n; if(!Objects.equals(path, Config.getInstance().getSettings().recordingsDir)) { Config.getInstance().getSettings().recordingsDir = path; saveConfig(); @@ -262,26 +264,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { layout.add(directoryStructure, 1, row++); recordingsDirectory.prefWidthProperty().bind(directoryStructure.widthProperty()); - Label l = new Label("Maximum resolution (0 = unlimited)"); - layout.add(l, 0, row); - List resolutionOptions = new ArrayList<>(); - resolutionOptions.add(1080); - resolutionOptions.add(720); - resolutionOptions.add(600); - resolutionOptions.add(480); - resolutionOptions.add(0); - maxResolution = new ComboBox<>(FXCollections.observableList(resolutionOptions)); - setMaxResolutionValue(); - maxResolution.setOnAction((e) -> { - Config.getInstance().getSettings().maximumResolution = maxResolution.getSelectionModel().getSelectedItem(); - saveConfig(); - }); - maxResolution.prefWidthProperty().bind(directoryStructure.widthProperty()); - layout.add(maxResolution, 1, row++); - GridPane.setMargin(l, new Insets(0, 0, 0, 0)); - GridPane.setMargin(maxResolution, new Insets(0, 0, 0, CHECKBOX_MARGIN)); - - l = new Label("Split recordings after (minutes)"); + Label l = new Label("Split recordings after (minutes)"); layout.add(l, 0, row); List splitOptions = new ArrayList<>(); splitOptions.add(new SplitAfterOption("disabled", 0)); @@ -306,11 +289,31 @@ public class SettingsTab extends Tab implements TabSelectionListener { GridPane.setMargin(l, new Insets(0, 0, 0, 0)); GridPane.setMargin(splitAfter, new Insets(0, 0, 0, CHECKBOX_MARGIN)); + l = new Label("Maximum resolution (0 = unlimited)"); + layout.add(l, 0, row); + maxResolution = new TextField(Integer.toString(Config.getInstance().getSettings().maximumResolution)); + maxResolution.textProperty().addListener((observable, oldValue, newValue) -> { + if (!newValue.matches("\\d*")) { + maxResolution.setText(newValue.replaceAll("[^\\d]", "")); + } + if (!maxResolution.getText().isEmpty()) { + int newRes = Integer.parseInt(maxResolution.getText()); + if (newRes != Config.getInstance().getSettings().maximumResolution) { + Config.getInstance().getSettings().maximumResolution = newRes; + saveConfig(); + } + } + }); + maxResolution.prefWidthProperty().bind(directoryStructure.widthProperty()); + layout.add(maxResolution, 1, row++); + GridPane.setMargin(l, new Insets(0, 0, 0, 0)); + GridPane.setMargin(maxResolution, new Insets(0, 0, 0, CHECKBOX_MARGIN)); + layout.add(new Label("Post-Processing"), 0, row); - // TODO allow empty strings to remove post-processing scripts postProcessing = new ProgramSelectionBox(Config.getInstance().getSettings().postProcessing); + postProcessing.allowEmptyValue(); postProcessing.fileProperty().addListener((obs, o, n) -> { - String path = n.getAbsolutePath(); + String path = n; if(!Objects.equals(path, Config.getInstance().getSettings().postProcessing)) { Config.getInstance().getSettings().postProcessing = path; saveConfig(); @@ -360,6 +363,26 @@ public class SettingsTab extends Tab implements TabSelectionListener { GridPane.setMargin(leaveSpaceOnDevice, new Insets(0, 0, 0, CHECKBOX_MARGIN)); layout.add(leaveSpaceOnDevice, 1, row++); + tt = new Tooltip("Delete recordings, which are shorter than x seconds. 0 to disable."); + l = new Label("Delete recordings shorter than (secs)"); + l.setTooltip(tt); + layout.add(l, 0, row); + int minimumLengthInSeconds = Config.getInstance().getSettings().minimumLengthInSeconds; + minimumLengthInSecs = new TextField(Integer.toString(minimumLengthInSeconds)); + minimumLengthInSecs.setTooltip(tt); + minimumLengthInSecs.textProperty().addListener((observable, oldValue, newValue) -> { + if (!newValue.matches("\\d*")) { + minimumLengthInSecs.setText(newValue.replaceAll("[^\\d]", "")); + } + if(!minimumLengthInSecs.getText().isEmpty()) { + int minimumLength = Integer.parseInt(minimumLengthInSecs.getText()); + Config.getInstance().getSettings().minimumLengthInSeconds = minimumLength; + saveConfig(); + } + }); + GridPane.setMargin(minimumLengthInSecs, new Insets(0, 0, 0, CHECKBOX_MARGIN)); + layout.add(minimumLengthInSecs, 1, row++); + TitledPane locations = new TitledPane("Recorder", layout); locations.setCollapsible(false); return locations; @@ -372,7 +395,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { layout.add(new Label("Player"), 0, row); mediaPlayer = new ProgramSelectionBox(Config.getInstance().getSettings().mediaPlayer); mediaPlayer.fileProperty().addListener((obs, o, n) -> { - String path = n.getAbsolutePath(); + String path = n; if (!Objects.equals(path, Config.getInstance().getSettings().mediaPlayer)) { Config.getInstance().getSettings().mediaPlayer = path; saveConfig(); @@ -401,7 +424,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { Config.getInstance().getSettings().showPlayerStarting = showPlayerStarting.isSelected(); saveConfig(); }); - GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); + GridPane.setMargin(l, new Insets(3, 0, 0, 0)); GridPane.setMargin(showPlayerStarting, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); layout.add(showPlayerStarting, 1, row++); @@ -413,7 +436,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { Config.getInstance().getSettings().determineResolution = loadResolution.isSelected(); saveConfig(); }); - GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); + GridPane.setMargin(l, new Insets(3, 0, 0, 0)); GridPane.setMargin(loadResolution, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); layout.add(loadResolution, 1, row++); @@ -424,7 +447,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { Config.getInstance().getSettings().chooseStreamQuality = chooseStreamQuality.isSelected(); saveConfig(); }); - GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); + GridPane.setMargin(l, new Insets(3, 0, 0, 0)); GridPane.setMargin(chooseStreamQuality, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); layout.add(chooseStreamQuality, 1, row++); @@ -435,10 +458,22 @@ public class SettingsTab extends Tab implements TabSelectionListener { Config.getInstance().getSettings().updateThumbnails = updateThumbnails.isSelected(); saveConfig(); }); - GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); - GridPane.setMargin(updateThumbnails, new Insets(CHECKBOX_MARGIN, 0, CHECKBOX_MARGIN, CHECKBOX_MARGIN)); + GridPane.setMargin(l, new Insets(3, 0, 0, 0)); + GridPane.setMargin(updateThumbnails, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); layout.add(updateThumbnails, 1, row++); + l = new Label("Enable live previews (experimental)"); + layout.add(l, 0, row); + livePreviews.setSelected(Config.getInstance().getSettings().livePreviews); + livePreviews.setOnAction((e) -> { + Config.getInstance().getSettings().livePreviews = livePreviews.isSelected(); + saveConfig(); + showRestartRequired(); + }); + GridPane.setMargin(l, new Insets(3, 0, 0, 0)); + GridPane.setMargin(livePreviews, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); + layout.add(livePreviews, 1, row++); + l = new Label("Start Tab"); layout.add(l, 0, row); startTab = new ComboBox<>(); @@ -447,8 +482,8 @@ public class SettingsTab extends Tab implements TabSelectionListener { saveConfig(); }); layout.add(startTab, 1, row++); - GridPane.setMargin(l, new Insets(0, 0, 0, 0)); - GridPane.setMargin(startTab, new Insets(0, 0, 0, CHECKBOX_MARGIN)); + GridPane.setMargin(l, new Insets(3, 0, 0, 0)); + GridPane.setMargin(startTab, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); l = new Label("Colors (Base / Accent)"); layout.add(l, 0, row); @@ -471,15 +506,6 @@ public class SettingsTab extends Tab implements TabSelectionListener { } } - private void setMaxResolutionValue() { - int value = Config.getInstance().getSettings().maximumResolution; - for (Integer option : maxResolution.getItems()) { - if(option == value) { - maxResolution.getSelectionModel().select(option); - } - } - } - void showRestartRequired() { restartLabel.setVisible(true); } @@ -503,6 +529,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { onlineCheckIntervalInSecs.setDisable(!local); leaveSpaceOnDevice.setDisable(!local); postProcessing.setDisable(!local); + minimumLengthInSecs.setDisable(!local); } @Override diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java index e7845956..beb2f07c 100644 --- a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java @@ -56,7 +56,10 @@ public class BongaCamsUpdateService extends PaginatedScheduledService { JSONArray _models = json.getJSONArray("models"); for (int i = 0; i < _models.length(); i++) { JSONObject m = _models.getJSONObject(i); - String name = m.getString("username"); + String name = m.optString("username"); + if(name.isEmpty()) { + continue; + } BongaCamsModel model = (BongaCamsModel) bongaCams.createModel(name); boolean away = m.optBoolean("is_away"); boolean online = m.optBoolean("online"); diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateConfigUI.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateConfigUI.java new file mode 100644 index 00000000..d338cab1 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateConfigUI.java @@ -0,0 +1,86 @@ +package ctbrec.ui.sites.streamate; + +import ctbrec.Config; +import ctbrec.Settings; +import ctbrec.sites.streamate.Streamate; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.settings.SettingsTab; +import ctbrec.ui.sites.AbstractConfigUI; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class StreamateConfigUI extends AbstractConfigUI { + private Streamate streamate; + + public StreamateConfigUI(Streamate streamate) { + this.streamate = streamate; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + Settings settings = Config.getInstance().getSettings(); + + int row = 0; + Label l = new Label("Active"); + layout.add(l, 0, row); + CheckBox enabled = new CheckBox(); + enabled.setSelected(!settings.disabledSites.contains(streamate.getName())); + enabled.setOnAction((e) -> { + if(enabled.isSelected()) { + settings.disabledSites.remove(streamate.getName()); + } else { + settings.disabledSites.add(streamate.getName()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + layout.add(new Label("Streamate User"), 0, row); + TextField username = new TextField(settings.streamateUsername); + username.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().streamateUsername)) { + Config.getInstance().getSettings().streamateUsername = username.getText(); + streamate.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(username, true); + GridPane.setHgrow(username, Priority.ALWAYS); + GridPane.setColumnSpan(username, 2); + layout.add(username, 1, row++); + + layout.add(new Label("Streamate Password"), 0, row); + PasswordField password = new PasswordField(); + password.setText(settings.streamatePassword); + password.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().streamatePassword)) { + Config.getInstance().getSettings().streamatePassword = password.getText(); + streamate.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + Button createAccount = new Button("Create new Account"); + createAccount.setOnAction((e) -> DesktopIntegration.open(streamate.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedService.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedService.java new file mode 100644 index 00000000..d78b04c1 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedService.java @@ -0,0 +1,93 @@ +package ctbrec.ui.sites.streamate; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPathExpressionException; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xml.sax.SAXException; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.streamate.Streamate; +import ctbrec.sites.streamate.StreamateHttpClient; +import ctbrec.sites.streamate.StreamateModel; +import ctbrec.ui.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; + +public class StreamateFollowedService extends PaginatedScheduledService { + + private static final transient Logger LOG = LoggerFactory.getLogger(StreamateFollowedService.class); + + private static final int MODELS_PER_PAGE = 48; + private Streamate streamate; + private StreamateHttpClient httpClient; + private String url; + private boolean showOnline = true; + + public StreamateFollowedService(Streamate streamate) { + this.streamate = streamate; + this.httpClient = (StreamateHttpClient) streamate.getHttpClient(); + this.url = streamate.getBaseUrl() + "/api/search/v1/favorites?host=streamate.com&domain=streamate.com"; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException, SAXException, ParserConfigurationException, XPathExpressionException { + httpClient.login(); + String saKey = httpClient.getSaKey(); + String _url = url + "&page_number=" + page + "&results_per_page=" + MODELS_PER_PAGE + "&sakey=" + saKey; + LOG.debug("Fetching page {}", _url); + Request request = new Request.Builder() + .url(_url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", streamate.getBaseUrl()) + .build(); + try(Response response = streamate.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + String content = response.body().string(); + JSONObject json = new JSONObject(content); + if(json.optString("status").equals("SM_OK")) { + JSONArray performers = json.getJSONArray("Results"); + for (int i = 0; i < performers.length(); i++) { + JSONObject p = performers.getJSONObject(i); + String nickname = p.getString("Nickname"); + StreamateModel model = (StreamateModel) streamate.createModel(nickname); + model.setId(p.getLong("PerformerId")); + model.setPreview("https://m1.nsimg.net/biopic/320x240/" + model.getId()); + boolean online = p.optString("LiveStatus").equals("live"); + model.setOnline(online); + if(online == showOnline) { + models.add(model); + } + } + } else { + throw new IOException("Status: " + json.optString("status")); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + }; + } + + public void setOnline(boolean online) { + this.showOnline = online; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedTab.java new file mode 100644 index 00000000..f79cc30f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedTab.java @@ -0,0 +1,77 @@ +package ctbrec.ui.sites.streamate; + +import ctbrec.sites.streamate.Streamate; +import ctbrec.ui.FollowedTab; +import ctbrec.ui.ThumbOverviewTab; +import javafx.concurrent.WorkerStateEvent; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; + +public class StreamateFollowedTab extends ThumbOverviewTab implements FollowedTab { + private Label status; + + public StreamateFollowedTab(Streamate streamate) { + super("Favorites", new StreamateFollowedService(streamate), streamate); + + status = new Label("Logging in..."); + grid.getChildren().add(status); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + ToggleGroup group = new ToggleGroup(); + RadioButton online = new RadioButton("online"); + online.setToggleGroup(group); + RadioButton offline = new RadioButton("offline"); + offline.setToggleGroup(group); + pagination.getChildren().add(online); + pagination.getChildren().add(offline); + HBox.setMargin(online, new Insets(5,5,5,40)); + HBox.setMargin(offline, new Insets(5,5,5,5)); + online.setSelected(true); + group.selectedToggleProperty().addListener((e) -> { + ((StreamateFollowedService)updateService).setOnline(online.isSelected()); + queue.clear(); + updateService.restart(); + }); + } + + @Override + protected void onSuccess() { + grid.getChildren().remove(status); + super.onSuccess(); + } + + @Override + protected void onFail(WorkerStateEvent event) { + status.setText("Login failed"); + super.onFail(event); + } + + @Override + public void selected() { + status.setText("Logging in..."); + super.selected(); + } + + public void setScene(Scene scene) { + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if(this.isSelected()) { + if(event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateSiteUi.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateSiteUi.java new file mode 100644 index 00000000..c7348a1f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateSiteUi.java @@ -0,0 +1,37 @@ +package ctbrec.ui.sites.streamate; + +import java.io.IOException; + +import ctbrec.sites.ConfigUI; +import ctbrec.sites.streamate.Streamate; +import ctbrec.ui.SiteUI; +import ctbrec.ui.TabProvider; + +public class StreamateSiteUi implements SiteUI { + + private StreamateTabProvider tabProvider; + private StreamateConfigUI configUi; + private Streamate streamate; + + public StreamateSiteUi(Streamate streamate) { + this.streamate = streamate; + tabProvider = new StreamateTabProvider(streamate); + configUi = new StreamateConfigUI(streamate); + } + + @Override + public TabProvider getTabProvider() { + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + return configUi; + } + + @Override + public boolean login() throws IOException { + return streamate.login(); + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTabProvider.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTabProvider.java new file mode 100644 index 00000000..d43ec25f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTabProvider.java @@ -0,0 +1,62 @@ +package ctbrec.ui.sites.streamate; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.recorder.Recorder; +import ctbrec.sites.streamate.Streamate; +import ctbrec.ui.TabProvider; +import ctbrec.ui.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +public class StreamateTabProvider extends TabProvider { + private static final transient Logger LOG = LoggerFactory.getLogger(StreamateTabProvider.class); + private Streamate streamate; + private Recorder recorder; + private ThumbOverviewTab followedTab; + + public StreamateTabProvider(Streamate streamate) { + this.streamate = streamate; + this.recorder = streamate.getRecorder(); + } + + @Override + public List getTabs(Scene scene) { + List tabs = new ArrayList<>(); + try { + tabs.add(createTab("Girls", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:f")); + tabs.add(createTab("Guys", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:m")); + tabs.add(createTab("Couples", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:mf")); + tabs.add(createTab("Lesbian", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:ff")); + tabs.add(createTab("Gay", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:mm")); + tabs.add(createTab("Groups", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:g")); + tabs.add(createTab("Trans female", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:tm2f")); + tabs.add(createTab("Trans male", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:tf2m")); + tabs.add(createTab("New", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=new:true")); + + followedTab = new StreamateFollowedTab(streamate); + followedTab.setRecorder(recorder); + tabs.add(followedTab); + } catch (IOException e) { + LOG.error("Couldn't create streamate tab", e); + } + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + + private Tab createTab(String title, String url) throws IOException { + StreamateUpdateService updateService = new StreamateUpdateService(streamate, url); + ThumbOverviewTab tab = new ThumbOverviewTab(title, updateService, streamate); + tab.setRecorder(recorder); + return tab; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java new file mode 100644 index 00000000..083a2008 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java @@ -0,0 +1,86 @@ +package ctbrec.ui.sites.streamate; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPathExpressionException; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xml.sax.SAXException; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.streamate.Streamate; +import ctbrec.sites.streamate.StreamateModel; +import ctbrec.ui.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; + +public class StreamateUpdateService extends PaginatedScheduledService { + + private static final transient Logger LOG = LoggerFactory.getLogger(StreamateUpdateService.class); + + private static final int MODELS_PER_PAGE = 48; + private Streamate streamate; + private String url; + + public StreamateUpdateService(Streamate streamate, String url) { + this.streamate = streamate; + this.url = url; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException, SAXException, ParserConfigurationException, XPathExpressionException { + int from = (page - 1) * MODELS_PER_PAGE; + String _url = url + "&from=" + from + "&size=" + MODELS_PER_PAGE; + LOG.debug("Fetching page {}", _url); + Request request = new Request.Builder() + .url(_url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", streamate.getBaseUrl()) + .build(); + try(Response response = streamate.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + String content = response.body().string(); + JSONObject json = new JSONObject(content); + JSONArray performers = json.getJSONArray("performers"); + for (int i = 0; i < performers.length(); i++) { + JSONObject p = performers.getJSONObject(i); + String nickname = p.getString("nickname"); + StreamateModel model = (StreamateModel) streamate.createModel(nickname); + model.setId(p.getLong("id")); + model.setPreview(p.getString("thumbnail")); + model.setOnline(p.optBoolean("online")); + // TODO figure out, what all the states mean + // liveState {…} + // exclusiveShow false + // goldShow true + // onBreak false + // partyChat true + // preGoldShow true + // privateChat false + // specialShow false + models.add(model); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + }; + } +} diff --git a/common/pom.xml b/common/pom.xml index 4f20e4fa..4308d633 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -8,7 +8,7 @@ ctbrec master - 1.15.0 + 1.16.0 ../master diff --git a/common/src/main/java/ctbrec/MpegUtil.java b/common/src/main/java/ctbrec/MpegUtil.java new file mode 100644 index 00000000..4ef37a53 --- /dev/null +++ b/common/src/main/java/ctbrec/MpegUtil.java @@ -0,0 +1,73 @@ +package ctbrec; + +import java.io.File; +import java.io.IOException; +import java.nio.channels.ReadableByteChannel; +import java.time.Duration; +import java.util.Set; + +import org.jcodec.common.Demuxer; +import org.jcodec.common.DemuxerTrack; +import org.jcodec.common.TrackType; +import org.jcodec.common.Tuple; +import org.jcodec.common.Tuple._2; +import org.jcodec.common.io.FileChannelWrapper; +import org.jcodec.common.io.NIOUtils; +import org.jcodec.common.model.Packet; +import org.jcodec.containers.mps.MPSDemuxer; +import org.jcodec.containers.mps.MTSDemuxer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MpegUtil { + private static final transient Logger LOG = LoggerFactory.getLogger(MpegUtil.class); + + public static void main(String[] args) throws IOException { + readFile(new File("../../test-recs/ff.ts")); + } + + public static void readFile(File file) throws IOException { + System.out.println(file.getCanonicalPath()); + double duration = MpegUtil.getFileDuration(file); + System.out.println(Duration.ofSeconds((long) duration)); + } + + public static double getFileDuration(File file) throws IOException { + try(FileChannelWrapper ch = NIOUtils.readableChannel(file)) { + _2 m2tsDemuxer = createM2TSDemuxer(ch, TrackType.VIDEO); + Demuxer demuxer = m2tsDemuxer.v1; + DemuxerTrack videoDemux = demuxer.getTracks().get(0); + Packet videoFrame = null; + double totalDuration = 0; + while( (videoFrame = videoDemux.nextFrame()) != null) { + totalDuration += videoFrame.getDurationD(); + } + return totalDuration; + } + } + + public static _2 createM2TSDemuxer(FileChannelWrapper ch, TrackType targetTrack) throws IOException { + MTSDemuxer mts = new MTSDemuxer(ch); + Set programs = mts.getPrograms(); + if (programs.size() == 0) { + LOG.error("The MPEG TS stream contains no programs"); + return null; + } + Tuple._2 found = null; + for (Integer pid : programs) { + ReadableByteChannel program = mts.getProgram(pid); + if (found != null) { + program.close(); + continue; + } + MPSDemuxer demuxer = new MPSDemuxer(program); + if (targetTrack == TrackType.AUDIO && demuxer.getAudioTracks().size() > 0 + || targetTrack == TrackType.VIDEO && demuxer.getVideoTracks().size() > 0) { + found = org.jcodec.common.Tuple._2(pid, (Demuxer) demuxer); + } else { + program.close(); + } + } + return found; + } +} diff --git a/common/src/main/java/ctbrec/OS.java b/common/src/main/java/ctbrec/OS.java index d5181887..e86842e4 100644 --- a/common/src/main/java/ctbrec/OS.java +++ b/common/src/main/java/ctbrec/OS.java @@ -93,7 +93,6 @@ public class OS { } else if(OS.getOsType() == OS.TYPE.WINDOWS) { notifyWindows(title, header, msg); } else if(OS.getOsType() == OS.TYPE.MAC) { - // TODO find out, if it makes a sound or if we have to play a sound notifyMac(title, header, msg); } else { // unknown system, try systemtray notification anyways diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 384ea432..c96cf985 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -41,6 +41,7 @@ public class Settings { public String recordingsDir = System.getProperty("user.home") + File.separator + "ctbrec"; public DirectoryStructure recordingsDirStructure = DirectoryStructure.FLAT; public long minimumSpaceLeftInBytes = 0; + public int minimumLengthInSeconds = 0; public String mediaPlayer = "/usr/bin/mpv"; public String postProcessing = ""; public String username = ""; // chaturbate username TODO maybe rename this onetime @@ -59,13 +60,16 @@ public class Settings { public boolean mfcIgnoreUpscaled = false; public String camsodaUsername = ""; public String camsodaPassword = ""; - public String cam4Username; - public String cam4Password; + public String cam4Username = ""; + public String cam4Password = ""; + public String streamateUsername = ""; + public String streamatePassword = ""; public String lastDownloadDir = ""; public List models = new ArrayList<>(); public List eventHandlers = new ArrayList<>(); public boolean determineResolution = false; + public boolean livePreviews = false; public boolean requireAuthentication = false; public boolean chooseStreamQuality = false; public int maximumResolution = 0; diff --git a/common/src/main/java/ctbrec/event/ExecuteProgram.java b/common/src/main/java/ctbrec/event/ExecuteProgram.java index 28cb4807..5bb2b321 100644 --- a/common/src/main/java/ctbrec/event/ExecuteProgram.java +++ b/common/src/main/java/ctbrec/event/ExecuteProgram.java @@ -48,9 +48,9 @@ public class ExecuteProgram extends Action { err.start(); process.waitFor(); - LOG.debug("executing {} finished", executable); + LOG.debug("Executing {} finished", executable); } catch (Exception e) { - LOG.error("Error while processing {}", e); + LOG.error("Error while executing {}", executable, e); } } diff --git a/common/src/main/java/ctbrec/io/HttpClient.java b/common/src/main/java/ctbrec/io/HttpClient.java index 9dc81010..5b2d8d9c 100644 --- a/common/src/main/java/ctbrec/io/HttpClient.java +++ b/common/src/main/java/ctbrec/io/HttpClient.java @@ -29,6 +29,8 @@ import okhttp3.OkHttpClient.Builder; import okhttp3.Request; import okhttp3.Response; import okhttp3.Route; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; public abstract class HttpClient { private static final transient Logger LOG = LoggerFactory.getLogger(HttpClient.class); @@ -219,4 +221,9 @@ public abstract class HttpClient { getCookieJar().clear(); loggedIn = false; } + + public WebSocket newWebSocket(String url, WebSocketListener l) { + Request request = new Request.Builder().url(url).build(); + return client.newWebSocket(request, l); + } } diff --git a/common/src/main/java/ctbrec/io/ModelJsonAdapter.java b/common/src/main/java/ctbrec/io/ModelJsonAdapter.java index 5296f3e6..54794087 100644 --- a/common/src/main/java/ctbrec/io/ModelJsonAdapter.java +++ b/common/src/main/java/ctbrec/io/ModelJsonAdapter.java @@ -5,6 +5,9 @@ import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.JsonReader; import com.squareup.moshi.JsonReader.Token; @@ -16,6 +19,8 @@ import ctbrec.sites.chaturbate.ChaturbateModel; public class ModelJsonAdapter extends JsonAdapter { + private static final transient Logger LOG = LoggerFactory.getLogger(ModelJsonAdapter.class); + private List sites; public ModelJsonAdapter() { @@ -62,7 +67,12 @@ public class ModelJsonAdapter extends JsonAdapter { model.setSuspended(suspended); } else if(key.equals("siteSpecific")) { reader.beginObject(); - model.readSiteSpecificData(reader); + try { + model.readSiteSpecificData(reader); + } catch(Exception e) { + LOG.error("Couldn't read site specific data for model {}", model.getName()); + throw e; + } reader.endObject(); } } else { diff --git a/common/src/main/java/ctbrec/io/XmlParserUtils.java b/common/src/main/java/ctbrec/io/XmlParserUtils.java new file mode 100644 index 00000000..a1ac9cf6 --- /dev/null +++ b/common/src/main/java/ctbrec/io/XmlParserUtils.java @@ -0,0 +1,117 @@ +package ctbrec.io; + +import java.io.IOException; +import java.io.StringReader; +import java.util.List; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; + +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +public class XmlParserUtils { + + public static Document parse(String xml) throws ParserConfigurationException, SAXException, IOException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(new InputSource(new StringReader(xml))); + } + + public static Node getFirstElementByTagName(Document doc, String tagName) { + NodeList list = doc.getElementsByTagName(tagName); + if (list.getLength() > 0) { + return list.item(0); + } else { + return null; + } + } + + public static String getTextContent(Document doc, String tagName) { + Node node = getFirstElementByTagName(doc, tagName); + if (node != null) { + return node.getTextContent(); + } else { + return null; + } + } + + public static String getTextContent(Node parent, String tagName) { + Node node = findChildWithTagName(parent, tagName); + if (node != null) { + return node.getTextContent(); + } else { + return null; + } + } + + public static Node findChildWithTagName(Node parent, String tagName) { + if (parent == null) { + return null; + } + + NodeList childs = parent.getChildNodes(); + for (int i = 0; i < childs.getLength(); i++) { + Node child = childs.item(i); + if (child.getNodeName().equals(tagName)) { + return child; + } else if (child.hasChildNodes()) { + Node result = findChildWithTagName(child, tagName); + if (result != null) { + return result; + } + } + } + + return null; + } + + public static void getElementsByTagName(Node parent, String tagName, List result) { + if (parent == null) { + return; + } + + NodeList childs = parent.getChildNodes(); + for (int i = 0; i < childs.getLength(); i++) { + Node child = childs.item(i); + if (child.getNodeName().equals(tagName)) { + result.add(child); + } else if (child.hasChildNodes()) { + getElementsByTagName(child, tagName, result); + } + } + } + + public static String getStringWithXpath(String xml, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return xp.evaluate(xpath, new InputSource(new StringReader(xml))); + } + + public static String getStringWithXpath(Node node, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return xp.evaluate(xpath, node); + } + + public static Node getNodeWithXpath(String xml, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return (Node) xp.evaluate(xpath, new InputSource(new StringReader(xml)), XPathConstants.NODE); + } + + public static Node getNodeWithXpath(Node node, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return (Node) xp.evaluate(xpath, node, XPathConstants.NODE); + } + + public static NodeList getNodeListWithXpath(String xml, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return (NodeList) xp.evaluate(xpath, new InputSource(new StringReader(xml)), XPathConstants.NODESET); + } +} diff --git a/common/src/main/java/ctbrec/recorder/LocalRecorder.java b/common/src/main/java/ctbrec/recorder/LocalRecorder.java index 0732220b..6c9ab6df 100644 --- a/common/src/main/java/ctbrec/recorder/LocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/LocalRecorder.java @@ -4,6 +4,8 @@ import static ctbrec.Recording.State.*; import static ctbrec.event.Event.Type.*; import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FilenameFilter; import java.io.IOException; import java.nio.file.FileStore; @@ -11,6 +13,7 @@ import java.nio.file.Files; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.text.SimpleDateFormat; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -34,11 +37,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.eventbus.Subscribe; +import com.iheartradio.m3u8.Encoding; +import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; +import com.iheartradio.m3u8.PlaylistParser; +import com.iheartradio.m3u8.data.MediaPlaylist; +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.TrackData; import ctbrec.Config; import ctbrec.Model; +import ctbrec.MpegUtil; import ctbrec.OS; import ctbrec.Recording; import ctbrec.Recording.State; @@ -205,10 +216,14 @@ public class LocalRecorder implements Recorder { private void stopRecordingProcess(Model model) { Download download = recordingProcesses.get(model); - download.stop(); recordingProcesses.remove(model); fireRecordingStateChanged(download.getTarget(), STOPPED, model, download.getStartTime()); - ppThreadPool.submit(createPostProcessor(download)); + + Runnable stopAndThePostProcess = () -> { + download.stop(); + createPostProcessor(download).run(); + }; + ppThreadPool.submit(stopAndThePostProcess); } private void postprocess(Download download) { @@ -539,6 +554,10 @@ public class LocalRecorder implements Recorder { if (rec.listFiles().length == 0) { continue; } + // don't list recordings, which currently get deleted + if (deleteInProgress.contains(rec)) { + continue; + } Date startDate = sdf.parse(rec.getName()); Recording recording = new Recording(); @@ -740,9 +759,71 @@ public class LocalRecorder implements Recorder { fireRecordingStateChanged(download.getTarget(), GENERATING_PLAYLIST, download.getModel(), download.getStartTime()); generatePlaylist(download.getTarget()); } + boolean deleted = deleteIfTooShort(download); + if(deleted) { + // recording was too short. stop here and don't do post-processing + return; + } fireRecordingStateChanged(download.getTarget(), POST_PROCESSING, download.getModel(), download.getStartTime()); postprocess(download); fireRecordingStateChanged(download.getTarget(), FINISHED, download.getModel(), download.getStartTime()); }; } + + + // TODO maybe get file size and bitrate and check, if the values are plausible + // we could also compare the length with the time elapsed since starting the recording + private boolean deleteIfTooShort(Download download) { + long minimumLengthInSeconds = Config.getInstance().getSettings().minimumLengthInSeconds; + if(minimumLengthInSeconds <= 0) { + return false; + } + + try { + LOG.debug("Determining video length for {}", download.getTarget()); + File target = download.getTarget(); + double duration = 0; + if(target.isDirectory()) { + File playlist = new File(target, "playlist.m3u8"); + duration = getPlaylistLength(playlist); + } else { + duration = MpegUtil.getFileDuration(target); + } + Duration minLength = Duration.ofSeconds(minimumLengthInSeconds); + Duration videoLength = Duration.ofSeconds((long) duration); + LOG.debug("Recording started at:{}. Video length is {}", download.getStartTime(), videoLength); + if(videoLength.minus(minLength).isNegative()) { + LOG.debug("Video too short {} {}", videoLength, download.getTarget()); + LOG.debug("Deleting {}", target); + if(target.isDirectory()) { + deleteDirectory(target); + deleteEmptyParents(target); + } else { + Files.delete(target.toPath()); + deleteEmptyParents(target.getParentFile()); + } + return true; + } else { + return false; + } + } catch (Exception e) { + LOG.error("Couldn't check video length", e); + return false; + } + } + + private double getPlaylistLength(File playlist) throws IOException, ParseException, PlaylistException { + if(playlist.exists()) { + PlaylistParser playlistParser = new PlaylistParser(new FileInputStream(playlist), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); + Playlist m3u = playlistParser.parse(); + MediaPlaylist mediaPlaylist = m3u.getMediaPlaylist(); + double length = 0; + for (TrackData trackData : mediaPlaylist.getTracks()) { + length += trackData.getTrackInfo().duration; + } + return length; + } else { + throw new FileNotFoundException(playlist.getAbsolutePath() + " does not exist"); + } + } } diff --git a/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java b/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java index 2fec113c..1c02a614 100644 --- a/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java +++ b/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java @@ -6,24 +6,10 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; -import java.nio.channels.ReadableByteChannel; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import org.jcodec.common.Demuxer; -import org.jcodec.common.DemuxerTrack; -import org.jcodec.common.TrackType; -import org.jcodec.common.Tuple; -import org.jcodec.common.Tuple._2; -import org.jcodec.common.io.FileChannelWrapper; -import org.jcodec.common.io.NIOUtils; -import org.jcodec.common.model.Packet; -import org.jcodec.containers.mps.MPSDemuxer; -import org.jcodec.containers.mps.MTSDemuxer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,6 +26,8 @@ import com.iheartradio.m3u8.data.PlaylistType; import com.iheartradio.m3u8.data.TrackData; import com.iheartradio.m3u8.data.TrackInfo; +import ctbrec.MpegUtil; + public class PlaylistGenerator { private static final transient Logger LOG = LoggerFactory.getLogger(PlaylistGenerator.class); @@ -58,10 +46,8 @@ public class PlaylistGenerator { Arrays.sort(files, (f1, f2) -> { String n1 = f1.getName(); - int seq1 = getSequence(n1); String n2 = f2.getName(); - int seq2 = getSequence(n2); - return seq1 - seq2; + return n1.compareTo(n2); }); // create a track containing all files @@ -72,7 +58,7 @@ public class PlaylistGenerator { try { track.add(new TrackData.Builder() .withUri(file.getName()) - .withTrackInfo(new TrackInfo((float) getFileDuration(file), file.getName())) + .withTrackInfo(new TrackInfo((float) MpegUtil.getFileDuration(file), file.getName())) .build()); } catch(Exception e) { LOG.warn("Couldn't determine duration for {}. Skipping this file.", file.getName()); @@ -112,16 +98,6 @@ public class PlaylistGenerator { return output; } - private int getSequence(String filename) { - filename = filename.substring(0, filename.lastIndexOf('.')); // cut off file suffix - Matcher matcher = Pattern.compile(".*?(\\d+)").matcher(filename); - if(matcher.matches()) { - return Integer.parseInt(matcher.group(1)); - } else { - return -1; - } - } - private void updateProgressListeners(double percentage) { int p = (int) (percentage*100); if(p > lastPercentage) { @@ -141,45 +117,6 @@ public class PlaylistGenerator { return targetDuration; } - private double getFileDuration(File file) throws IOException { - try(FileChannelWrapper ch = NIOUtils.readableChannel(file)) { - _2 m2tsDemuxer = createM2TSDemuxer(ch, TrackType.VIDEO); - Demuxer demuxer = m2tsDemuxer.v1; - DemuxerTrack videoDemux = demuxer.getTracks().get(0); - Packet videoFrame = null; - double totalDuration = 0; - while( (videoFrame = videoDemux.nextFrame()) != null) { - totalDuration += videoFrame.getDurationD(); - } - return totalDuration; - } - } - - public static _2 createM2TSDemuxer(FileChannelWrapper ch, TrackType targetTrack) throws IOException { - MTSDemuxer mts = new MTSDemuxer(ch); - Set programs = mts.getPrograms(); - if (programs.size() == 0) { - LOG.error("The MPEG TS stream contains no programs"); - return null; - } - Tuple._2 found = null; - for (Integer pid : programs) { - ReadableByteChannel program = mts.getProgram(pid); - if (found != null) { - program.close(); - continue; - } - MPSDemuxer demuxer = new MPSDemuxer(program); - if (targetTrack == TrackType.AUDIO && demuxer.getAudioTracks().size() > 0 - || targetTrack == TrackType.VIDEO && demuxer.getVideoTracks().size() > 0) { - found = org.jcodec.common.Tuple._2(pid, (Demuxer) demuxer); - } else { - program.close(); - } - } - return found; - } - public void addProgressListener(ProgressListener l) { listeners.add(l); } diff --git a/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java index 1fb6333d..b4ab0507 100644 --- a/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java @@ -52,6 +52,9 @@ public abstract class AbstractHlsDownload implements Download { Request request = new Request.Builder().url(segmentsUrl).addHeader("connection", "keep-alive").build(); try(Response response = client.execute(request)) { if(response.isSuccessful()) { + // String body = response.body().string(); + // InputStream inputStream = new ByteArrayInputStream(body.getBytes("utf-8")); + // LOG.debug("Segments {}", body); InputStream inputStream = response.body().byteStream(); PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); diff --git a/common/src/main/java/ctbrec/recorder/download/HlsDownload.java b/common/src/main/java/ctbrec/recorder/download/HlsDownload.java index 94aa5a4d..9eef5d7c 100644 --- a/common/src/main/java/ctbrec/recorder/download/HlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/HlsDownload.java @@ -13,10 +13,13 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.text.DecimalFormat; +import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.time.Instant; import java.util.Date; import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +42,10 @@ public class HlsDownload extends AbstractHlsDownload { protected Path downloadDir; + private int segmentCounter = 1; + private NumberFormat nf = new DecimalFormat("000000"); + private Object downloadFinished = new Object(); + public HlsDownload(HttpClient client) { super(client); } @@ -69,18 +76,13 @@ public class HlsDownload extends AbstractHlsDownload { } int lastSegment = 0; int nextSegment = 0; + int waitFactor = 1; while(running) { SegmentPlaylist lsp = getNextSegments(segments); if(nextSegment > 0 && lsp.seq > nextSegment) { - LOG.warn("Missed segments {} < {} in download for {}", nextSegment, lsp.seq, model); - String first = lsp.segments.get(0); - int seq = lsp.seq; - for (int i = nextSegment; i < lsp.seq; i++) { - URL segmentUrl = new URL(first.replaceAll(Integer.toString(seq), Integer.toString(i))); - LOG.debug("Reloading segment {} for model {}", i, model.getName()); - downloadThreadPool.submit(new SegmentDownload(segmentUrl, downloadDir, client)); - } // TODO switch to a lower bitrate/resolution ?!? + waitFactor *= 2; + LOG.warn("Missed segments {} < {} in download for {} - setting wait factor to 1/{}", nextSegment, lsp.seq, model, waitFactor); } int skip = nextSegment - lsp.seq; for (String segment : lsp.segments) { @@ -88,7 +90,8 @@ public class HlsDownload extends AbstractHlsDownload { skip--; } else { URL segmentUrl = new URL(segment); - downloadThreadPool.submit(new SegmentDownload(segmentUrl, downloadDir, client)); + String prefix = nf.format(segmentCounter++); + downloadThreadPool.submit(new SegmentDownload(segmentUrl, downloadDir, client, prefix)); //new SegmentDownload(segment, downloadDir).call(); } } @@ -96,7 +99,7 @@ public class HlsDownload extends AbstractHlsDownload { long wait = 0; if(lastSegment == lsp.seq) { // playlist didn't change -> wait for at least half the target duration - wait = (long) lsp.targetDuration * 1000 / 2; + wait = (long) lsp.targetDuration * 1000 / waitFactor; LOG.trace("Playlist didn't change... waiting for {}ms", wait); } else { // playlist did change -> wait for at least last segment duration @@ -112,8 +115,12 @@ public class HlsDownload extends AbstractHlsDownload { } } + // this if check makes sure, that we don't decrease nextSegment. for some reason + // streamate playlists sometimes jump back. e.g. max sequence = 79 -> 80 -> 79 lastSegment = lsp.seq; - nextSegment = lastSegment + lsp.segments.size(); + if(lastSegment + lsp.segments.size() > nextSegment) { + nextSegment = lastSegment + lsp.segments.size(); + } } } else { throw new IOException("Couldn't determine segments uri"); @@ -134,7 +141,15 @@ public class HlsDownload extends AbstractHlsDownload { } catch(Exception e) { throw new IOException("Couldn't download segment", e); } finally { + downloadThreadPool.shutdown(); + try { + LOG.debug("Waiting for last segments for {}", model); + downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS); + } catch (InterruptedException e) {} alive = false; + synchronized (downloadFinished) { + downloadFinished.notifyAll(); + } LOG.debug("Download for {} terminated", model); } } @@ -142,7 +157,13 @@ public class HlsDownload extends AbstractHlsDownload { @Override public void stop() { running = false; - alive = false; + try { + synchronized (downloadFinished) { + downloadFinished.wait(); + } + } catch (InterruptedException e) { + LOG.error("Couldn't wait for download to finish", e); + } } private static class SegmentDownload implements Callable { @@ -150,11 +171,11 @@ public class HlsDownload extends AbstractHlsDownload { private Path file; private HttpClient client; - public SegmentDownload(URL url, Path dir, HttpClient client) { + public SegmentDownload(URL url, Path dir, HttpClient client, String prefix) { this.url = url; this.client = client; File path = new File(url.getPath()); - file = FileSystems.getDefault().getPath(dir.toString(), path.getName()); + file = FileSystems.getDefault().getPath(dir.toString(), prefix + '_' + path.getName()); } @Override diff --git a/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java index e298d48a..958fae17 100644 --- a/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java @@ -66,6 +66,7 @@ public class MergedHlsDownload extends AbstractHlsDownload { private BlockingQueue downloadQueue = new LinkedBlockingQueue<>(50); private ExecutorService downloadThreadPool = new ThreadPoolExecutor(5, 5, 2, TimeUnit.MINUTES, downloadQueue); private FileChannel fileChannel = null; + private Object downloadFinished = new Object(); public MergedHlsDownload(HttpClient client) { super(client); @@ -105,13 +106,20 @@ public class MergedHlsDownload extends AbstractHlsDownload { } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e) { throw new IOException("Couldn't add HMAC to playlist url", e); } finally { - alive = false; try { streamer.stop(); } catch(Exception e) { LOG.error("Couldn't stop streamer", e); } downloadThreadPool.shutdown(); + try { + LOG.debug("Waiting for last segments for {}", model); + downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS); + } catch (InterruptedException e) {} + alive = false; + synchronized (downloadFinished) { + downloadFinished.notifyAll(); + } LOG.debug("Download terminated for {}", segmentPlaylistUri); } } @@ -155,7 +163,6 @@ public class MergedHlsDownload extends AbstractHlsDownload { } catch(Exception e) { throw new IOException("Couldn't download segment", e); } finally { - alive = false; if(streamer != null) { try { streamer.stop(); @@ -163,6 +170,15 @@ public class MergedHlsDownload extends AbstractHlsDownload { LOG.error("Couldn't stop streamer", e); } } + downloadThreadPool.shutdown(); + try { + LOG.debug("Waiting for last segments for {}", model); + downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS); + } catch (InterruptedException e) {} + alive = false; + synchronized (downloadFinished) { + downloadFinished.notifyAll(); + } LOG.debug("Download for {} terminated", model); } } @@ -353,10 +369,16 @@ public class MergedHlsDownload extends AbstractHlsDownload { @Override public void stop() { running = false; - alive = false; if(streamer != null) { streamer.stop(); } + try { + synchronized (downloadFinished) { + downloadFinished.wait(); + } + } catch (InterruptedException e) { + LOG.error("Couldn't wait for download to finish", e); + } LOG.debug("Download stopped"); } diff --git a/common/src/main/java/ctbrec/sites/bonga/BongaCams.java b/common/src/main/java/ctbrec/sites/bonga/BongaCams.java index 573c4d67..52b2d084 100644 --- a/common/src/main/java/ctbrec/sites/bonga/BongaCams.java +++ b/common/src/main/java/ctbrec/sites/bonga/BongaCams.java @@ -157,15 +157,17 @@ public class BongaCams extends AbstractSite { JSONArray results = json.getJSONArray("models"); for (int i = 0; i < results.length(); i++) { JSONObject result = results.getJSONObject(i); - Model model = createModel(result.getString("username")); - String thumb = result.getString("thumb_image"); - if(thumb != null) { - model.setPreview("https:" + thumb); + if(result.has("username")) { + Model model = createModel(result.getString("username")); + String thumb = result.getString("thumb_image"); + if(thumb != null) { + model.setPreview("https:" + thumb); + } + if(result.has("display_name")) { + model.setDisplayName(result.getString("display_name")); + } + models.add(model); } - if(result.has("display_name")) { - model.setDisplayName(result.getString("display_name")); - } - models.add(model); } return models; } else { diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java index 9f5c26d6..cc4a3e06 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java @@ -277,7 +277,7 @@ public class MyFreeCamsClient { case ROOMDATA: LOG.debug("ROOMDATA: {}", message); case UEOPT: - LOG.debug("UEOPT: {}", message); + LOG.trace("UEOPT: {}", message); break; case SLAVEVSHARE: // LOG.debug("SLAVEVSHARE {}", message); @@ -295,7 +295,7 @@ public class MyFreeCamsClient { } break; default: - LOG.debug("Unknown message {}", message); + LOG.trace("Unknown message {}", message); break; } } @@ -420,6 +420,11 @@ public class MyFreeCamsClient { return; } + // uid not set, we can't identify this model + if(state.getUid() == null || state.getUid() <= 0) { + return; + } + MyFreeCamsModel model = models.getIfPresent(state.getUid()); if(model == null) { model = mfc.createModel(state.getNm()); diff --git a/common/src/main/java/ctbrec/sites/streamate/Streamate.java b/common/src/main/java/ctbrec/sites/streamate/Streamate.java new file mode 100644 index 00000000..a86eb91b --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamate/Streamate.java @@ -0,0 +1,201 @@ +package ctbrec.sites.streamate; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.sites.AbstractSite; +import okhttp3.Request; +import okhttp3.Response; + +public class Streamate extends AbstractSite { + + private static final transient Logger LOG = LoggerFactory.getLogger(Streamate.class); + + public static final String BASE_URL = "https://www.streamate.com"; + + private StreamateHttpClient httpClient; + + @Override + public String getName() { + return "Streamate"; + } + + @Override + public String getBaseUrl() { + return BASE_URL; + } + + @Override + public String getAffiliateLink() { + return BASE_URL + "/landing/click/?AFNO=2-11329.1"; + // return BASE_URL + "/landing/click/?AFNO=2-11330.2"; + } + + @Override + public Model createModel(String name) { + StreamateModel model = new StreamateModel(); + model.setName(name); + model.setUrl(BASE_URL + "/cam/" + name); + model.setDescription(""); + model.setSite(this); + return model; + } + + @Override + public Integer getTokenBalance() throws IOException { + // int userId = ((StreamateHttpClient)getHttpClient()).getUserId(); + // String url = Streamate.BASE_URL + "/tools/amf.php"; + // RequestBody body = new FormBody.Builder() + // .add("method", "ping") + // .add("args[]", Integer.toString(userId)) + // .build(); + // Request request = new Request.Builder() + // .url(url) + // .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + // .addHeader("Accept", "application/json, text/javascript, */*") + // .addHeader("Accept-Language", "en") + // .addHeader("Referer", Streamate.BASE_URL) + // .addHeader("X-Requested-With", "XMLHttpRequest") + // .post(body) + // .build(); + // try(Response response = getHttpClient().execute(request)) { + // if(response.isSuccessful()) { + // JSONObject json = new JSONObject(response.body().string()); + // if(json.optString("status").equals("online")) { + // JSONObject userData = json.getJSONObject("userData"); + // return userData.getInt("balance"); + // } else { + // throw new IOException("Request was not successful: " + json.toString(2)); + // } + // } else { + // throw new HttpException(response.code(), response.message()); + // } + // } + return 0; + } + + @Override + public String getBuyTokensLink() { + return getAffiliateLink(); + } + + @Override + public synchronized boolean login() throws IOException { + return credentialsAvailable() && getHttpClient().login(); + } + + @Override + public HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new StreamateHttpClient(); + } + return httpClient; + } + + @Override + public void init() throws IOException { + } + + @Override + public void shutdown() { + if (httpClient != null) { + httpClient.shutdown(); + } + } + + @Override + public boolean supportsTips() { + return false; + } + + @Override + public boolean supportsFollow() { + return true; + } + + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public boolean searchRequiresLogin() { + return false; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + String url = BASE_URL + "/api/search/autocomplete?exact=false&skin_search_kids=0&results_per_page=10&query=" + URLEncoder.encode(q, "utf-8"); + Request req = new Request.Builder().url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest").build(); + try (Response response = getHttpClient().execute(req)) { + if (response.isSuccessful()) { + String body = response.body().string(); + JSONObject json = new JSONObject(body); + if (json.optString("status").equals("SM_OK")) { + List models = new ArrayList<>(); + JSONObject results = json.getJSONObject("results"); + JSONArray nickname = results.getJSONArray("nickname"); + for (int i = 0; i < nickname.length(); i++) { + JSONObject result = nickname.getJSONObject(i); + StreamateModel model = (StreamateModel) createModel(result.getString("nickname")); + model.setId(Long.parseLong(result.getString("performerId"))); + String thumb = result.getString("thumbnail"); + if (thumb != null) { + model.setPreview(thumb); + } + model.setOnline(result.optString("liveStatus").equals("live")); + models.add(model); + } + return models; + } else { + LOG.warn("Search result: " + json.toString(2)); + return Collections.emptyList(); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof StreamateModel; + } + + @Override + public boolean credentialsAvailable() { + String username = Config.getInstance().getSettings().username; + return StringUtil.isNotBlank(username); + } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://.*?streamate.com/cam/([^/]*?)/?").matcher(url); + if (m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } +} diff --git a/common/src/main/java/ctbrec/sites/streamate/StreamateHttpClient.java b/common/src/main/java/ctbrec/sites/streamate/StreamateHttpClient.java new file mode 100644 index 00000000..3f056e73 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamate/StreamateHttpClient.java @@ -0,0 +1,144 @@ +package ctbrec.sites.streamate; + +import java.io.IOException; +import java.util.Collections; +import java.util.NoSuchElementException; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.io.HttpClient; +import okhttp3.Cookie; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class StreamateHttpClient extends HttpClient { + + private static final transient Logger LOG = LoggerFactory.getLogger(StreamateHttpClient.class); + + private Long userId; + private String saKey = ""; + private String userNickname = ""; + + public StreamateHttpClient() { + super("streamate"); + + // this cookie is needed for the search + Cookie searchCookie = new Cookie.Builder() + .domain("streamate.com") + .name("Xld_rct") + .value("1") + .build(); + getCookieJar().saveFromResponse(HttpUrl.parse(Streamate.BASE_URL), Collections.singletonList(searchCookie)); + + // try to load sakey from cookie + try { + Cookie cookie = getCookieJar().getCookie(HttpUrl.parse("https://www.streamate.com"), "sakey"); + saKey = cookie.value(); + } catch (NoSuchElementException e) { + // ignore + } + } + + @Override + public synchronized boolean login() throws IOException { + if(loggedIn) { + return true; + } + + boolean cookiesWorked = checkLoginSuccess(); + if(cookiesWorked) { + loggedIn = true; + LOG.debug("Logged in with cookies"); + return true; + } + + loggedIn = loginWithoutCookies(); + return loggedIn; + } + + private synchronized boolean loginWithoutCookies() throws IOException { + JSONObject loginRequest = new JSONObject(); + loginRequest.put("email", Config.getInstance().getSettings().streamateUsername); + loginRequest.put("password", Config.getInstance().getSettings().streamatePassword); + loginRequest.put("referrerId", 0); + loginRequest.put("siteId", 1); + RequestBody body = RequestBody.create(MediaType.parse("application/json"), loginRequest.toString()); + Request login = new Request.Builder() + .url(Streamate.BASE_URL + "/api/member/login") + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest") + .post(body) + .build(); + try (Response response = client.newCall(login).execute()) { + String content = response.body().string(); + if(response.isSuccessful()) { + JSONObject json = new JSONObject(content); + LOG.debug(json.toString(2)); + loggedIn = json.has("sakey"); + saKey = json.optString("sakey"); + JSONObject account = json.getJSONObject("account"); + userId = account.getLong("userid"); + userNickname = account.getString("nickname"); + } else { + throw new IOException("Login failed: " + response.code() + " " + response.message()); + } + response.close(); + } + + return loggedIn; + } + + /** + * Check, if the login worked by loading the favorites + */ + public boolean checkLoginSuccess() { + String url = Streamate.BASE_URL + "/api/search/v1/favorites?host=streamate.com&domain=streamate.com"; + url = url + "&page_number=1&results_per_page=48&sakey=" + saKey + "&userid=" + userId; + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL) + .build(); + try(Response response = execute(request)) { + if (response.isSuccessful()) { + String content = response.body().string(); + JSONObject json = new JSONObject(content); + if(json.optString("status").equals("SM_OK")) { + return true; + } else { + return false; + } + } else { + return false; + } + } catch(Exception e) { + return false; + } + } + + public String getSaKey() { + return saKey; + } + + public Long getUserId() throws IOException { + if(userId == null) { + loginWithoutCookies(); + } + return userId; + } + + public String getUserNickname() { + return userNickname; + } +} diff --git a/common/src/main/java/ctbrec/sites/streamate/StreamateModel.java b/common/src/main/java/ctbrec/sites/streamate/StreamateModel.java new file mode 100644 index 00000000..519bce2c --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamate/StreamateModel.java @@ -0,0 +1,342 @@ +package ctbrec.sites.streamate; + +import static ctbrec.Model.State.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; + +import ctbrec.AbstractModel; +import ctbrec.Config; +import ctbrec.io.HttpException; +import ctbrec.recorder.download.StreamSource; +import okhttp3.FormBody; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.Buffer; + +public class StreamateModel extends AbstractModel { + + private static final transient Logger LOG = LoggerFactory.getLogger(StreamateModel.class); + + private boolean online = false; + private List streamSources = new ArrayList<>(); + private int[] resolution; + private Long id; + private String streamId; + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + if(ignoreCache) { + String url = "https://sea1c-ls.naiadsystems.com/sea1c-edge-ls/80/live/s:" + getName() + ".json"; + Request req = new Request.Builder().url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "*/*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL + '/' + getName()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = site.getHttpClient().execute(req)) { + online = response.isSuccessful(); + } + } + return online; + } + + public void setOnline(boolean online) { + this.online = online; + } + + @Override + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if(failFast) { + return onlineState; + } else { + if(onlineState == UNKNOWN) { + return online ? ONLINE : OFFLINE; + } + return onlineState; + } + } + + @Override + public void setOnlineState(State onlineState) { + this.onlineState = onlineState; + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { + String url = "https://sea1c-ls.naiadsystems.com/sea1c-edge-ls/80/live/s:" + getName() + ".json"; + Request req = new Request.Builder().url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "*/*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL + '/' + getName()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = site.getHttpClient().execute(req)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + JSONObject formats = json.getJSONObject("formats"); + JSONObject hls = formats.getJSONObject("mp4-hls"); + + // add encodings + JSONArray encodings = hls.getJSONArray("encodings"); + streamSources.clear(); + for (int i = 0; i < encodings.length(); i++) { + JSONObject encoding = encodings.getJSONObject(i); + StreamSource src = new StreamSource(); + src.mediaPlaylistUrl = encoding.getString("location"); + src.width = encoding.optInt("videoWidth"); + src.height = encoding.optInt("videoHeight"); + src.bandwidth = (encoding.optInt("videoKbps") + encoding.optInt("audioKbps")) * 1024; + streamSources.add(src); + } + + // add raw source stream + if(formats.has("mp4-ws")) { + JSONObject ws = formats.getJSONObject("mp4-ws"); + JSONObject origin = hls.getJSONObject("origin"); + StreamSource src = new StreamSource(); + src.mediaPlaylistUrl = origin.getString("location"); + origin = ws.getJSONObject("origin"); // switch to web socket origin, because it has width, height and bitrates + src.width = origin.optInt("videoWidth"); + src.height = origin.optInt("videoHeight"); + src.bandwidth = (origin.optInt("videoKbps") + origin.optInt("audioKbps")) * 1024; + streamSources.add(src); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + return streamSources; + } + + @Override + public void invalidateCacheEntries() { + resolution = null; + } + + @Override + public void receiveTip(int tokens) throws IOException { + /* + Mt._giveGoldAjax = function(e, t) { + var n = _t.getState(), + a = n.nickname, + o = n.id, + i = Ds.getState(), + r = i.userStreamId, + s = i.sakey, + l = i.userId, + c = i.nickname, + u = ""; + switch (Ot.getState().streamType) { + case z.STREAM_TYPE_PRIVATE: + case z.STREAM_TYPE_BLOCK: + u = "premium"; + break; + case z.STREAM_TYPE_EXCLUSIVE: + case z.STREAM_TYPE_BLOCK_EXCLUSIVE: + u = "exclusive" + } + if (!l) return ae.a.reject("no userId!"); + var d = { + amt: e, + isprepopulated: t, + modelname: a, + nickname: c, + performernickname: a, + sakey: s, + session: u, + smid: o, + streamid: r, + userid: l, + username: c + }, + p = de.a.getBaseUrl() + "/api/v1/givegold/"; + return de.a.postPromise(p, d, "json") + }, + */ + + StreamateHttpClient client = (StreamateHttpClient) getSite().getHttpClient(); + client.login(); + String saKey = client.getSaKey(); + Long userId = client.getUserId(); + String nickname = client.getUserNickname(); + + String url = "https://hybridclient.naiadsystems.com/api/v1/givegold/"; // this returns 404 at the moment. not sure if it's the wrong server, or if this is not used anymore + RequestBody body = new FormBody.Builder() + .add("amt", Integer.toString(tokens)) // amount + .add("isprepopulated", "1") // ? + .add("modelname", getName()) // model's name + .add("nickname", nickname) // user's nickname + .add("performernickname", getName()) // model's name + .add("sakey", saKey) // sakey from login + .add("session", "") // is related to gold an private shows, for normal tips keep it empty + .add("smid", Long.toString(getId())) // model id + .add("streamid", getStreamId()) // id of the current stream + .add("userid", Long.toString(userId)) // user's id + .add("username", nickname) // user's nickname + .build(); + Buffer b = new Buffer(); + body.writeTo(b); + LOG.debug("tip params {}", b.readUtf8()); + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL + '/' + getName()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .post(body) + .build(); + try(Response response = site.getHttpClient().execute(request)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + LOG.debug(json.toString(2)); + if(!json.optString("status").equals("success")) { + LOG.error("Sending tip failed {}", json.toString(2)); + throw new IOException("Sending tip failed"); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private String getStreamId() throws IOException { + loadModelInfo(); + return streamId; + } + + void loadModelInfo() throws IOException { + String url = "https://hybridclient.naiadsystems.com/api/v1/config/?name=" + getName() + + "&sabasic=&sakey=&sk=www.streamate.com&userid=0&version=6.3.17&ajax=1"; + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL + '/' + getName()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = site.getHttpClient().execute(request)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + JSONObject stream = json.getJSONObject("stream"); + streamId = stream.getString("streamId"); + JSONObject performer = json.getJSONObject("performer"); + id = performer.getLong("id"); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + if(resolution == null) { + if(failFast) { + return new int[2]; + } + try { + if(!isOnline()) { + return new int[2]; + } + List streamSources = getStreamSources(); + Collections.sort(streamSources); + StreamSource best = streamSources.get(streamSources.size()-1); + resolution = new int[] {best.width, best.height}; + } catch (ExecutionException | IOException | ParseException | PlaylistException | InterruptedException e) { + LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); + } + return resolution; + } else { + return resolution; + } + } + + @Override + public boolean follow() throws IOException { + return follow(true); + } + + @Override + public boolean unfollow() throws IOException { + return follow(false); + } + + private boolean follow(boolean follow) throws IOException { + StreamateHttpClient client = (StreamateHttpClient) getSite().getHttpClient(); + client.login(); + String saKey = client.getSaKey(); + Long userId = client.getUserId(); + + JSONObject requestParams = new JSONObject(); + requestParams.put("sakey", saKey); + requestParams.put("userid", userId); + requestParams.put("pid", id); + requestParams.put("domain", "streamate.com"); + requestParams.put("fav", follow); + RequestBody body = RequestBody.create(MediaType.parse("application/json"), requestParams.toString()); + + String url = site.getBaseUrl() + "/ajax/fav-notify.php?userid="+userId+"&sakey="+saKey+"&pid="+id+"&fav="+follow+"&domain=streamate.com"; + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", getSite().getBaseUrl()) + .post(body) + .build(); + try(Response response = getSite().getHttpClient().execute(request)) { + String content = response.body().string(); + if (response.isSuccessful()) { + JSONObject json = new JSONObject(content); + return json.optBoolean("success"); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + @Override + public void readSiteSpecificData(JsonReader reader) throws IOException { + reader.nextName(); + id = reader.nextLong(); + } + + @Override + public void writeSiteSpecificData(JsonWriter writer) throws IOException { + if(id == null) { + try { + loadModelInfo(); + } catch (IOException e) { + LOG.error("Couldn't load model ID for {}. This can cause problems with saving / loading the model", getName()); + } + } + writer.name("id").value(id); + } +} \ No newline at end of file diff --git a/common/src/main/java/ctbrec/sites/streamate/StreamateWebsocketClient.java b/common/src/main/java/ctbrec/sites/streamate/StreamateWebsocketClient.java new file mode 100644 index 00000000..d13cde56 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamate/StreamateWebsocketClient.java @@ -0,0 +1,74 @@ +package ctbrec.sites.streamate; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.io.HttpClient; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; + +public class StreamateWebsocketClient { + + private static final transient Logger LOG = LoggerFactory.getLogger(StreamateWebsocketClient.class); + private String url; + private HttpClient client; + + public StreamateWebsocketClient(String url, HttpClient client) { + this.url = url; + this.client = client; + } + + String roomId = ""; + public String getRoomId() throws InterruptedException { + LOG.debug("Connecting to {}", url); + Object monitor = new Object(); + client.newWebSocket(url, new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, Response response) { + response.close(); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + if(text.contains("NaiadAuthorized")) { + Matcher m = Pattern.compile("\"roomid\":\"(.*?)\"").matcher(text); + if(m.find()) { + roomId = m.group(1); + webSocket.close(1000, ""); + } + } + } + + @Override + public void onMessage(WebSocket webSocket, ByteString bytes) { + LOG.debug("ws btxt {}", bytes.toString()); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + synchronized (monitor) { + monitor.notify(); + } + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + LOG.debug("ws failure", t); + response.close(); + synchronized (monitor) { + monitor.notify(); + } + } + }); + synchronized (monitor) { + monitor.wait(); + } + return roomId; + } +} + diff --git a/master/pom.xml b/master/pom.xml index 78ea5606..bc994b4a 100644 --- a/master/pom.xml +++ b/master/pom.xml @@ -6,7 +6,7 @@ ctbrec master pom - 1.15.0 + 1.16.0 ../common diff --git a/server/pom.xml b/server/pom.xml index 24075158..4b71406f 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -8,7 +8,7 @@ ctbrec master - 1.15.0 + 1.16.0 ../master diff --git a/server/src/main/java/ctbrec/recorder/server/HttpServer.java b/server/src/main/java/ctbrec/recorder/server/HttpServer.java index ee57211d..4262c6ff 100644 --- a/server/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/server/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -33,6 +33,7 @@ import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.sites.streamate.Streamate; public class HttpServer { @@ -82,6 +83,7 @@ public class HttpServer { sites.add(new Camsoda()); sites.add(new Cam4()); sites.add(new BongaCams()); + sites.add(new Streamate()); } private void addShutdownHook() {