Add "record later" tab to "bookmark" models

This commit is contained in:
0xb00bface 2021-01-03 18:37:21 +01:00
parent c462aefd4f
commit d43e022563
16 changed files with 868 additions and 104 deletions

View File

@ -1,5 +1,6 @@
NEXT
========================
* Added "record later" tab to "bookmark" models
* Added config option to show the total number of models in the title bar
3.11.0

View File

@ -64,7 +64,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;
@ -105,7 +105,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;
@ -213,7 +213,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);

View File

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

View File

@ -4,6 +4,8 @@ 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;
@ -27,13 +29,15 @@ public class CheckModelAccountAction {
}
public void execute() {
public void execute(Predicate<Model> filter) {
String buttonText = b.getText();
b.setDisable(true);
CompletableFuture.runAsync(() -> {
List<Model> deletedAccounts = new ArrayList<>();
try {
List<Model> models = recorder.getModels();
List<Model> 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;

View File

@ -0,0 +1,633 @@
package ctbrec.ui.tabs;
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;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.CheckModelAccountAction;
import ctbrec.ui.action.EditNotesAction;
import ctbrec.ui.action.FollowAction;
import ctbrec.ui.action.IgnoreModelsAction;
import ctbrec.ui.action.PlayAction;
import ctbrec.ui.action.ResumeAction;
import ctbrec.ui.action.StopRecordingAction;
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.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableColumn.SortType;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.PropertyValueFactory;
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.util.Callback;
import javafx.util.Duration;
public class RecordLaterTab extends Tab implements TabSelectionListener {
private static final Logger LOG = LoggerFactory.getLogger(RecordLaterTab.class);
private ReentrantLock lock = new ReentrantLock();
private ScheduledService<List<JavaFxModel>> updateService;
private Recorder recorder;
private List<Site> sites;
FlowPane grid = new FlowPane();
ScrollPane scrollPane = new ScrollPane();
TableView<JavaFxModel> table = new TableView<>();
ObservableList<JavaFxModel> observableModels = FXCollections.observableArrayList();
ObservableList<JavaFxModel> 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<Site> 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<JavaFxModel> row = new TableRow<>();
row.addEventHandler(MouseEvent.ANY, previewPopupHandler);
return row;
});
TableColumn<JavaFxModel, String> 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<JavaFxModel, String> name = new TableColumn<>("Model");
name.setPrefWidth(200);
name.setCellValueFactory(new PropertyValueFactory<>("displayName"));
name.setCellFactory(new ClickableCellFactory<>());
name.setEditable(false);
name.setId("name");
TableColumn<JavaFxModel, String> url = new TableColumn<>("URL");
url.setCellValueFactory(new PropertyValueFactory<>("url"));
url.setCellFactory(new ClickableCellFactory<>());
url.setPrefWidth(400);
url.setEditable(false);
url.setId("url");
TableColumn<JavaFxModel, String> 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<JavaFxModel> 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<String> 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.startRecording(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.startRecording(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<JavaFxModel> 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<JavaFxModel> 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<JavaFxModel> 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<JavaFxModel, ?> 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<List<JavaFxModel>> createUpdateService() {
ScheduledService<List<JavaFxModel>> modelUpdateService = new ScheduledService<List<JavaFxModel>>() {
@Override
protected Task<List<JavaFxModel>> createTask() {
return new Task<List<JavaFxModel>>() {
@Override
public List<JavaFxModel> 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<JavaFxModel> 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<JavaFxModel> selectedModels) {
new IgnoreModelsAction(table, selectedModels, recorder, true).execute();
}
private void follow(ObservableList<JavaFxModel> selectedModels) {
new FollowAction(getTabPane(), new ArrayList<>(selectedModels)).execute();
}
private void notes(ObservableList<JavaFxModel> selectedModels) {
new EditNotesAction(getTabPane(), selectedModels.get(0), table).execute();
}
private void openInPlayer(JavaFxModel selectedModel) {
new PlayAction(getTabPane(), selectedModel).execute();
}
private void startAction(List<JavaFxModel> selectedModels) {
List<Model> models = selectedModels.stream().map(JavaFxModel::getDelegate).collect(Collectors.toList());
new ResumeAction(table, models, recorder).execute();
}
private void stopAction(List<JavaFxModel> 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<Model> 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<JavaFxModel, ?> 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<JavaFxModel, ?> 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<TableColumn<JavaFxModel,?>> 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<JavaFxModel, ?> 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<S, T> implements Callback<TableColumn<S, T>, TableCell<S, T>> {
@Override
public TableCell<S, T> call(TableColumn<S, T> param) {
TableCell<S, T> 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;
}
}
}

View File

@ -23,6 +23,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.slf4j.Logger;
@ -49,6 +50,7 @@ import ctbrec.ui.action.PlayAction;
import ctbrec.ui.action.RemoveTimeLimitAction;
import ctbrec.ui.action.ResumeAction;
import ctbrec.ui.action.SetStopDateAction;
import ctbrec.ui.action.StartRecordingAction;
import ctbrec.ui.action.StopRecordingAction;
import ctbrec.ui.action.ToggleRecordingAction;
import ctbrec.ui.controls.CustomMouseBehaviorContextMenu;
@ -297,7 +299,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);
@ -584,6 +587,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
List<Model> onlineModels = recorder.getOnlineModels();
return recorder.getModels()
.stream()
.filter(Predicate.not(Model::isMarkedForLaterRecording))
.map(JavaFxModel::new)
.peek(fxm -> { // NOSONAR
for (Recording recording : recordings) {
@ -641,6 +645,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,7 +681,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 CustomMouseBehaviorContextMenu(stop);
ContextMenu menu = new CustomMouseBehaviorContextMenu(stop, recordLater);
if (selectedModels.size() == 1) {
menu.getItems().add(selectedModels.get(0).isSuspended() ? resumeRecording : pauseRecording);
menu.getItems().add(stopRecordingAt);
@ -772,7 +778,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
alert.showAndWait();
}
private void stopAction(List<JavaFxModel> selectedModels) {
private boolean stopAction(List<JavaFxModel> selectedModels) {
boolean confirmed = true;
if (Config.getInstance().getSettings().confirmationForDangerousActions) {
int n = selectedModels.size();
@ -787,6 +793,21 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
table.getItems().remove(m);
}));
}
return confirmed;
}
private void recordLater(List<JavaFxModel> selectedModels) {
boolean confirmed = stopAction(selectedModels);
if (confirmed) {
List<Model> 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<JavaFxModel> selectedModels) {

View File

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

View File

@ -512,7 +512,7 @@ public class ThumbCell extends StackPane {
try {
if (start) {
recorder.startRecording(model);
setRecording(true);
setRecording(!model.isMarkedForLaterRecording());
} else {
recorder.stopRecording(model);
setRecording(false);
@ -561,6 +561,11 @@ public class ThumbCell extends StackPane {
});
}
void recordLater() {
model.setMarkedForLaterRecording(true);
startStopAction(true);
}
public Model getModel() {
return model;
}

View File

@ -488,7 +488,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
MenuItem addPaused = new MenuItem("Add in paused state");
addPaused.setOnAction(e -> addPaused(getSelectedThumbCells(cell)));
MenuItem recordLater = new MenuItem("Record Later");
recordLater.setOnAction(e -> 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));
@ -523,7 +523,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
if(modelIsTrackedByRecorder) {
contextMenu.getItems().add(pauseResume);
} else {
contextMenu.getItems().addAll(recordUntil, addPaused/*, recordLater*/);
contextMenu.getItems().addAll(recordUntil, addPaused, recordLater);
}
contextMenu.getItems().add(new SeparatorMenuItem());
if(site.supportsFollow()) {
@ -544,6 +544,12 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
return contextMenu;
}
private void recordLater(List<ThumbCell> list) {
for (ThumbCell cell : list) {
cell.recordLater();
}
}
private void startRecordingWithTimeLimit(List<ThumbCell> list) {
for (ThumbCell cell : list) {
cell.startStopAction(true);

View File

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

View File

@ -122,6 +122,10 @@ public interface Model extends Comparable<Model>, Serializable {
public void setSuspended(boolean suspended);
public boolean isMarkedForLaterRecording();
public void setMarkedForLaterRecording(boolean marked);
public Download createDownload();
public void setPriority(int priority);

View File

@ -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<Model> recordLater = new ArrayList<>();
public boolean recordSingleFile = false;
public boolean removeRecordingAfterPostProcessing = false;
public boolean requireAuthentication = false;

View File

@ -35,51 +35,41 @@ public class ModelJsonAdapter extends JsonAdapter<Model> {
@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<Model> {
} 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<Model> {
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<Model> {
}
private void writeValueIfSet(JsonWriter writer, String name, String value) throws IOException {
if(value != null) {
if (value != null) {
writer.name(name).value(value);
}
}

View File

@ -215,7 +215,7 @@ public class NextGenLocalRecorder implements Recorder {
}
}
private CompletableFuture<Void> startRecordingProcess(Model model) throws IOException {
private CompletableFuture<Void> startRecordingProcess(Model model) {
return CompletableFuture.runAsync(() -> {
recorderLock.lock();
try {
@ -287,7 +287,7 @@ public class NextGenLocalRecorder implements Recorder {
Recording rec = new Recording();
rec.setId(UUID.randomUUID().toString());
rec.setDownload(download);
String recordingFile = download.getPath(model).replaceAll("\\\\", "/");
String recordingFile = download.getPath(model).replace("\\\\", "/");
File absoluteFile = new File(config.getSettings().recordingsDir, recordingFile);
rec.setAbsoluteFile(absoluteFile);
rec.setModel(model);
@ -367,10 +367,22 @@ public class NextGenLocalRecorder implements Recorder {
}
private void stopRecordingProcesses() {
LOG.debug("Stopping all recording processes");
recorderLock.lock();
try {
for (Recording rec : recordingProcesses.values()) {
rec.getDownload().stop();
// make a copy to avoid ConcurrentModificationException
List<Recording> toStop = new ArrayList<>(recordingProcesses.values());
if (!toStop.isEmpty()) {
ExecutorService shutdownPool = Executors.newFixedThreadPool(toStop.size());
for (Recording rec : toStop) {
Optional.ofNullable(rec.getDownload()).ifPresent(d -> shutdownPool.submit(d::stop));
}
shutdownPool.shutdown();
try {
shutdownPool.awaitTermination(10, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} finally {
recorderLock.unlock();
@ -381,7 +393,13 @@ public class NextGenLocalRecorder implements Recorder {
public boolean isTracked(Model model) {
recorderLock.lock();
try {
return models.contains(model);
int index = models.indexOf(model);
if (index >= 0) {
Model modelFromList = models.get(index);
return !modelFromList.isMarkedForLaterRecording();
} else {
return false;
}
} finally {
recorderLock.unlock();
}
@ -409,63 +427,44 @@ public class NextGenLocalRecorder implements Recorder {
@Override
public void shutdown(boolean immediately) {
// TODO add a config flag for waitign or stopping immediately
LOG.info("Shutting down");
recording = false;
if (!immediately) {
LOG.debug("Stopping all recording processes");
recorderLock.lock();
try {
// make a copy to avoid ConcurrentModificationException
List<Recording> toStop = new ArrayList<>(recordingProcesses.values());
if (!toStop.isEmpty()) {
ExecutorService shutdownPool = Executors.newFixedThreadPool(toStop.size());
List<Future<?>> shutdownFutures = new ArrayList<>(toStop.size());
for (Recording rec : toStop) {
Optional.ofNullable(rec.getDownload()).ifPresent(d -> {
shutdownFutures.add(shutdownPool.submit(() -> d.stop()));
});
}
shutdownPool.shutdown();
try {
shutdownPool.awaitTermination(10, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} finally {
recorderLock.unlock();
}
stopRecordingProcesses();
awaitDownloadsFinish();
shutdownThreadPools();
}
}
// wait for downloads to finish
LOG.info("Waiting for downloads to finish");
for (int i = 0; i < 60; i++) {
if (!recordingProcesses.isEmpty()) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Error while waiting for downloads to finish", e);
}
private void awaitDownloadsFinish() {
LOG.info("Waiting for downloads to finish");
for (int i = 0; i < 60; i++) {
if (!recordingProcesses.isEmpty()) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Error while waiting for downloads to finish", e);
}
}
}
}
// shutdown threadpools
try {
LOG.info("Shutting down download pool");
downloadPool.shutdown();
client.shutdown();
downloadPool.awaitTermination(1, TimeUnit.MINUTES);
LOG.info("Shutting down post-processing pool");
ppPool.shutdown();
int minutesToWait = 10;
LOG.info("Waiting {} minutes (max) for post-processing to finish", minutesToWait);
ppPool.awaitTermination(minutesToWait, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Error while waiting for pools to finish", e);
}
private void shutdownThreadPools() {
try {
LOG.info("Shutting down download pool");
downloadPool.shutdown();
client.shutdown();
downloadPool.awaitTermination(1, TimeUnit.MINUTES);
LOG.info("Shutting down post-processing pool");
ppPool.shutdown();
int minutesToWait = 10;
LOG.info("Waiting {} minutes (max) for post-processing to finish", minutesToWait);
ppPool.awaitTermination(minutesToWait, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Error while waiting for pools to finish", e);
}
}
@ -500,7 +499,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 {
@ -651,7 +652,6 @@ public class NextGenLocalRecorder implements Recorder {
config.save();
} else {
LOG.warn("Couldn't change priority for model {}. Not found in list", model.getName());
return;
}
} catch (IOException e) {
LOG.error("Couldn't save config", e);

View File

@ -82,7 +82,9 @@ public class OnlineMonitor extends Thread {
// submit online check jobs to the executor for the model's site
List<Future<?>> 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));

View File

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