diff --git a/CHANGELOG.md b/CHANGELOG.md index e0cc526e..d1a9af11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,21 @@ +NEXT +======================== +* Added "record later" tab to "bookmark" models +* Added config option to show the total number of models in the title bar +* Fixed problem with Cam4 playlist URLs, thanks @gohufrapoc + 3.11.0 ======================== * Added config option for faster scroll speed * Added a few more settings to the web interface +* Added config option to show confirmation dialogs for irreversible actions +* Disabled right click in context menus +* Fixed unjustified chaturbate follow / unfollow error dialog +* Use lowercase model names for Cam4. This should resolve recording problems * Updated Configration.md page in help section * Updated bundled Java to version 15.0.1 +* Improved robustness of live previews (still experimental though) +* Some smaller UI tweaks here and there 3.10.10 ======================== diff --git a/client/pom.xml b/client/pom.xml index 8dfdb547..3b8e2750 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -132,6 +132,7 @@ true 15 512 + -Dfile.encoding=utf-8 4.0.0.0 diff --git a/client/src/assembly/ctbrec-linux-jre.sh b/client/src/assembly/ctbrec-linux-jre.sh index 3a284a4c..848617d6 100755 --- a/client/src/assembly/ctbrec-linux-jre.sh +++ b/client/src/assembly/ctbrec-linux-jre.sh @@ -4,5 +4,5 @@ DIR="$(dirname "$0")" pushd "${DIR}" JAVA=./jre/bin/java $JAVA -version -$JAVA -Djdk.gtk.version=3 -cp "${DIR}:${name.final}.jar" ctbrec.ui.Launcher +$JAVA -Djdk.gtk.version=3 -cp "${DIR}:${name.final}.jar" -Dfile.encoding=utf-8 ctbrec.ui.Launcher popd diff --git a/client/src/assembly/ctbrec-linux.sh b/client/src/assembly/ctbrec-linux.sh index 4b73212a..2a95a1d7 100755 --- a/client/src/assembly/ctbrec-linux.sh +++ b/client/src/assembly/ctbrec-linux.sh @@ -4,5 +4,5 @@ DIR="$(dirname "$0")" pushd "${DIR}" JAVA=java $JAVA -version -$JAVA -Djdk.gtk.version=3 -cp "${DIR}:${name.final}.jar" ctbrec.ui.Launcher +$JAVA -Djdk.gtk.version=3 -cp "${DIR}:${name.final}.jar" -Dfile.encoding=utf-8 ctbrec.ui.Launcher popd diff --git a/client/src/assembly/ctbrec-macos-jre.sh b/client/src/assembly/ctbrec-macos-jre.sh index 2d857f9f..ef21243f 100755 --- a/client/src/assembly/ctbrec-macos-jre.sh +++ b/client/src/assembly/ctbrec-macos-jre.sh @@ -5,5 +5,5 @@ pushd "$DIR" JAVA_HOME="$DIR/jre/Contents/Home" JAVA="$JAVA_HOME/bin/java" $JAVA -version -$JAVA -cp "${DIR}:${name.final}.jar" ctbrec.ui.Launcher +$JAVA -cp "${DIR}:${name.final}.jar" -Dfile.encoding=utf-8 ctbrec.ui.Launcher popd \ No newline at end of file diff --git a/client/src/assembly/ctbrec-macos.sh b/client/src/assembly/ctbrec-macos.sh index 70363914..28f5d6fa 100755 --- a/client/src/assembly/ctbrec-macos.sh +++ b/client/src/assembly/ctbrec-macos.sh @@ -4,5 +4,5 @@ DIR=$(dirname $0) pushd "$DIR" JAVA=java $JAVA -version -$JAVA -cp "${DIR}:${name.final}.jar" ctbrec.ui.Launcher +$JAVA -cp "${DIR}:${name.final}.jar" -Dfile.encoding=utf-8 ctbrec.ui.Launcher popd \ No newline at end of file diff --git a/client/src/assembly/ctbrec.bat b/client/src/assembly/ctbrec.bat index d3b2eaed..2a95c05b 100644 --- a/client/src/assembly/ctbrec.bat +++ b/client/src/assembly/ctbrec.bat @@ -1 +1 @@ -jre\bin\java -Xmx512m -cp ".;${name.final}.jar" ctbrec.ui.Launcher \ No newline at end of file +jre\bin\java -Xmx512m -cp ".;${name.final}.jar" -Dfile.encoding=utf-8 ctbrec.ui.Launcher \ No newline at end of file diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 81704963..a68d493f 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -1,6 +1,8 @@ package ctbrec.ui; +import static ctbrec.event.Event.Type.*; + import java.io.BufferedReader; import java.io.File; import java.io.FileOutputStream; @@ -61,7 +63,7 @@ import ctbrec.ui.news.NewsTab; import ctbrec.ui.settings.SettingsTab; import ctbrec.ui.tabs.DonateTabFx; import ctbrec.ui.tabs.HelpTab; -import ctbrec.ui.tabs.RecordedModelsTab; +import ctbrec.ui.tabs.RecordedTab; import ctbrec.ui.tabs.RecordingsTab; import ctbrec.ui.tabs.SiteTab; import ctbrec.ui.tabs.TabSelectionListener; @@ -102,7 +104,7 @@ public class CamrecApplication extends Application { public static HttpClient httpClient; public static String title; private Stage primaryStage; - private RecordedModelsTab modelsTab; + private RecordedTab modelsTab; private RecordingsTab recordingsTab; private ScheduledExecutorService scheduler; private int activeRecordings = 0; @@ -210,7 +212,7 @@ public class CamrecApplication extends Application { } } - modelsTab = new RecordedModelsTab("Recording", recorder, sites); + modelsTab = new RecordedTab(recorder, sites); tabPane.getTabs().add(modelsTab); recordingsTab = new RecordingsTab("Recordings", recorder, config); tabPane.getTabs().add(recordingsTab); @@ -348,11 +350,12 @@ public class CamrecApplication extends Application { EventBusHolder.BUS.register(new Object() { @Subscribe public void handleEvent(Event evt) { - if(evt.getType() == Event.Type.MODEL_ONLINE || evt.getType() == Event.Type.MODEL_STATUS_CHANGED || evt.getType() == Event.Type.RECORDING_STATUS_CHANGED) { + if (evt.getType() == MODEL_ONLINE || evt.getType() == MODEL_STATUS_CHANGED || evt.getType() == RECORDING_STATUS_CHANGED) { try { - List models = recorder.getCurrentlyRecording(); - activeRecordings = models.size(); - String windowTitle = activeRecordings > 0 ? "(" + activeRecordings + ") " + title : title; + int modelCount = recorder.getModelCount(); + List currentlyRecording = recorder.getCurrentlyRecording(); + activeRecordings = currentlyRecording.size(); + String windowTitle = getActiveRecordings(activeRecordings, modelCount) + title; Platform.runLater(() -> primaryStage.setTitle(windowTitle)); updateStatus(); } catch (Exception e) { @@ -360,6 +363,19 @@ public class CamrecApplication extends Application { } } } + + private String getActiveRecordings(int activeRecordings, int modelCount) { + if (activeRecordings > 0) { + StringBuilder s = new StringBuilder("(").append(activeRecordings); + if (config.getSettings().totalModelCountInTitle) { + s.append("/").append(modelCount); + } + s.append(") "); + return s.toString(); + } else { + return ""; + } + } }); } @@ -377,7 +393,7 @@ public class CamrecApplication extends Application { bytesPerSecond = 0; } String humanReadable = ByteUnitFormatter.format(bytesPerSecond); - String status = String.format("Recording %s / %s models @ %s/s", activeRecordings, recorder.getModels().size(), humanReadable); + String status = String.format("Recording %s / %s models @ %s/s", activeRecordings, recorder.getModelCount(), humanReadable); Platform.runLater(() -> statusLabel.setText(status)); } diff --git a/client/src/main/java/ctbrec/ui/ClipboardListener.java b/client/src/main/java/ctbrec/ui/ClipboardListener.java index dc83cb96..d6ac591c 100644 --- a/client/src/main/java/ctbrec/ui/ClipboardListener.java +++ b/client/src/main/java/ctbrec/ui/ClipboardListener.java @@ -1,19 +1,18 @@ package ctbrec.ui; -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.util.List; -import java.util.Objects; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.Model; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; import javafx.application.Platform; import javafx.scene.input.Clipboard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Objects; public class ClipboardListener implements Runnable { @@ -54,7 +53,7 @@ public class ClipboardListener implements Runnable { Model m = site.createModelFromUrl(url); if (m != null) { try { - recorder.startRecording(m); + recorder.addModel(m); DesktopIntegration.notification("Add from clipboard", "Model added", "Model " + m.getDisplayName() + " added"); } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { DesktopIntegration.notification("Add from clipboard", "Error", "Couldn't add URL from clipboard: " + e.getLocalizedMessage()); diff --git a/client/src/main/java/ctbrec/ui/JavaFxModel.java b/client/src/main/java/ctbrec/ui/JavaFxModel.java index f2ad24c8..b160a0f1 100644 --- a/client/src/main/java/ctbrec/ui/JavaFxModel.java +++ b/client/src/main/java/ctbrec/ui/JavaFxModel.java @@ -319,4 +319,16 @@ public class JavaFxModel implements Model { public boolean isRecordingTimeLimited() { return delegate.isRecordingTimeLimited(); } + + @Override + public boolean isMarkedForLaterRecording() { + return delegate.isMarkedForLaterRecording(); + } + + @Override + public void setMarkedForLaterRecording(boolean marked) { + delegate.setMarkedForLaterRecording(marked); + } + + } diff --git a/client/src/main/java/ctbrec/ui/UiUtils.java b/client/src/main/java/ctbrec/ui/UiUtils.java index 3edeb2ba..a4e1cb96 100644 --- a/client/src/main/java/ctbrec/ui/UiUtils.java +++ b/client/src/main/java/ctbrec/ui/UiUtils.java @@ -1,5 +1,7 @@ package ctbrec.ui; +import javafx.geometry.Bounds; +import javafx.scene.Node; import javafx.scene.control.ContextMenu; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; @@ -15,4 +17,17 @@ public class UiUtils { } }); } + + public static void ignoreMouseReleasedIfMouseExited(ContextMenu menu) { + menu.addEventFilter(MouseEvent.MOUSE_RELEASED, evt -> { + if (evt.getTarget() instanceof Node) { + Node target = (Node) evt.getTarget(); + Bounds screenBounds = target.localToScreen(target.getBoundsInLocal()); + boolean releasedOnOriginalMouseItem = screenBounds.contains(evt.getScreenX(), evt.getScreenY()); + if (!releasedOnOriginalMouseItem) { + evt.consume(); + } + } + }); + } } diff --git a/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java b/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java index e9f3b188..cad69b2a 100644 --- a/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java +++ b/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java @@ -3,6 +3,9 @@ package ctbrec.ui.action; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,13 +29,15 @@ public class CheckModelAccountAction { } - public void execute() { + public void execute(Predicate filter) { String buttonText = b.getText(); b.setDisable(true); - Runnable checker = (() -> { + CompletableFuture.runAsync(() -> { List deletedAccounts = new ArrayList<>(); try { - List models = recorder.getModels(); + List models = recorder.getModels().stream() // + .filter(filter) // + .collect(Collectors.toList()); int total = models.size(); for (int i = 0; i < total; i++) { final int counter = i+1; @@ -66,6 +71,5 @@ public class CheckModelAccountAction { }); } }); - new Thread(checker).start(); } } diff --git a/client/src/main/java/ctbrec/ui/action/StartRecordingAction.java b/client/src/main/java/ctbrec/ui/action/StartRecordingAction.java index 0f92466c..30b46858 100644 --- a/client/src/main/java/ctbrec/ui/action/StartRecordingAction.java +++ b/client/src/main/java/ctbrec/ui/action/StartRecordingAction.java @@ -1,21 +1,21 @@ package ctbrec.ui.action; -import java.util.List; - import ctbrec.Model; import ctbrec.recorder.Recorder; import ctbrec.ui.controls.Dialogs; import javafx.application.Platform; import javafx.scene.Node; +import java.util.List; + public class StartRecordingAction extends ModelMassEditAction { public StartRecordingAction(Node source, List models, Recorder recorder) { super(source, models); action = (m) -> { try { - recorder.startRecording(m); - } catch(Exception e) { + recorder.addModel(m); + } catch (Exception e) { Platform.runLater(() -> Dialogs.showError(source.getScene(), "Couldn't start recording", "Starting recording of " + m.getName() + " failed", e)); } diff --git a/client/src/main/java/ctbrec/ui/controls/CustomMouseBehaviorContextMenu.java b/client/src/main/java/ctbrec/ui/controls/CustomMouseBehaviorContextMenu.java new file mode 100644 index 00000000..8b472c71 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/CustomMouseBehaviorContextMenu.java @@ -0,0 +1,14 @@ +package ctbrec.ui.controls; + +import ctbrec.ui.UiUtils; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; + +public class CustomMouseBehaviorContextMenu extends ContextMenu { + + public CustomMouseBehaviorContextMenu(MenuItem...items) { + super(items); + UiUtils.disableRightClickFor(this); + UiUtils.ignoreMouseReleasedIfMouseExited(this); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java index 3b469739..0dbb8449 100644 --- a/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java +++ b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java @@ -31,13 +31,6 @@ */ package ctbrec.ui.controls; -import java.net.URL; -import java.util.Objects; -import java.util.Optional; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.Model; import ctbrec.recorder.Recorder; import ctbrec.ui.action.PlayAction; @@ -47,15 +40,18 @@ import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.scene.Cursor; import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.ListCell; -import javafx.scene.control.ListView; -import javafx.scene.control.Skin; +import javafx.scene.control.*; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; import javafx.scene.shape.Rectangle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URL; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; /** * Popover page that displays a list of samples and sample categories for a given SampleCategory. @@ -167,7 +163,7 @@ public class SearchPopoverTreeList extends PopoverTreeList implements Pop follow = new Button("Follow"); follow.setOnAction(evt -> { setCursor(Cursor.WAIT); - new Thread(new Task() { + CompletableFuture.runAsync(new Task() { @Override protected Boolean call() throws Exception { model.getSite().login(); @@ -183,15 +179,15 @@ public class SearchPopoverTreeList extends PopoverTreeList implements Pop } Platform.runLater(() -> setCursor(Cursor.DEFAULT)); } - }).start(); + }); }); record = new Button("Record"); record.setOnAction(evt -> { setCursor(Cursor.WAIT); - new Thread(new Task() { + CompletableFuture.runAsync(new Task() { @Override protected Void call() throws Exception { - recorder.startRecording(model); + recorder.addModel(model); return null; } @@ -199,7 +195,7 @@ public class SearchPopoverTreeList extends PopoverTreeList implements Pop protected void done() { Platform.runLater(() -> setCursor(Cursor.DEFAULT)); } - }).start(); + }); }); getChildren().addAll(thumb, title, follow, record); diff --git a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java index b3cebec2..2c2605af 100644 --- a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java +++ b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java @@ -123,6 +123,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { private SimpleStringProperty path; private SimpleStringProperty downloadFilename; private SimpleBooleanProperty requireAuthentication; + private SimpleBooleanProperty totalModelCountInTitle; private SimpleBooleanProperty transportLayerSecurity; private SimpleBooleanProperty fastScrollSpeed; private ExclusiveSelectionProperty recordLocal; @@ -176,6 +177,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { downloadFilename = new SimpleStringProperty(null, "downloadFilename", settings.downloadFilename); requireAuthentication = new SimpleBooleanProperty(null, "requireAuthentication", settings.requireAuthentication); requireAuthentication.addListener(this::requireAuthenticationChanged); + totalModelCountInTitle = new SimpleBooleanProperty(null, "totalModelCountInTitle", settings.totalModelCountInTitle); transportLayerSecurity = new SimpleBooleanProperty(null, "transportLayerSecurity", settings.transportLayerSecurity); recordLocal = new ExclusiveSelectionProperty(null, "localRecording", settings.localRecording, "Local", "Remote"); postProcessingThreads = new SimpleIntegerProperty(null, "postProcessingThreads", settings.postProcessingThreads); @@ -208,6 +210,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { Setting.of("Add models from clipboard", monitorClipboard, "Monitor clipboard for model URLs and automatically add them to the recorder").needsRestart(), Setting.of("Fast scroll speed", fastScrollSpeed, "Makes the thumbnail overviews scroll faster with the mouse wheel").needsRestart(), Setting.of("Show confirmation dialogs", confirmationDialogs, "Show confirmation dialogs for irreversible actions"), + Setting.of("Total model count in title", totalModelCountInTitle, "Show the total number of models in the title bar"), Setting.of("Start Tab", startTab), Setting.of("Colors (Base / Accent)", new ColorSettingsPane(Config.getInstance())).needsRestart() ), diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4UpdateService.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4UpdateService.java index 6916f14a..f0537bb2 100644 --- a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4UpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4UpdateService.java @@ -33,7 +33,7 @@ import okhttp3.Response; public class Cam4UpdateService extends PaginatedScheduledService { - private static final transient Logger LOG = LoggerFactory.getLogger(Cam4UpdateService.class); + private static final Logger LOG = LoggerFactory.getLogger(Cam4UpdateService.class); private String url; private Cam4 site; private boolean loginRequired; @@ -86,6 +86,7 @@ public class Cam4UpdateService extends PaginatedScheduledService { String slug = path.substring(1); Cam4Model model = site.createModel(slug); String playlistUrl = profileLink.attr("data-hls-preview-url"); + model.setDisplayName(HtmlParser.getText(boxHtml, "div.profileBoxTitle a").trim()); model.setPlaylistUrl(playlistUrl); model.setPreview("https://snapshots.xcdnpro.com/thumbnails/" + model.getName() + "?s=" + System.currentTimeMillis()); model.setDescription(parseDesription(boxHtml)); diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaShowsTab.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaShowsTab.java index c36d0e8e..d4e0d342 100644 --- a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaShowsTab.java +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaShowsTab.java @@ -1,24 +1,5 @@ package ctbrec.ui.sites.camsoda; -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.time.temporal.TemporalAccessor; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -import org.json.JSONArray; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.Model; import ctbrec.recorder.Recorder; import ctbrec.sites.camsoda.Camsoda; @@ -32,14 +13,7 @@ import javafx.concurrent.Task; import javafx.geometry.Insets; import javafx.scene.Cursor; import javafx.scene.Node; -import javafx.scene.control.Alert; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.ProgressIndicator; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.Tab; -import javafx.scene.control.TitledPane; -import javafx.scene.control.Tooltip; +import javafx.scene.control.*; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; @@ -48,6 +22,25 @@ import javafx.scene.text.Font; import javafx.scene.text.FontWeight; import okhttp3.Request; import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; public class CamsodaShowsTab extends Tab implements TabSelectionListener { @@ -136,7 +129,7 @@ public class CamsodaShowsTab extends Tab implements TabSelectionListener { }); } }; - new Thread(task).start(); + CompletableFuture.runAsync(task); } @Override @@ -202,7 +195,7 @@ public class CamsodaShowsTab extends Tab implements TabSelectionListener { private void follow(Model model) { setCursor(Cursor.WAIT); - new Thread(() -> { + CompletableFuture.runAsync(() -> { try { SiteUiFactory.getUi(model.getSite()).login(); model.follow(); @@ -214,14 +207,14 @@ public class CamsodaShowsTab extends Tab implements TabSelectionListener { setCursor(Cursor.DEFAULT); }); } - }).start(); + }); } private void record(Model model) { setCursor(Cursor.WAIT); - new Thread(() -> { + CompletableFuture.runAsync(() -> { try { - recorder.startRecording(model); + recorder.addModel(model); } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) { showErrorDialog("Oh no!", "Couldn't add model to the recorder", "Recorder error: " + e.getMessage()); } finally { @@ -229,11 +222,11 @@ public class CamsodaShowsTab extends Tab implements TabSelectionListener { setCursor(Cursor.DEFAULT); }); } - }).start(); + }); } private void loadImage(Model model, ImageView thumb) { - new Thread(() -> { + CompletableFuture.runAsync(() -> { try { String url = camsoda.getBaseUrl() + "/api/v1/user/" + model.getName(); Request detailRequest = new Request.Builder().url(url).build(); @@ -270,7 +263,7 @@ public class CamsodaShowsTab extends Tab implements TabSelectionListener { } catch (Exception e) { LOG.error("Couldn't load model details", e); } - }).start(); + }); } private Node createLabel(String string, boolean bold) { diff --git a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedUpdateService.java index c183a9fc..452ea270 100644 --- a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedUpdateService.java @@ -30,12 +30,10 @@ public class LiveJasminFollowedUpdateService extends PaginatedScheduledService { private static final Logger LOG = LoggerFactory.getLogger(LiveJasminFollowedUpdateService.class); private LiveJasmin liveJasmin; private String url; - private boolean showOnline = true; public LiveJasminFollowedUpdateService(LiveJasmin liveJasmin) { this.liveJasmin = liveJasmin; this.url = liveJasmin.getBaseUrl() + "/en/member/favorite"; - //this.url = liveJasmin.getBaseUrl() + "/en/free/favourite/get-favourite-list?_dc=" + ts; } @Override @@ -43,15 +41,15 @@ public class LiveJasminFollowedUpdateService extends PaginatedScheduledService { return new Task>() { @Override public List call() throws IOException { - if(!liveJasmin.credentialsAvailable()) { + if (!liveJasmin.credentialsAvailable()) { throw new NotLoggedInExcetion("Credentials missing"); } boolean loggedIn = SiteUiFactory.getUi(liveJasmin).login(); - if(!loggedIn) { - throw new RuntimeException("Couldn't login to livejasmin"); + if (!loggedIn) { + throw new NotLoggedInExcetion("Couldn't login to livejasmin"); } - //LOG.debug("Fetching page {}", url); + LOG.debug("Fetching page {}", url); Request request = new Request.Builder() .url(url) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) @@ -80,8 +78,4 @@ public class LiveJasminFollowedUpdateService extends PaginatedScheduledService { } }; } - - public void setShowOnline(boolean showOnline) { - this.showOnline = showOnline; - } } diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTableTab.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTableTab.java index 8581ca7f..c09865fb 100644 --- a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTableTab.java +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTableTab.java @@ -45,6 +45,7 @@ import ctbrec.ui.DesktopIntegration; import ctbrec.ui.action.FollowAction; import ctbrec.ui.action.PlayAction; import ctbrec.ui.action.StartRecordingAction; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; import ctbrec.ui.controls.SearchBox; import ctbrec.ui.tabs.TabSelectionListener; import javafx.beans.property.BooleanProperty; @@ -341,7 +342,7 @@ public class MyFreeCamsTableTab extends Tab implements TabSelectionListener { MenuItem follow = new MenuItem("Follow"); follow.setOnAction(e -> new FollowAction(getTabPane(), selectedModels).execute()); - ContextMenu menu = new ContextMenu(); + ContextMenu menu = new CustomMouseBehaviorContextMenu(); menu.getItems().addAll(startRecording, copyUrl, openInPlayer, openInBrowser, follow); if (selectedModels.size() > 1) { @@ -467,7 +468,7 @@ public class MyFreeCamsTableTab extends Tab implements TabSelectionListener { } private void showColumnSelection(ActionEvent evt) { - ContextMenu menu = new ContextMenu(); + ContextMenu menu = new CustomMouseBehaviorContextMenu(); for (TableColumn tc : columns) { CheckMenuItem item = new CheckMenuItem(tc.getText()); item.setSelected(isColumnEnabled(tc)); diff --git a/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedUpdateService.java index 856cc00b..e3d6e4d0 100644 --- a/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedUpdateService.java +++ b/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedUpdateService.java @@ -14,6 +14,7 @@ import org.json.JSONObject; import ctbrec.Config; import ctbrec.Model; +import ctbrec.UnexpectedResponseException; import ctbrec.io.HttpException; import ctbrec.sites.showup.Showup; import ctbrec.sites.showup.ShowupModel; @@ -55,7 +56,7 @@ public class ShowupFollowedUpdateService extends PaginatedScheduledService { .filter(m -> onlineModels.containsKey(m.getName()) == showOnline) .collect(Collectors.toList()); } else { - throw new RuntimeException("Request was not successful: " + body); + throw new UnexpectedResponseException("Request was not successful: " + body); } } else { throw new HttpException(response.code(), response.message()); @@ -83,7 +84,7 @@ public class ShowupFollowedUpdateService extends PaginatedScheduledService { for (int i = 0; i < online.length(); i++) { JSONObject m = online.getJSONObject(i); String preview = site.getBaseUrl() + "/files/" + m.optString("big_img") + ".jpg"; - //onlineModels.put(m.optLong("uid"), preview); + onlineModels.put(String.valueOf(m.optLong("uid")), preview); } return onlineModels; } diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordLaterTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordLaterTab.java new file mode 100644 index 00000000..4f939985 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/RecordLaterTab.java @@ -0,0 +1,607 @@ +package ctbrec.ui.tabs; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.ui.AutosizeAlert; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.PreviewPopupHandler; +import ctbrec.ui.action.*; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.SearchBox; +import ctbrec.ui.controls.autocomplete.AutoFillTextField; +import ctbrec.ui.controls.autocomplete.ObservableListSuggester; +import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringPropertyBase; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.ScheduledService; +import javafx.concurrent.Task; +import javafx.concurrent.WorkerStateEvent; +import javafx.event.ActionEvent; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.*; +import javafx.scene.control.TableColumn.SortType; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.input.*; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.util.Callback; +import javafx.util.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +public class RecordLaterTab extends Tab implements TabSelectionListener { + private static final Logger LOG = LoggerFactory.getLogger(RecordLaterTab.class); + + private ReentrantLock lock = new ReentrantLock(); + private ScheduledService> updateService; + private Recorder recorder; + private List sites; + + FlowPane grid = new FlowPane(); + ScrollPane scrollPane = new ScrollPane(); + TableView table = new TableView<>(); + ObservableList observableModels = FXCollections.observableArrayList(); + ObservableList filteredModels = FXCollections.observableArrayList(); + ContextMenu popup; + + Label modelLabel = new Label("Model"); + AutoFillTextField model; + Button addModelButton = new Button("Record"); + Button checkModelAccountExistance = new Button("Check URLs"); + TextField filter; + + public RecordLaterTab(String title, Recorder recorder, List sites) { + super(title); + this.recorder = recorder; + this.sites = sites; + createGui(); + setClosable(false); + initializeUpdateService(); + } + + @SuppressWarnings("unchecked") + private void createGui() { + grid.setPadding(new Insets(5)); + grid.setHgap(5); + grid.setVgap(5); + + scrollPane.setContent(grid); + scrollPane.setFitToHeight(true); + scrollPane.setFitToWidth(true); + BorderPane.setMargin(scrollPane, new Insets(5)); + + table.setEditable(true); + table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + PreviewPopupHandler previewPopupHandler = new PreviewPopupHandler(table); + table.setRowFactory(tableview -> { + TableRow row = new TableRow<>(); + row.addEventHandler(MouseEvent.ANY, previewPopupHandler); + return row; + }); + TableColumn preview = new TableColumn<>("🎥"); + preview.setPrefWidth(35); + 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")); + name.setCellFactory(new ClickableCellFactory<>()); + name.setEditable(false); + name.setId("name"); + TableColumn url = new TableColumn<>("URL"); + url.setCellValueFactory(new PropertyValueFactory<>("url")); + url.setCellFactory(new ClickableCellFactory<>()); + url.setPrefWidth(400); + url.setEditable(false); + url.setId("url"); + TableColumn notes = new TableColumn<>("Notes"); + notes.setCellValueFactory(cdf -> { + JavaFxModel m = cdf.getValue(); + return new StringPropertyBase() { + @Override + public String getName() { + return "Model Notes"; + } + + @Override + public Object getBean() { + return null; + } + + @Override + public String get() { + String modelNotes = Config.getInstance().getModelNotes(m); + return modelNotes; + } + }; + }); + notes.setPrefWidth(400); + notes.setEditable(false); + notes.setId("notes"); + table.getColumns().addAll(preview, name, url, notes); + table.setItems(observableModels); + table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { + popup = createContextMenu(); + if (popup != null) { + popup.show(table, event.getScreenX(), event.getScreenY()); + } + event.consume(); + }); + table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { + if (popup != null) { + popup.hide(); + } + }); + table.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + List selectedModels = table.getSelectionModel().getSelectedItems(); + if (event.getCode() == KeyCode.DELETE) { + stopAction(selectedModels); + } else { + jumpToNextModel(event.getCode()); + } + }); + + scrollPane.setContent(table); + + HBox addModelBox = new HBox(5); + modelLabel.setPadding(new Insets(5, 0, 0, 0)); + ObservableList suggestions = FXCollections.observableArrayList(); + sites.forEach(site -> suggestions.add(site.getClass().getSimpleName())); + model = new AutoFillTextField(new ObservableListSuggester(suggestions)); + model.minWidth(150); + model.prefWidth(600); + HBox.setHgrow(model, Priority.ALWAYS); + model.setPromptText("e.g. MyFreeCams:ModelName or an URL like https://chaturbate.com/modelname/"); + model.onActionHandler(this::addModel); + model.setTooltip(new Tooltip("To add a model enter SiteName:ModelName\n" + "press ENTER to confirm a suggested site name")); + BorderPane.setMargin(addModelBox, new Insets(5)); + addModelButton.setOnAction(this::addModel); + addModelButton.setPadding(new Insets(5)); + addModelBox.getChildren().addAll(modelLabel, model, addModelButton, checkModelAccountExistance); + checkModelAccountExistance.setPadding(new Insets(5)); + checkModelAccountExistance.setTooltip(new Tooltip("Go over all model URLs and check, if the account still exists")); + HBox.setMargin(checkModelAccountExistance, new Insets(0, 0, 0, 20)); + checkModelAccountExistance.setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder) + .execute(Model::isMarkedForLaterRecording)); + + HBox filterContainer = new HBox(); + filterContainer.setSpacing(0); + filterContainer.setPadding(new Insets(0)); + filterContainer.setAlignment(Pos.CENTER_RIGHT); + filterContainer.minWidth(100); + filterContainer.prefWidth(150); + HBox.setHgrow(filterContainer, Priority.ALWAYS); + filter = new SearchBox(false); + filter.minWidth(100); + filter.prefWidth(150); + filter.setPromptText("Filter"); + filter.textProperty().addListener((observableValue, oldValue, newValue) -> { + String q = filter.getText(); + lock.lock(); + try { + filter(q); + } finally { + lock.unlock(); + } + }); + filter.getStyleClass().remove("search-box-icon"); + filterContainer.getChildren().add(filter); + addModelBox.getChildren().add(filterContainer); + + BorderPane root = new BorderPane(); + root.setPadding(new Insets(5)); + root.setTop(addModelBox); + root.setCenter(scrollPane); + setContent(root); + + restoreState(); + } + + private void jumpToNextModel(KeyCode code) { + if (!table.getItems().isEmpty() && (code.isLetterKey() || code.isDigitKey())) { + // determine where to start looking for the next model + int startAt = 0; + if (table.getSelectionModel().getSelectedIndex() >= 0) { + startAt = table.getSelectionModel().getSelectedIndex() + 1; + if (startAt >= table.getItems().size()) { + startAt = 0; + } + } + + String c = code.getChar().toLowerCase(); + int i = startAt; + do { + JavaFxModel current = table.getItems().get(i); + if (current.getName().toLowerCase().replaceAll("[^0-9a-z]", "").startsWith(c)) { + table.getSelectionModel().clearAndSelect(i); + table.scrollTo(i); + break; + } + + i++; + if (i >= table.getItems().size()) { + i = 0; + } + } while (i != startAt); + } + } + + private void addModel(ActionEvent e) { + String input = model.getText(); + if (StringUtil.isBlank(input)) { + return; + } + + if (input.startsWith("http")) { + addModelByUrl(input); + } else { + addModelByName(input); + } + } + + private void addModelByUrl(String url) { + for (Site site : sites) { + Model newModel = site.createModelFromUrl(url); + if (newModel != null) { + try { + newModel.setMarkedForLaterRecording(true); + recorder.addModel(newModel); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + Dialogs.showError(getTabPane().getScene(), "Couldn't add model", "The model " + newModel.getName() + " could not be added: ", e1); + } + return; + } + } + + Dialogs.showError(getTabPane().getScene(), "Unknown URL format", + "The URL you entered has an unknown format or the function does not support this site, yet", null); + } + + private void addModelByName(String siteModelCombo) { + String[] parts = siteModelCombo.trim().split(":"); + if (parts.length != 2) { + Dialogs.showError(getTabPane().getScene(), "Wrong input format", "Use something like \"MyFreeCams:ModelName\"", null); + return; + } + + String siteName = parts[0]; + String modelName = parts[1]; + for (Site site : sites) { + if (Objects.equals(siteName.toLowerCase(), site.getClass().getSimpleName().toLowerCase())) { + try { + Model m = site.createModel(modelName); + m.setMarkedForLaterRecording(true); + recorder.addModel(m); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + Dialogs.showError(getTabPane().getScene(), "Couldn't add model", "The model " + modelName + " could not be added:", e1); + } + return; + } + } + + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getTabPane().getScene()); + alert.setTitle("Unknown site"); + alert.setHeaderText("Couldn't add model"); + alert.setContentText("The site you entered is unknown"); + alert.showAndWait(); + } + + void initializeUpdateService() { + updateService = createUpdateService(); + updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2))); + updateService.setOnSucceeded(this::onUpdateSuccess); + updateService.setOnFailed(event -> LOG.info("Couldn't get list of models from recorder", event.getSource().getException())); + } + + private void onUpdateSuccess(WorkerStateEvent event) { + List updatedModels = updateService.getValue(); + if (updatedModels == null) { + return; + } + + lock.lock(); + try { + addOrUpdateModels(updatedModels); + + // remove old ones, which are not in the list of updated models + for (Iterator iterator = observableModels.iterator(); iterator.hasNext();) { + Model oldModel = iterator.next(); + if (!updatedModels.contains(oldModel)) { + iterator.remove(); + } + } + } finally { + lock.unlock(); + } + + filteredModels.clear(); + filter(filter.getText()); + table.sort(); + } + + private void addOrUpdateModels(List updatedModels) { + for (JavaFxModel updatedModel : updatedModels) { + int index = observableModels.indexOf(updatedModel); + if (index == -1) { + observableModels.add(updatedModel); + } else { + // make sure to update the JavaFX online property, so that the table cell is updated + JavaFxModel oldModel = observableModels.get(index); + oldModel.setSuspended(updatedModel.isSuspended()); + oldModel.getOnlineProperty().set(updatedModel.getOnlineProperty().get()); + oldModel.getRecordingProperty().set(updatedModel.getRecordingProperty().get()); + oldModel.lastRecordedProperty().set(updatedModel.lastRecordedProperty().get()); + oldModel.lastSeenProperty().set(updatedModel.lastSeenProperty().get()); + } + } + } + + private void filter(String filter) { + lock.lock(); + try { + if (StringUtil.isBlank(filter)) { + observableModels.addAll(filteredModels); + filteredModels.clear(); + return; + } + + String[] tokens = filter.split(" "); + observableModels.addAll(filteredModels); + filteredModels.clear(); + for (int i = 0; i < table.getItems().size(); i++) { + StringBuilder sb = new StringBuilder(); + for (TableColumn tc : table.getColumns()) { + Object cellData = tc.getCellData(i); + if (cellData != null) { + String content = cellData.toString(); + sb.append(content).append(' '); + } + } + String searchText = sb.toString(); + + boolean tokensMissing = false; + for (String token : tokens) { + if (!searchText.toLowerCase().contains(token.toLowerCase())) { + tokensMissing = true; + break; + } + } + if (tokensMissing) { + JavaFxModel filteredModel = table.getItems().get(i); + filteredModels.add(filteredModel); + } + } + observableModels.removeAll(filteredModels); + } finally { + lock.unlock(); + } + } + + private ScheduledService> createUpdateService() { + ScheduledService> modelUpdateService = new ScheduledService>() { + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws InvalidKeyException, NoSuchAlgorithmException, IOException { + LOG.trace("Updating models marked for later recording"); + return recorder.getModels().stream().filter(Model::isMarkedForLaterRecording).map(JavaFxModel::new).collect(Collectors.toList()); + } + }; + } + }; + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("RecordLaterTab UpdateService"); + return t; + }); + modelUpdateService.setExecutor(executor); + return modelUpdateService; + } + + @Override + public void selected() { + if (updateService != null) { + updateService.reset(); + updateService.restart(); + } + } + + @Override + public void deselected() { + if (updateService != null) { + updateService.cancel(); + } + } + + private ContextMenu createContextMenu() { + ObservableList selectedModels = table.getSelectionModel().getSelectedItems(); + if (selectedModels.isEmpty()) { + return null; + } + MenuItem start = new MenuItem("Start Recording"); + start.setOnAction(e -> startAction(selectedModels)); + MenuItem stop = new MenuItem("Remove Model"); + stop.setOnAction(e -> stopAction(selectedModels)); + + MenuItem copyUrl = new MenuItem("Copy URL"); + copyUrl.setOnAction(e -> { + Model selected = selectedModels.get(0); + final Clipboard clipboard = Clipboard.getSystemClipboard(); + final ClipboardContent content = new ClipboardContent(); + content.putString(selected.getUrl()); + clipboard.setContent(content); + }); + + MenuItem openInBrowser = new MenuItem("Open in Browser"); + openInBrowser.setOnAction(e -> DesktopIntegration.open(selectedModels.get(0).getUrl())); + MenuItem openInPlayer = new MenuItem("Open in Player"); + openInPlayer.setOnAction(e -> openInPlayer(selectedModels.get(0))); + MenuItem follow = new MenuItem("Follow"); + follow.setOnAction(e -> follow(selectedModels)); + follow.setDisable(!selectedModels.stream().allMatch(m -> m.getSite().supportsFollow() && m.getSite().credentialsAvailable())); + MenuItem ignore = new MenuItem("Ignore"); + ignore.setOnAction(e -> ignore(selectedModels)); + MenuItem notes = new MenuItem("Notes"); + notes.setOnAction(e -> notes(selectedModels)); + + ContextMenu menu = new CustomMouseBehaviorContextMenu(start, stop, copyUrl, openInPlayer, openInBrowser, follow, notes, ignore); + + if (selectedModels.size() > 1) { + copyUrl.setDisable(true); + openInPlayer.setDisable(true); + openInBrowser.setDisable(true); + notes.setDisable(true); + } + + return menu; + } + + private void ignore(ObservableList selectedModels) { + new IgnoreModelsAction(table, selectedModels, recorder, true).execute(); + } + + private void follow(ObservableList selectedModels) { + new FollowAction(getTabPane(), new ArrayList<>(selectedModels)).execute(); + } + + private void notes(ObservableList selectedModels) { + new EditNotesAction(getTabPane(), selectedModels.get(0), table).execute(); + } + + private void openInPlayer(JavaFxModel selectedModel) { + new PlayAction(getTabPane(), selectedModel).execute(); + } + + private void startAction(List selectedModels) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).collect(Collectors.toList()); + new ResumeAction(table, models, recorder).execute(); + } + + private void stopAction(List selectedModels) { + boolean confirmed = true; + if (Config.getInstance().getSettings().confirmationForDangerousActions) { + int n = selectedModels.size(); + String plural = n > 1 ? "s" : ""; + String header = "This will remove " + n + " model" + plural; + confirmed = Dialogs.showConfirmDialog("Remove From List", "Continue?", header, table.getScene()); + } + if (confirmed) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).collect(Collectors.toList()); + new StopRecordingAction(getTabPane(), models, recorder).execute(m -> Platform.runLater(() -> { + table.getSelectionModel().clearSelection(table.getItems().indexOf(m)); + table.getItems().remove(m); + })); + } + } + + public void saveState() { + if (!table.getSortOrder().isEmpty()) { + TableColumn col = table.getSortOrder().get(0); + Config.getInstance().getSettings().recordLaterSortColumn = col.getText(); + Config.getInstance().getSettings().recordLaterSortType = col.getSortType().toString(); + } + int columns = table.getColumns().size(); + double[] columnWidths = new double[columns]; + String[] columnIds = new String[columns]; + for (int i = 0; i < columnWidths.length; i++) { + columnWidths[i] = table.getColumns().get(i).getWidth(); + columnIds[i] = table.getColumns().get(i).getId(); + } + Config.getInstance().getSettings().recordLaterColumnWidths = columnWidths; + Config.getInstance().getSettings().recordLaterColumnIds = columnIds; + } + + private void restoreState() { + restoreColumnOrder(); + restoreColumnWidths(); + restoreSorting(); + } + + private void restoreSorting() { + String sortCol = Config.getInstance().getSettings().recordLaterSortColumn; + if (StringUtil.isNotBlank(sortCol)) { + for (TableColumn col : table.getColumns()) { + if (Objects.equals(sortCol, col.getText())) { + col.setSortType(SortType.valueOf(Config.getInstance().getSettings().recordLaterSortType)); + table.getSortOrder().clear(); + table.getSortOrder().add(col); + break; + } + } + } + } + + private void restoreColumnOrder() { + String[] columnIds = Config.getInstance().getSettings().recordLaterColumnIds; + ObservableList> columns = table.getColumns(); + for (int i = 0; i < columnIds.length; i++) { + for (int j = 0; j < table.getColumns().size(); j++) { + if(Objects.equals(columnIds[i], columns.get(j).getId())) { + TableColumn col = columns.get(j); + columns.remove(j); // NOSONAR + columns.add(i, col); + } + } + } + } + + private void restoreColumnWidths() { + double[] columnWidths = Config.getInstance().getSettings().recordLaterColumnWidths; + if (columnWidths != null && columnWidths.length == table.getColumns().size()) { + for (int i = 0; i < columnWidths.length; i++) { + table.getColumns().get(i).setPrefWidth(columnWidths[i]); + } + } + } + + private class ClickableCellFactory implements Callback, TableCell> { + @Override + public TableCell call(TableColumn param) { + TableCell cell = new TableCell<>() { + @Override + protected void updateItem(Object item, boolean empty) { + setText(empty ? "" : Objects.toString(item)); + } + }; + + cell.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + JavaFxModel selectedModel = table.getSelectionModel().getSelectedItem(); + if (selectedModel != null) { + new PlayAction(table, selectedModel).execute(); + } + } + }); + return cell; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java index a584d319..99555d6f 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordedModelsTab.java @@ -1,57 +1,14 @@ package ctbrec.ui.tabs; -import static ctbrec.Recording.State.*; -import static ctbrec.ui.UnicodeEmoji.*; - -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Function; -import java.util.stream.Collectors; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.Config; import ctbrec.Model; import ctbrec.Recording; import ctbrec.StringUtil; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; -import ctbrec.ui.AutosizeAlert; -import ctbrec.ui.DesktopIntegration; -import ctbrec.ui.JavaFxModel; -import ctbrec.ui.PreviewPopupHandler; -import ctbrec.ui.StreamSourceSelectionDialog; -import ctbrec.ui.UiUtils; -import ctbrec.ui.action.CheckModelAccountAction; -import ctbrec.ui.action.EditNotesAction; -import ctbrec.ui.action.FollowAction; -import ctbrec.ui.action.IgnoreModelsAction; -import ctbrec.ui.action.OpenRecordingsDir; -import ctbrec.ui.action.PauseAction; -import ctbrec.ui.action.PlayAction; -import ctbrec.ui.action.RemoveTimeLimitAction; -import ctbrec.ui.action.ResumeAction; -import ctbrec.ui.action.SetStopDateAction; -import ctbrec.ui.action.StopRecordingAction; -import ctbrec.ui.action.ToggleRecordingAction; +import ctbrec.ui.*; +import ctbrec.ui.action.*; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; import ctbrec.ui.controls.DateTimeCellFactory; import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.SearchBox; @@ -69,33 +26,13 @@ import javafx.concurrent.WorkerStateEvent; import javafx.event.ActionEvent; import javafx.geometry.Insets; import javafx.geometry.Pos; -import javafx.scene.control.Alert; -import javafx.scene.control.Button; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.Label; -import javafx.scene.control.MenuItem; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.SelectionMode; -import javafx.scene.control.Tab; -import javafx.scene.control.TableCell; -import javafx.scene.control.TableColumn; +import javafx.scene.control.*; import javafx.scene.control.TableColumn.CellEditEvent; import javafx.scene.control.TableColumn.SortType; -import javafx.scene.control.TableRow; -import javafx.scene.control.TableView; -import javafx.scene.control.TextField; -import javafx.scene.control.ToggleButton; -import javafx.scene.control.Tooltip; import javafx.scene.control.cell.CheckBoxTableCell; import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.control.cell.TextFieldTableCell; -import javafx.scene.input.Clipboard; -import javafx.scene.input.ClipboardContent; -import javafx.scene.input.ContextMenuEvent; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyEvent; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; +import javafx.scene.input.*; import javafx.scene.layout.BorderPane; import javafx.scene.layout.FlowPane; import javafx.scene.layout.HBox; @@ -104,6 +41,30 @@ import javafx.util.Callback; import javafx.util.Duration; import javafx.util.StringConverter; import javafx.util.converter.NumberStringConverter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static ctbrec.Recording.State.RECORDING; +import static ctbrec.ui.UnicodeEmoji.CLOCK; +import static ctbrec.ui.UnicodeEmoji.HEAVY_CHECK_MARK; public class RecordedModelsTab extends Tab implements TabSelectionListener { private static final Logger LOG = LoggerFactory.getLogger(RecordedModelsTab.class); @@ -297,7 +258,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { checkModelAccountExistance.setPadding(new Insets(5)); checkModelAccountExistance.setTooltip(new Tooltip("Go over all model URLs and check, if the account still exists")); HBox.setMargin(checkModelAccountExistance, new Insets(0, 0, 0, 20)); - checkModelAccountExistance.setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder).execute()); + checkModelAccountExistance.setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder) + .execute(Predicate.not(Model::isMarkedForLaterRecording))); HBox filterContainer = new HBox(); filterContainer.setSpacing(0); @@ -404,7 +366,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { Model newModel = site.createModelFromUrl(url); if (newModel != null) { try { - recorder.startRecording(newModel); + recorder.addModel(newModel); } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { Dialogs.showError(getTabPane().getScene(), "Couldn't add model", "The model " + newModel.getName() + " could not be added: ", e1); } @@ -429,7 +391,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { if (Objects.equals(siteName.toLowerCase(), site.getClass().getSimpleName().toLowerCase())) { try { Model m = site.createModel(modelName); - recorder.startRecording(m); + recorder.addModel(m); } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { Dialogs.showError(getTabPane().getScene(), "Couldn't add model", "The model " + modelName + " could not be added:", e1); } @@ -584,6 +546,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { List onlineModels = recorder.getOnlineModels(); return recorder.getModels() .stream() + .filter(Predicate.not(Model::isMarkedForLaterRecording)) .map(JavaFxModel::new) .peek(fxm -> { // NOSONAR for (Recording recording : recordings) { @@ -641,6 +604,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } MenuItem stop = new MenuItem("Remove Model"); stop.setOnAction(e -> stopAction(selectedModels)); + MenuItem recordLater = new MenuItem("Record Later"); + recordLater.setOnAction(e -> recordLater(selectedModels)); MenuItem copyUrl = new MenuItem("Copy URL"); copyUrl.setOnAction(e -> { @@ -675,8 +640,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { MenuItem openRecDir = new MenuItem("Open recording directory"); openRecDir.setOnAction(e -> new OpenRecordingsDir(table, selectedModels.get(0)).execute()); - ContextMenu menu = new ContextMenu(stop); - UiUtils.disableRightClickFor(menu); + ContextMenu menu = new CustomMouseBehaviorContextMenu(stop, recordLater); if (selectedModels.size() == 1) { menu.getItems().add(selectedModels.get(0).isSuspended() ? resumeRecording : pauseRecording); menu.getItems().add(stopRecordingAt); @@ -773,7 +737,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { alert.showAndWait(); } - private void stopAction(List selectedModels) { + private boolean stopAction(List selectedModels) { boolean confirmed = true; if (Config.getInstance().getSettings().confirmationForDangerousActions) { int n = selectedModels.size(); @@ -788,6 +752,21 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { table.getItems().remove(m); })); } + return confirmed; + } + + private void recordLater(List selectedModels) { + boolean confirmed = stopAction(selectedModels); + if (confirmed) { + List models = selectedModels.stream() + .map(JavaFxModel::getDelegate) + .map(m -> { + m.setMarkedForLaterRecording(true); + return m; + }) + .collect(Collectors.toList()); + new StartRecordingAction(table, models, recorder).execute(); + } } private void pauseRecording(List selectedModels) { diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java new file mode 100644 index 00000000..bc33c61a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java @@ -0,0 +1,61 @@ +package ctbrec.ui.tabs; + +import java.util.List; + +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Side; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; + +public class RecordedTab extends Tab implements TabSelectionListener { + + private TabPane tabPane; + private RecordedModelsTab recordedModelsTab; + private RecordLaterTab recordLaterTab; + + public RecordedTab(Recorder recorder, List sites) { + super("Recording"); + setClosable(false); + + recordedModelsTab = new RecordedModelsTab("Active", recorder, sites); + recordLaterTab = new RecordLaterTab("Later", recorder, sites); + + tabPane = new TabPane(); + tabPane.setSide(Side.LEFT); + tabPane.getTabs().addAll(recordedModelsTab, recordLaterTab); + setContent(tabPane); + + // register changelistener to activate / deactivate tabs, when the user switches between them + tabPane.getSelectionModel().selectedItemProperty().addListener((ChangeListener) (ov, from, to) -> { + if (from instanceof TabSelectionListener) { + ((TabSelectionListener) from).deselected(); + } + if (to instanceof TabSelectionListener) { + ((TabSelectionListener) to).selected(); + } + }); + } + + @Override + public void selected() { + Tab selectedTab = tabPane.getSelectionModel().getSelectedItem(); + if(selectedTab instanceof TabSelectionListener) { + ((TabSelectionListener) selectedTab).selected(); + } + } + + @Override + public void deselected() { + Tab selectedTab = tabPane.getSelectionModel().getSelectedItem(); + if(selectedTab instanceof TabSelectionListener) { + ((TabSelectionListener) selectedTab).deselected(); + } + } + + public void saveState() { + recordedModelsTab.saveState(); + recordLaterTab.saveState(); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java index c6f4563c..a65db11c 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java @@ -45,11 +45,11 @@ import ctbrec.ui.DesktopIntegration; import ctbrec.ui.FileDownload; import ctbrec.ui.JavaFxRecording; import ctbrec.ui.Player; -import ctbrec.ui.UiUtils; import ctbrec.ui.action.FollowAction; import ctbrec.ui.action.PauseAction; import ctbrec.ui.action.PlayAction; import ctbrec.ui.action.StopRecordingAction; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; import ctbrec.ui.controls.DateTimeCellFactory; import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.Toast; @@ -389,11 +389,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private ContextMenu createContextMenu(List recordings) { - ContextMenu contextMenu = new ContextMenu(); + ContextMenu contextMenu = new CustomMouseBehaviorContextMenu(); contextMenu.setHideOnEscape(true); contextMenu.setAutoHide(true); contextMenu.setAutoFix(true); - UiUtils.disableRightClickFor(contextMenu); JavaFxRecording first = recordings.get(0); MenuItem openInPlayer = new MenuItem("Open in Player"); diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java index aa2df6ee..8a76eea0 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java @@ -511,12 +511,13 @@ public class ThumbCell extends StackPane { new Thread(() -> { try { if (start) { - recorder.startRecording(model); - setRecording(true); + recorder.addModel(model); + setRecording(!model.isMarkedForLaterRecording()); } else { recorder.stopRecording(model); setRecording(false); } + update(); } catch (Exception e1) { LOG.error(COULDNT_START_STOP_RECORDING, e1); Dialogs.showError(getScene(), COULDNT_START_STOP_RECORDING, "I/O error while starting/stopping the recording: ", e1); @@ -561,6 +562,11 @@ public class ThumbCell extends StackPane { }); } + void recordLater() { + model.setMarkedForLaterRecording(true); + startStopAction(true); + } + public Model getModel() { return model; } @@ -684,6 +690,7 @@ public class ThumbCell extends StackPane { void addInPausedState() { model.setSuspended(true); + model.setMarkedForLaterRecording(false); startStopAction(true); } } diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java index ecfcf3de..ae0a1b9b 100644 --- a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java @@ -1,31 +1,5 @@ package ctbrec.ui.tabs; -import static ctbrec.ui.controls.Dialogs.*; - -import java.io.IOException; -import java.net.SocketTimeoutException; -import java.text.DecimalFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.Config; import ctbrec.Model; import ctbrec.event.EventBusHolder; @@ -33,24 +7,12 @@ import ctbrec.recorder.Recorder; import ctbrec.sites.Site; import ctbrec.sites.mfc.MyFreeCamsClient; import ctbrec.sites.mfc.MyFreeCamsModel; -import ctbrec.ui.AutosizeAlert; -import ctbrec.ui.DesktopIntegration; -import ctbrec.ui.SiteUiFactory; -import ctbrec.ui.TipDialog; -import ctbrec.ui.TokenLabel; -import ctbrec.ui.UiUtils; +import ctbrec.ui.*; import ctbrec.ui.action.IgnoreModelsAction; import ctbrec.ui.action.OpenRecordingsDir; import ctbrec.ui.action.SetStopDateAction; -import ctbrec.ui.controls.FasterVerticalScrollPaneSkin; -import ctbrec.ui.controls.SearchBox; -import ctbrec.ui.controls.SearchPopover; -import ctbrec.ui.controls.SearchPopoverTreeList; -import javafx.animation.FadeTransition; -import javafx.animation.Interpolator; -import javafx.animation.ParallelTransition; -import javafx.animation.ScaleTransition; -import javafx.animation.TranslateTransition; +import ctbrec.ui.controls.*; +import javafx.animation.*; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; @@ -66,33 +28,24 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.Parent; -import javafx.scene.control.Alert; -import javafx.scene.control.Button; -import javafx.scene.control.ComboBox; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.Label; -import javafx.scene.control.MenuItem; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.SeparatorMenuItem; -import javafx.scene.control.Tab; -import javafx.scene.control.TabPane; -import javafx.scene.control.TextField; -import javafx.scene.control.Tooltip; +import javafx.scene.control.*; import javafx.scene.image.ImageView; -import javafx.scene.input.Clipboard; -import javafx.scene.input.ClipboardContent; -import javafx.scene.input.ContextMenuEvent; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyEvent; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.FlowPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.StackPane; +import javafx.scene.input.*; +import javafx.scene.layout.*; import javafx.scene.transform.Transform; import javafx.util.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.text.DecimalFormat; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import static ctbrec.ui.controls.Dialogs.showError; public class ThumbOverviewTab extends Tab implements TabSelectionListener { private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTab.class); @@ -123,6 +76,8 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { SearchPopoverTreeList popoverTreeList = new SearchPopoverTreeList(); double imageAspectRatio = 3.0 / 4.0; private final SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true); + ProgressIndicator progressIndicator; + Label noResultsFound = new Label("Nothing found!"); private ComboBox thumbWidth; @@ -140,6 +95,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { grid.setHgap(5); grid.setVgap(5); + progressIndicator = new ProgressIndicator(); + progressIndicator.setPrefSize(100, 100); + SearchBox filterInput = new SearchBox(false); filterInput.setPromptText("Filter models on this page"); filterInput.textProperty().addListener( (observableValue, oldValue, newValue) -> { @@ -361,6 +319,8 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { gridLock.lock(); try { ObservableList nodes = grid.getChildren(); + nodes.remove(progressIndicator); + nodes.remove(noResultsFound); // first remove models, which are not in the updated list removeModelsMissingInUpdate(nodes, models); @@ -374,6 +334,11 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { // move models, which are tracked by the recorder to the front moveActiveRecordingsToFront(); + + // show "empty" label, if grid is still empty + if (grid.getChildren().isEmpty()) { + nodes.add(noResultsFound); + } } finally { gridLock.unlock(); } @@ -460,28 +425,29 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { private ContextMenu createContextMenu(ThumbCell cell) { Model model = cell.getModel(); boolean modelIsTrackedByRecorder = recorder.isTracked(model); + MenuItem openInPlayer = new MenuItem("Open in Player"); openInPlayer.setOnAction(e -> startPlayer(getSelectedThumbCells(cell))); MenuItem start = new MenuItem("Start Recording"); - start.setOnAction(e -> startStopAction(e, getSelectedThumbCells(cell), true)); + start.setOnAction(e -> startStopAction(getSelectedThumbCells(cell), true)); MenuItem stop = new MenuItem("Stop Recording"); - stop.setOnAction(e -> startStopAction(e, getSelectedThumbCells(cell), false)); - MenuItem startStop = recorder.isTracked(cell.getModel()) ? stop : start; + stop.setOnAction(e -> startStopAction(getSelectedThumbCells(cell), false)); + MenuItem startStop = recorder.isTracked(model) ? stop : start; MenuItem recordUntil = new MenuItem("Start Recording Until"); recordUntil.setOnAction(e -> startRecordingWithTimeLimit(getSelectedThumbCells(cell))); MenuItem addPaused = new MenuItem("Add in paused state"); addPaused.setOnAction(e -> addPaused(getSelectedThumbCells(cell))); MenuItem recordLater = new MenuItem("Record Later"); - recordLater.setOnAction(e -> LOG.debug("Record Later not implemented, yet")); + recordLater.setOnAction(e -> recordLater(getSelectedThumbCells(cell))); MenuItem pause = new MenuItem("Pause Recording"); pause.setOnAction(e -> pauseResumeAction(getSelectedThumbCells(cell), true)); MenuItem resume = new MenuItem("Resume Recording"); resume.setOnAction(e -> pauseResumeAction(getSelectedThumbCells(cell), false)); - MenuItem pauseResume = recorder.isSuspended(cell.getModel()) ? resume : pause; + MenuItem pauseResume = recorder.isSuspended(model) ? resume : pause; MenuItem follow = new MenuItem("Follow"); follow.setOnAction(e -> follow(getSelectedThumbCells(cell), true)); @@ -495,45 +461,54 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { refresh.setOnAction(e -> refresh()); MenuItem openRecDir = new MenuItem("Open recording directory"); - openRecDir.setOnAction(e -> new OpenRecordingsDir(cell, cell.getModel()).execute()); + openRecDir.setOnAction(e -> new OpenRecordingsDir(cell, model).execute()); MenuItem copyUrl = createCopyUrlMenuItem(cell); MenuItem sendTip = createTipMenuItem(cell); configureItemsForSelection(cell, openInPlayer, copyUrl, sendTip); - ContextMenu contextMenu = new ContextMenu(); + ContextMenu contextMenu = new CustomMouseBehaviorContextMenu(); contextMenu.setAutoHide(true); contextMenu.setHideOnEscape(true); contextMenu.setAutoFix(true); contextMenu.getItems().addAll(openInPlayer, new SeparatorMenuItem(), startStop); - UiUtils.disableRightClickFor(contextMenu); - if(modelIsTrackedByRecorder) { - contextMenu.getItems().add(pauseResume); + if (modelIsTrackedByRecorder) { + contextMenu.getItems().addAll(pauseResume, recordLater); } else { - contextMenu.getItems().addAll(recordUntil, addPaused/*, recordLater*/); + contextMenu.getItems().addAll(recordUntil, addPaused); + if (!recorder.isMarkedForLaterRecording(model)) { + contextMenu.getItems().add(recordLater); + } } contextMenu.getItems().add(new SeparatorMenuItem()); - if(site.supportsFollow()) { + if (site.supportsFollow()) { MenuItem followOrUnFollow = (this instanceof FollowedTab) ? unfollow : follow; followOrUnFollow.setDisable(!site.credentialsAvailable()); contextMenu.getItems().add(followOrUnFollow); } - if(site.supportsTips()) { + if (site.supportsTips()) { contextMenu.getItems().add(sendTip); } contextMenu.getItems().addAll(copyUrl, ignore, refresh, openRecDir); - if(cell.getModel() instanceof MyFreeCamsModel && Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + if (model instanceof MyFreeCamsModel && Objects.equals(System.getenv("CTBREC_DEV"), "1")) { MenuItem debug = new MenuItem("debug"); - debug.setOnAction(e -> MyFreeCamsClient.getInstance().getSessionState(cell.getModel())); + debug.setOnAction(e -> MyFreeCamsClient.getInstance().getSessionState(model)); contextMenu.getItems().add(debug); } return contextMenu; } + private void recordLater(List list) { + for (ThumbCell cell : list) { + cell.recordLater(); + } + } + private void startRecordingWithTimeLimit(List list) { for (ThumbCell cell : list) { + cell.getModel().setMarkedForLaterRecording(false); cell.startStopAction(true); new SetStopDateAction(cell, cell.getModel(), recorder).execute(); } @@ -691,8 +666,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { return 0; } - private void startStopAction(ActionEvent e, List selection, boolean start) { + private void startStopAction(List selection, boolean start) { for (ThumbCell thumbCell : selection) { + thumbCell.getModel().setSuspended(false); + thumbCell.getModel().setMarkedForLaterRecording(false); thumbCell.startStopAction(start); } } @@ -768,12 +745,14 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { // remove the ones from grid, which don't match for (Iterator iterator = grid.getChildren().iterator(); iterator.hasNext();) { Node node = iterator.next(); - ThumbCell cell = (ThumbCell) node; - Model m = cell.getModel(); - if(!matches(m, filter)) { - iterator.remove(); - filteredThumbCells.add(cell); - cell.setSelected(false); + if (node instanceof ThumbCell) { + ThumbCell cell = (ThumbCell) node; + Model m = cell.getModel(); + if (!matches(m, filter)) { + iterator.remove(); + filteredThumbCells.add(cell); + cell.setSelected(false); + } } } @@ -881,8 +860,12 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { @Override public void selected() { + grid.getChildren().remove(noResultsFound); + if (grid.getChildren().isEmpty()) { + grid.getChildren().add(progressIndicator); + } queue.clear(); - if(updateService != null) { + if (updateService != null) { State s = updateService.getState(); if (s != State.SCHEDULED && s != State.RUNNING) { updateService.reset(); @@ -901,7 +884,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { for (Iterator iterator = grid.getChildren().iterator(); iterator.hasNext();) { Node node = iterator.next(); - if(node instanceof ThumbCell) { + if (node instanceof ThumbCell) { ThumbCell thumbCell = (ThumbCell) node; thumbCell.releaseResources(); iterator.remove(); diff --git a/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java b/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java index 51af2896..7dbf38ac 100644 --- a/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java @@ -1,5 +1,10 @@ package ctbrec.ui.tabs; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import ctbrec.io.HttpException; import ctbrec.ui.CamrecApplication; import ctbrec.ui.CamrecApplication.Release; @@ -15,10 +20,8 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import okhttp3.Request; import okhttp3.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -public class UpdateTab extends Tab { +public class UpdateTab extends Tab implements TabSelectionListener { private static final Logger LOG = LoggerFactory.getLogger(UpdateTab.class); @@ -37,14 +40,17 @@ public class UpdateTab extends Tab { vbox.setAlignment(Pos.CENTER); changelog = new TextArea(); changelog.setEditable(false); + changelog.setText("Loading changelog..."); vbox.getChildren().add(changelog); VBox.setVgrow(changelog, Priority.ALWAYS); setContent(vbox); + } - new Thread(() -> { + public void loadChangeLog() { + CompletableFuture.runAsync(() -> { Request req = new Request.Builder().url("https://pastebin.com/raw/fiAPtM0s").build(); - try(Response resp = CamrecApplication.httpClient.execute(req)) { - if(resp.isSuccessful()) { + try (Response resp = CamrecApplication.httpClient.execute(req)) { + if (resp.isSuccessful()) { changelog.setText(resp.body().string()); } else { throw new HttpException(resp.code(), resp.message()); @@ -53,6 +59,16 @@ public class UpdateTab extends Tab { LOG.error("Couldn't download the changelog", e1); Dialogs.showError(getTabPane().getScene(), "Communication error", "Couldn't download the changelog", e1); } - }).start(); + }); + } + + @Override + public void selected() { + loadChangeLog(); + } + + @Override + public void deselected() { + // nothing to do } } diff --git a/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java b/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java index c52b9aa8..ec7fe254 100644 --- a/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java @@ -18,6 +18,7 @@ import ch.qos.logback.classic.spi.IThrowableProxy; import ch.qos.logback.classic.spi.LoggingEvent; import ch.qos.logback.classic.spi.StackTraceElementProxy; import ctbrec.event.EventBusHolder; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; import ctbrec.ui.controls.SearchBox; import javafx.application.Platform; import javafx.beans.property.SimpleStringProperty; @@ -150,7 +151,7 @@ public class LoggingTab extends Tab { }); }); - ContextMenu menu = new ContextMenu(copy); + ContextMenu menu = new CustomMouseBehaviorContextMenu(copy); return menu; } diff --git a/client/src/main/resources/logback.xml b/client/src/main/resources/logback.xml index cf7ea0a9..dbd3a502 100644 --- a/client/src/main/resources/logback.xml +++ b/client/src/main/resources/logback.xml @@ -43,6 +43,9 @@ + + + diff --git a/common/.classpath b/common/.classpath index 3c5e7d17..13fa9dd6 100644 --- a/common/.classpath +++ b/common/.classpath @@ -6,11 +6,7 @@ - - - - - + diff --git a/common/.settings/org.eclipse.jdt.core.prefs b/common/.settings/org.eclipse.jdt.core.prefs index 8b5c4dca..3a0745fd 100644 --- a/common/.settings/org.eclipse.jdt.core.prefs +++ b/common/.settings/org.eclipse.jdt.core.prefs @@ -11,6 +11,6 @@ org.eclipse.jdt.core.compiler.problem.assertIdentifier=error org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled org.eclipse.jdt.core.compiler.problem.enumIdentifier=error org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning -org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore +org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning org.eclipse.jdt.core.compiler.release=disabled org.eclipse.jdt.core.compiler.source=1.8 diff --git a/common/src/main/java/ctbrec/AbstractModel.java b/common/src/main/java/ctbrec/AbstractModel.java index 8c16d499..b0ab9814 100644 --- a/common/src/main/java/ctbrec/AbstractModel.java +++ b/common/src/main/java/ctbrec/AbstractModel.java @@ -33,6 +33,7 @@ public abstract class AbstractModel implements Model { private int streamUrlIndex = -1; private int priority = 50; private boolean suspended = false; + private boolean markedForLaterRecording = false; protected transient Site site; protected State onlineState = State.UNKNOWN; private Instant lastSeen; @@ -145,6 +146,16 @@ public abstract class AbstractModel implements Model { this.suspended = suspended; } + @Override + public boolean isMarkedForLaterRecording() { + return markedForLaterRecording; + } + + @Override + public void setMarkedForLaterRecording(boolean marked) { + this.markedForLaterRecording = marked; + } + @Override public State getOnlineState(boolean failFast) throws IOException, ExecutionException { return onlineState; diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java index a777a69b..793c4cb0 100644 --- a/common/src/main/java/ctbrec/Config.java +++ b/common/src/main/java/ctbrec/Config.java @@ -174,6 +174,13 @@ public class Config { iterator.remove(); } } + // 3.11.0 make Cam4 model names lower case + settings.models.stream() + .filter(m -> m instanceof Cam4Model) + .forEach(m -> m.setName(m.getName().toLowerCase())); + settings.modelsIgnored.stream() + .filter(m -> m instanceof Cam4Model) + .forEach(m -> m.setName(m.getName().toLowerCase())); } private void makeBackup(File source) { diff --git a/common/src/main/java/ctbrec/Model.java b/common/src/main/java/ctbrec/Model.java index 1a75a170..551ac5c4 100644 --- a/common/src/main/java/ctbrec/Model.java +++ b/common/src/main/java/ctbrec/Model.java @@ -122,6 +122,10 @@ public interface Model extends Comparable, Serializable { public void setSuspended(boolean suspended); + public boolean isMarkedForLaterRecording(); + + public void setMarkedForLaterRecording(boolean marked); + public Download createDownload(); public void setPriority(int priority); diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index d120ba3c..73ed55bb 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -118,6 +118,10 @@ public class Settings { public String proxyPort; public ProxyType proxyType = ProxyType.DIRECT; public String proxyUser; + public double[] recordLaterColumnWidths = new double[0]; + public String[] recordLaterColumnIds = new String[0]; + public String recordLaterSortColumn = ""; + public String recordLaterSortType = ""; public double[] recordedModelsColumnWidths = new double[0]; public String[] recordedModelsColumnIds = new String[0]; public String recordedModelsSortColumn = ""; @@ -128,6 +132,7 @@ public class Settings { public DirectoryStructure recordingsDirStructure = DirectoryStructure.FLAT; public String recordingsSortColumn = ""; public String recordingsSortType = ""; + public List recordLater = new ArrayList<>(); public boolean recordSingleFile = false; public boolean removeRecordingAfterPostProcessing = false; public boolean requireAuthentication = false; @@ -147,6 +152,7 @@ public class Settings { public String stripchatUsername = ""; public String stripchatPassword = ""; public boolean stripchatUseXhamster = false; + public boolean totalModelCountInTitle = false; public boolean transportLayerSecurity = true; public int thumbWidth = 180; public boolean updateThumbnails = true; diff --git a/common/src/main/java/ctbrec/UnexpectedResponseException.java b/common/src/main/java/ctbrec/UnexpectedResponseException.java new file mode 100644 index 00000000..a06cecaf --- /dev/null +++ b/common/src/main/java/ctbrec/UnexpectedResponseException.java @@ -0,0 +1,9 @@ +package ctbrec; + +public class UnexpectedResponseException extends RuntimeException { + + public UnexpectedResponseException(String msg) { + super(msg); + } + +} diff --git a/common/src/main/java/ctbrec/io/HttpClient.java b/common/src/main/java/ctbrec/io/HttpClient.java index 583f366b..53eb8a85 100644 --- a/common/src/main/java/ctbrec/io/HttpClient.java +++ b/common/src/main/java/ctbrec/io/HttpClient.java @@ -1,7 +1,15 @@ package ctbrec.io; -import static java.nio.charset.StandardCharsets.*; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import ctbrec.Config; +import ctbrec.Settings.ProxyType; +import okhttp3.*; +import okhttp3.OkHttpClient.Builder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.net.ssl.*; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -13,39 +21,11 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.Map.Entry; -import java.util.Objects; -import java.util.Set; import java.util.concurrent.TimeUnit; -import javax.net.ssl.KeyManager; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; - -import ctbrec.Config; -import ctbrec.Settings.ProxyType; -import okhttp3.ConnectionPool; -import okhttp3.Cookie; -import okhttp3.Credentials; -import okhttp3.OkHttpClient; -import okhttp3.OkHttpClient.Builder; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.Route; -import okhttp3.WebSocket; -import okhttp3.WebSocketListener; +import static java.nio.charset.StandardCharsets.UTF_8; public abstract class HttpClient { private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class); @@ -293,4 +273,12 @@ public abstract class HttpClient { } return result; } + + public static String bodyToJsonObject(Response response) { + return Optional.ofNullable(response.body()).map(Object::toString).orElse("{}"); + } + + public static String bodyToJsonArray(Response response) { + return Optional.ofNullable(response.body()).map(Object::toString).orElse("[]"); + } } diff --git a/common/src/main/java/ctbrec/io/ModelJsonAdapter.java b/common/src/main/java/ctbrec/io/ModelJsonAdapter.java index a15172ff..7f585968 100644 --- a/common/src/main/java/ctbrec/io/ModelJsonAdapter.java +++ b/common/src/main/java/ctbrec/io/ModelJsonAdapter.java @@ -35,51 +35,41 @@ public class ModelJsonAdapter extends JsonAdapter { @Override public Model fromJson(JsonReader reader) throws IOException { reader.beginObject(); - String name = null; - String description = null; - String url = null; Object type = null; - int streamUrlIndex = -1; - int priority; - boolean suspended = false; - Model model = null; - while(reader.hasNext()) { + + while (reader.hasNext()) { try { Token token = reader.peek(); - if(token == Token.NAME) { + if (token == Token.NAME) { String key = reader.nextName(); - if(key.equals("type")) { + if (key.equals("type")) { type = reader.readJsonValue(); Class modelClass = Class.forName(Optional.ofNullable(type).orElse(ChaturbateModel.class.getName()).toString()); model = (Model) modelClass.getDeclaredConstructor().newInstance(); - } else if(key.equals("name")) { - name = reader.nextString(); - model.setName(name); - } else if(key.equals("description")) { - description = reader.nextString(); - model.setDescription(description); - } else if(key.equals("url")) { - url = reader.nextString(); - model.setUrl(url); - } else if(key.equals("priority")) { - priority = reader.nextInt(); - model.setPriority(priority); - } else if(key.equals("streamUrlIndex")) { - streamUrlIndex = reader.nextInt(); - model.setStreamUrlIndex(streamUrlIndex); - } else if(key.equals("suspended")) { - suspended = reader.nextBoolean(); - model.setSuspended(suspended); - } else if(key.equals("lastSeen")) { + } else if (key.equals("name")) { + model.setName(reader.nextString()); + } else if (key.equals("description")) { + model.setDescription(reader.nextString()); + } else if (key.equals("url")) { + model.setUrl(reader.nextString()); + } else if (key.equals("priority")) { + model.setPriority(reader.nextInt()); + } else if (key.equals("streamUrlIndex")) { + model.setStreamUrlIndex(reader.nextInt()); + } else if (key.equals("suspended")) { + model.setSuspended(reader.nextBoolean()); + } else if (key.equals("markedForLater")) { + model.setMarkedForLaterRecording(reader.nextBoolean()); + } else if (key.equals("lastSeen")) { model.setLastSeen(Instant.ofEpochMilli(reader.nextLong())); - } else if(key.equals("lastRecorded")) { + } else if (key.equals("lastRecorded")) { model.setLastRecorded(Instant.ofEpochMilli(reader.nextLong())); - } else if(key.equals("recordUntil")) { + } else if (key.equals("recordUntil")) { model.setRecordUntil(Instant.ofEpochMilli(reader.nextLong())); - } else if(key.equals("recordUntilSubsequentAction")) { + } else if (key.equals("recordUntilSubsequentAction")) { model.setRecordUntilSubsequentAction(SubsequentAction.valueOf(reader.nextString())); - } else if(key.equals("siteSpecific")) { + } else if (key.equals("siteSpecific")) { reader.beginObject(); try { model.readSiteSpecificData(reader); @@ -92,15 +82,16 @@ public class ModelJsonAdapter extends JsonAdapter { } else { reader.skipValue(); } - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) { + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { throw new IOException("Couldn't instantiate model class [" + type + "]", e); } } reader.endObject(); - if(sites != null) { + if (sites != null) { for (Site site : sites) { - if(site.isSiteForModel(model)) { + if (site.isSiteForModel(model)) { model.setSite(site); } } @@ -118,6 +109,7 @@ public class ModelJsonAdapter extends JsonAdapter { writer.name("priority").value(model.getPriority()); writer.name("streamUrlIndex").value(model.getStreamUrlIndex()); writer.name("suspended").value(model.isSuspended()); + writer.name("markedForLater").value(model.isMarkedForLaterRecording()); writer.name("lastSeen").value(model.getLastSeen().toEpochMilli()); writer.name("lastRecorded").value(model.getLastRecorded().toEpochMilli()); writer.name("recordUntil").value(model.getRecordUntil().toEpochMilli()); @@ -130,7 +122,7 @@ public class ModelJsonAdapter extends JsonAdapter { } private void writeValueIfSet(JsonWriter writer, String name, String value) throws IOException { - if(value != null) { + if (value != null) { writer.name(name).value(value); } } diff --git a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java index a7d5e57d..2d7c2d24 100644 --- a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java @@ -244,8 +244,13 @@ public class NextGenLocalRecorder implements Recorder { } @Override - public void startRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { - if (!models.contains(model)) { + public void addModel(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + Optional existing = findModel(model); + if (existing.isPresent()) { + existing.get().setSuspended(model.isSuspended()); + existing.get().setMarkedForLaterRecording(model.isMarkedForLaterRecording()); + startRecordingProcess(existing.get()); + } else { LOG.info("Model {} added", model); recorderLock.lock(); try { @@ -257,7 +262,6 @@ public class NextGenLocalRecorder implements Recorder { } finally { recorderLock.unlock(); } - startRecordingProcess(model); } } @@ -418,16 +422,6 @@ public class NextGenLocalRecorder implements Recorder { } } - @Override - public boolean isTracked(Model model) { - recorderLock.lock(); - try { - return models.contains(model); - } finally { - recorderLock.unlock(); - } - } - @Override public List getModels() { recorderLock.lock(); @@ -540,7 +534,9 @@ public class NextGenLocalRecorder implements Recorder { int index = models.indexOf(model); Model m = models.get(index); m.setSuspended(false); + m.setMarkedForLaterRecording(false); model.setSuspended(false); + model.setMarkedForLaterRecording(false); config.save(); startRecordingProcess(m); } else { @@ -551,16 +547,31 @@ public class NextGenLocalRecorder implements Recorder { } } + @Override + public boolean isTracked(Model model) { + Optional m = findModel(model); + boolean markedForRecording = m.map(Model::isMarkedForLaterRecording).orElse(false); + return m.isPresent() && !markedForRecording; + } + @Override public boolean isSuspended(Model model) { + return findModel(model).map(Model::isSuspended).orElse(false); + } + + @Override + public boolean isMarkedForLaterRecording(Model model) { + return findModel(model).map(Model::isMarkedForLaterRecording).orElse(false); + } + + private Optional findModel(Model m) { recorderLock.lock(); try { - int index = models.indexOf(model); + int index = models.indexOf(m); if (index >= 0) { - Model m = models.get(index); - return m.isSuspended(); + return Optional.of(models.get(index)); } else { - return false; + return Optional.empty(); } } finally { recorderLock.unlock(); @@ -763,4 +774,9 @@ public class NextGenLocalRecorder implements Recorder { LOG.info("Resuming recorder"); recording = true; } + + @Override + public int getModelCount() { + return (int) models.stream().filter(m -> !m.isMarkedForLaterRecording()).count(); + } } diff --git a/common/src/main/java/ctbrec/recorder/OnlineMonitor.java b/common/src/main/java/ctbrec/recorder/OnlineMonitor.java index 166fdf32..93a4a93c 100644 --- a/common/src/main/java/ctbrec/recorder/OnlineMonitor.java +++ b/common/src/main/java/ctbrec/recorder/OnlineMonitor.java @@ -82,7 +82,9 @@ public class OnlineMonitor extends Thread { // submit online check jobs to the executor for the model's site List> futures = new LinkedList<>(); for (Model model : models) { - if (config.getSettings().onlineCheckSkipsPausedModels && model.isSuspended()) { + boolean skipCheckForSuspended = config.getSettings().onlineCheckSkipsPausedModels && model.isSuspended(); + boolean skipCheckForMarkedAsLater = model.isMarkedForLaterRecording(); + if (skipCheckForSuspended || skipCheckForMarkedAsLater) { continue; } else { futures.add(updateModel(model)); diff --git a/common/src/main/java/ctbrec/recorder/Recorder.java b/common/src/main/java/ctbrec/recorder/Recorder.java index 3c2ce825..4c88b47b 100644 --- a/common/src/main/java/ctbrec/recorder/Recorder.java +++ b/common/src/main/java/ctbrec/recorder/Recorder.java @@ -1,17 +1,17 @@ package ctbrec.recorder; +import ctbrec.Model; +import ctbrec.Recording; +import ctbrec.io.HttpClient; + import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.stream.Collectors; -import ctbrec.Model; -import ctbrec.Recording; -import ctbrec.io.HttpClient; - public interface Recorder { - public void startRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; + public void addModel(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; public void stopRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; public void stopRecordingAt(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; @@ -59,6 +59,8 @@ public interface Recorder { public boolean isSuspended(Model model); + public boolean isMarkedForLaterRecording(Model model); + /** * Returns only the models from getModels(), which are online * @return @@ -140,4 +142,6 @@ public interface Recorder { * @throws InvalidKeyException */ public void resume() throws InvalidKeyException, NoSuchAlgorithmException, IOException; + + public int getModelCount(); } diff --git a/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java b/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java index 9f526cca..b1008beb 100644 --- a/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java +++ b/common/src/main/java/ctbrec/recorder/RecordingPreconditions.java @@ -29,6 +29,7 @@ public class RecordingPreconditions { void check(Model model) throws IOException { ensureRecorderIsActive(); ensureModelIsNotSuspended(model); + ensureModelIsNotMarkedForLaterRecording(model); ensureRecordUntilIsInFuture(model); ensureNoRecordingRunningForModel(model); ensureModelShouldBeRecorded(model); @@ -113,6 +114,12 @@ public class RecordingPreconditions { } } + private void ensureModelIsNotMarkedForLaterRecording(Model model) { + if (model.isMarkedForLaterRecording()) { + throw new PreconditionNotMetException("Model " + model + " is marked for later recording"); + } + } + private void ensureRecorderIsActive() { if (!recorder.isRecording()) { throw new PreconditionNotMetException("Recorder is not in recording mode"); diff --git a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java index 388828d1..fa075ef1 100644 --- a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java +++ b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java @@ -1,5 +1,25 @@ package ctbrec.recorder; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import ctbrec.Config; +import ctbrec.Hmac; +import ctbrec.Model; +import ctbrec.Recording; +import ctbrec.event.EventBusHolder; +import ctbrec.event.NoSpaceLeftEvent; +import ctbrec.event.RecordingStateChangedEvent; +import ctbrec.io.*; +import ctbrec.sites.Site; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.Request.Builder; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.File; import java.io.IOException; import java.io.UnsupportedEncodingException; @@ -7,38 +27,7 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Optional; - -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; - -import ctbrec.Config; -import ctbrec.Hmac; -import ctbrec.Model; -import ctbrec.Recording; -import ctbrec.event.EventBusHolder; -import ctbrec.event.NoSpaceLeftEvent; -import ctbrec.event.RecordingStateChangedEvent; -import ctbrec.io.BandwidthMeter; -import ctbrec.io.FileJsonAdapter; -import ctbrec.io.HttpClient; -import ctbrec.io.HttpException; -import ctbrec.io.InstantJsonAdapter; -import ctbrec.io.ModelJsonAdapter; -import ctbrec.sites.Site; -import okhttp3.MediaType; -import okhttp3.Request; -import okhttp3.Request.Builder; -import okhttp3.RequestBody; -import okhttp3.Response; +import java.util.*; public class RemoteRecorder implements Recorder { @@ -86,7 +75,7 @@ public class RemoteRecorder implements Recorder { } @Override - public void startRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + public void addModel(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException { sendRequest("start", model); } @@ -179,17 +168,27 @@ public class RemoteRecorder implements Recorder { @Override public boolean isTracked(Model model) { - return models != null && models.contains(model); + Optional m = findModel(model); + boolean markedForRecording = m.map(Model::isMarkedForLaterRecording).orElse(false); + return m.isPresent() && !markedForRecording; } @Override public boolean isSuspended(Model model) { - int index = models.indexOf(model); + return findModel(model).map(Model::isSuspended).orElse(false); + } + + @Override + public boolean isMarkedForLaterRecording(Model model) { + return findModel(model).map(Model::isMarkedForLaterRecording).orElse(false); + } + + private Optional findModel(Model m) { + int index = Optional.ofNullable(models).map(list -> list.indexOf(m)).orElse(-1); if (index >= 0) { - Model m = models.get(index); - return m.isSuspended(); + return Optional.of(models.get(index)); } else { - return false; + return Optional.empty(); } } @@ -588,4 +587,9 @@ public class RemoteRecorder implements Recorder { public void resume() throws InvalidKeyException, NoSuchAlgorithmException, IOException { sendRequest("resumeRecorder"); } + + @Override + public int getModelCount() { + return (int) models.stream().filter(m -> !m.isMarkedForLaterRecording()).count(); + } } diff --git a/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java b/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java index 9a8784e4..63ac709b 100644 --- a/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java @@ -272,6 +272,7 @@ public class DashDownload extends AbstractDownload { return this; } + @SuppressWarnings("deprecation") private boolean splitRecording() { if (splittingStrategy.splitNecessary(this)) { internalStop(); diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4.java b/common/src/main/java/ctbrec/sites/cam4/Cam4.java index 6a8b7562..2e21c239 100644 --- a/common/src/main/java/ctbrec/sites/cam4/Cam4.java +++ b/common/src/main/java/ctbrec/sites/cam4/Cam4.java @@ -1,17 +1,5 @@ package ctbrec.sites.cam4; -import static ctbrec.io.HttpConstants.*; - -import java.io.IOException; -import java.net.URLEncoder; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.json.JSONArray; -import org.json.JSONObject; - import ctbrec.Config; import ctbrec.Model; import ctbrec.StringUtil; @@ -20,6 +8,18 @@ import ctbrec.io.HttpException; import ctbrec.sites.AbstractSite; import okhttp3.Request; import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static ctbrec.io.HttpClient.bodyToJsonObject; +import static ctbrec.io.HttpConstants.USER_AGENT; public class Cam4 extends AbstractSite { @@ -47,8 +47,9 @@ public class Cam4 extends AbstractSite { public Cam4Model createModel(String name) { Cam4Model m = new Cam4Model(); m.setSite(this); - m.setName(name); - m.setUrl(getBaseUrl() + '/' + name + '/'); + m.setDisplayName(name); + m.setName(name.toLowerCase()); + m.setUrl(getBaseUrl() + '/' + m.getName() + '/'); return m; } @@ -129,7 +130,7 @@ public class Cam4 extends AbstractSite { .build(); try(Response response = getHttpClient().execute(req)) { if(response.isSuccessful()) { - String body = response.body().string(); + String body = bodyToJsonObject(response); JSONArray results = new JSONArray(body); for (int i = 0; i < results.length(); i++) { JSONObject result = results.getJSONObject(i); diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java index be5da187..5fcaebed 100644 --- a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java +++ b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java @@ -1,6 +1,7 @@ package ctbrec.sites.cam4; import static ctbrec.Model.State.*; +import static ctbrec.io.HttpClient.*; import static ctbrec.io.HttpConstants.*; import static java.util.regex.Pattern.*; @@ -8,7 +9,10 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; @@ -36,6 +40,8 @@ import ctbrec.Config; import ctbrec.StringUtil; import ctbrec.io.HtmlParser; import ctbrec.io.HttpException; +import ctbrec.recorder.download.HttpHeaderFactory; +import ctbrec.recorder.download.HttpHeaderFactoryImpl; import ctbrec.recorder.download.StreamSource; import okhttp3.FormBody; import okhttp3.Request; @@ -103,18 +109,14 @@ public class Cam4Model extends AbstractModel { @Override public State getOnlineState(boolean failFast) throws IOException, ExecutionException { - if(failFast) { - return onlineState; - } else { - if(onlineState == UNKNOWN) { - try { - loadModelDetails(); - } catch (Exception e) { - LOG.warn("Couldn't load model details {}", e.getMessage()); - } + if (!failFast && onlineState == UNKNOWN) { + try { + loadModelDetails(); + } catch (Exception e) { + LOG.warn("Couldn't load model details {}", e.getMessage()); } - return onlineState; } + return onlineState; } private String getPlaylistUrl() throws IOException { @@ -146,8 +148,8 @@ public class Cam4Model extends AbstractModel { .build(); // @formatter:on try (Response response = site.getHttpClient().execute(req)) { if (response.isSuccessful()) { - JSONObject json = new JSONObject(response.body().string()); - LOG.trace(json.toString(2)); + JSONObject json = new JSONObject(bodyToJsonObject(response)); + if (LOG.isTraceEnabled()) LOG.trace(json.toString(2)); if (json.has("canUseCDN")) { if (json.getBoolean("canUseCDN")) { playlistUrl = json.getString("cdnURL"); @@ -191,8 +193,7 @@ public class Cam4Model extends AbstractModel { src.height = Optional.ofNullable(playlist.getStreamInfo()).map(StreamInfo::getResolution).map(res -> res.height).orElse(0); String masterUrl = getPlaylistUrl(); String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1); - String segmentUri = baseUrl + playlist.getUri(); - src.mediaPlaylistUrl = segmentUri; + src.mediaPlaylistUrl = baseUrl + playlist.getUri(); LOG.trace("Media playlist {}", src.mediaPlaylistUrl); sources.add(src); } @@ -201,19 +202,20 @@ public class Cam4Model extends AbstractModel { } private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException { - String playlistUrl = getPlaylistUrl(); - LOG.trace("Loading master playlist [{}]", playlistUrl); - Request req = new Request.Builder().url(playlistUrl).build(); + String masterPlaylistUrl = getPlaylistUrl(); + LOG.trace("Loading master playlist [{}]", masterPlaylistUrl); + Request.Builder builder = new Request.Builder().url(masterPlaylistUrl); + getHttpHeaderFactory().createMasterPlaylistHeaders().forEach(builder::header); + Request req = builder.build(); try (Response response = site.getHttpClient().execute(req)) { if (response.isSuccessful()) { InputStream inputStream = response.body().byteStream(); PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); - MasterPlaylist master = playlist.getMasterPlaylist(); - return master; + return playlist.getMasterPlaylist(); } else { - throw new HttpException(response.code(), "Couldn't download HLS playlist " + playlistUrl); + throw new HttpException(response.code(), "Couldn't download HLS playlist " + masterPlaylistUrl); } } } @@ -321,12 +323,6 @@ public class Cam4Model extends AbstractModel { this.playlistUrl = playlistUrl; } - public class ModelDetailsEmptyException extends Exception { - public ModelDetailsEmptyException(String msg) { - super(msg); - } - } - @Override public void setUrl(String url) { String normalizedUrl = url.toLowerCase(); @@ -335,4 +331,22 @@ public class Cam4Model extends AbstractModel { } super.setUrl(normalizedUrl); } + + @Override + public HttpHeaderFactory getHttpHeaderFactory() { + HttpHeaderFactoryImpl fac = new HttpHeaderFactoryImpl(); + Map headers = new HashMap<>(); + headers.put(ACCEPT, "*/*"); + headers.put(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()); + headers.put(CONNECTION, KEEP_ALIVE); + if (getSite() != null) { + headers.put(ORIGIN, getSite().getBaseUrl()); + headers.put(REFERER, getSite().getBaseUrl()); + } + headers.put(USER_AGENT, Config.getInstance().getSettings().httpUserAgent); + fac.setMasterPlaylistHeaders(headers); + fac.setSegmentPlaylistHeaders(headers); + fac.setSegmentHeaders(headers); + return fac; + } } diff --git a/common/src/main/java/ctbrec/sites/manyvids/MVLive.java b/common/src/main/java/ctbrec/sites/manyvids/MVLive.java index 9e49863a..e3cfea35 100644 --- a/common/src/main/java/ctbrec/sites/manyvids/MVLive.java +++ b/common/src/main/java/ctbrec/sites/manyvids/MVLive.java @@ -3,6 +3,8 @@ package ctbrec.sites.manyvids; import static ctbrec.io.HttpConstants.*; import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -258,13 +260,19 @@ public class MVLive extends AbstractSite { @Override public Model createModelFromUrl(String url) { - Matcher m = Pattern.compile("https://live.manyvids.com/(?:stream/)?(.*?)(?:/.*?)?").matcher(url.trim()); - if(m.matches()) { - return createModel(m.group(1)); - } - m = Pattern.compile("https://www.manyvids.com/MVLive/(.*?)/\\d+/?").matcher(url.trim()); - if(m.matches()) { - return createModel(m.group(1)); + try { + Matcher m = Pattern.compile("https://live.manyvids.com/(?:stream/)?(.*?)(?:/.*?)?").matcher(url.trim()); + if (m.matches()) { + String modelName = URLDecoder.decode(m.group(1), "utf-8"); + return createModel(modelName); + } + m = Pattern.compile("https://www.manyvids.com/MVLive/(.*?)/\\d+/?").matcher(url.trim()); + if (m.matches()) { + String modelName = URLDecoder.decode(m.group(1), "utf-8"); + return createModel(modelName); + } + } catch (UnsupportedEncodingException e) { + LOG.error("Couldn't decode model name from URL", e); } return super.createModelFromUrl(url); diff --git a/server/src/assembly/ctbrec-systemd.service b/server/src/assembly/ctbrec-systemd.service index 6f786aca..6161776a 100644 --- a/server/src/assembly/ctbrec-systemd.service +++ b/server/src/assembly/ctbrec-systemd.service @@ -5,7 +5,7 @@ After=network.target [Service] WorkingDirectory=/opt/ctbrec SyslogIdentifier=ctbrec -ExecStart=-/usr/bin/java -Xmx256m -cp ${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer +ExecStart=-/usr/bin/java -Xmx256m -cp ${name.final}.jar -Dfile.encoding=utf-8 -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer User=ctbrec Type=simple TimeoutSec=900 diff --git a/server/src/assembly/server-linux.sh b/server/src/assembly/server-linux.sh index 3eadb7fa..206f479b 100755 --- a/server/src/assembly/server-linux.sh +++ b/server/src/assembly/server-linux.sh @@ -18,7 +18,7 @@ start() { # start ctbrec $JAVA -version - $JAVA -Xmx256m -cp "${DIR}:${DIR}/${name.final}.jar" -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer & + $JAVA -Xmx256m -cp "${DIR}:${DIR}/${name.final}.jar" -Dfile.encoding=utf-8 -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer & # write a pid file echo $! > "${PIDFILE}" diff --git a/server/src/assembly/server-macos.sh b/server/src/assembly/server-macos.sh index c42ab687..43f4ffb2 100755 --- a/server/src/assembly/server-macos.sh +++ b/server/src/assembly/server-macos.sh @@ -4,5 +4,5 @@ DIR=$(dirname $0) pushd $DIR JAVA=java $JAVA -version -$JAVA -Xmx192m -cp "${DIR}:${name.final}.jar" -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer +$JAVA -Xmx192m -cp "${DIR}:${name.final}.jar" -Dfile.encoding=utf-8 -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer popd diff --git a/server/src/assembly/server.bat b/server/src/assembly/server.bat index 05d408a7..a6278a4f 100755 --- a/server/src/assembly/server.bat +++ b/server/src/assembly/server.bat @@ -1 +1 @@ -java -Xmx192m -cp .;${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer \ No newline at end of file +java -Xmx192m -cp .;${name.final}.jar -Dfile.encoding=utf-8 -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer \ No newline at end of file diff --git a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java index b4cedc9e..18a5fc1a 100644 --- a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java @@ -1,27 +1,7 @@ package ctbrec.recorder.server; -import static javax.servlet.http.HttpServletResponse.*; - -import java.io.File; -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.util.Iterator; -import java.util.List; -import java.util.Objects; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; - import ctbrec.Config; import ctbrec.Model; import ctbrec.Recording; @@ -31,6 +11,23 @@ import ctbrec.io.InstantJsonAdapter; import ctbrec.io.ModelJsonAdapter; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; + +import static javax.servlet.http.HttpServletResponse.*; public class RecorderServlet extends AbstractCtbrecServlet { @@ -73,7 +70,7 @@ public class RecorderServlet extends AbstractCtbrecServlet { switch (request.action) { case "start": LOG.debug("Starting recording for model {} - {}", request.model.getName(), request.model.getUrl()); - recorder.startRecording(request.model); + recorder.addModel(request.model); String response = "{\"status\": \"success\", \"msg\": \"Recording started\"}"; resp.getWriter().write(response); break; @@ -262,7 +259,7 @@ public class RecorderServlet extends AbstractCtbrecServlet { for (Site site : sites) { Model model = site.createModelFromUrl(url); if (model != null) { - recorder.startRecording(model); + recorder.addModel(model); return; } } @@ -279,7 +276,7 @@ public class RecorderServlet extends AbstractCtbrecServlet { for (Site site : sites) { if (Objects.equals(siteName.toLowerCase(), site.getClass().getSimpleName().toLowerCase())) { Model m = site.createModel(modelName); - recorder.startRecording(m); + recorder.addModel(m); return; } } diff --git a/server/src/main/resources/logback.xml b/server/src/main/resources/logback.xml index cedd8bbe..9510a00e 100644 --- a/server/src/main/resources/logback.xml +++ b/server/src/main/resources/logback.xml @@ -35,12 +35,12 @@ + + - -