package ctbrec.ui; import static javafx.scene.control.ButtonType.NO; import static javafx.scene.control.ButtonType.YES; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URL; 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.Iterator; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.ParseException; import com.iheartradio.m3u8.PlaylistException; import ctbrec.Config; import ctbrec.Recording; import ctbrec.Recording.STATUS; import ctbrec.io.HttpClient; import ctbrec.recorder.Recorder; import ctbrec.recorder.download.MergedHlsDownload; import ctbrec.sites.chaturbate.ChaturbateModel; import javafx.application.Platform; import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.concurrent.ScheduledService; import javafx.concurrent.Task; import javafx.geometry.Insets; import javafx.scene.Cursor; import javafx.scene.control.Alert.AlertType; import javafx.scene.control.ButtonType; import javafx.scene.control.ContextMenu; import javafx.scene.control.MenuItem; import javafx.scene.control.ScrollPane; import javafx.scene.control.Tab; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.input.ContextMenuEvent; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.FlowPane; import javafx.stage.FileChooser; import javafx.util.Duration; public class RecordingsTab extends Tab implements TabSelectionListener { private static final transient Logger LOG = LoggerFactory.getLogger(RecordingsTab.class); private ScheduledService> updateService; private Config config; private Recorder recorder; FlowPane grid = new FlowPane(); ScrollPane scrollPane = new ScrollPane(); TableView table = new TableView(); ObservableList observableRecordings = FXCollections.observableArrayList(); ContextMenu popup; public RecordingsTab(String title, Recorder recorder, Config config) { super(title); this.recorder = recorder; this.config = config; 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(false); TableColumn name = new TableColumn<>("Model"); name.setPrefWidth(200); name.setCellValueFactory(new PropertyValueFactory("modelName")); TableColumn date = new TableColumn<>("Date"); date.setCellValueFactory((cdf) -> { Instant instant = cdf.getValue().getStartDate(); ZonedDateTime time = instant.atZone(ZoneId.systemDefault()); DateTimeFormatter dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM); return new SimpleStringProperty(dtf.format(time)); }); date.setPrefWidth(200); TableColumn status = new TableColumn<>("Status"); status.setCellValueFactory((cdf) -> cdf.getValue().getStatusProperty()); status.setPrefWidth(300); TableColumn progress = new TableColumn<>("Progress"); progress.setCellValueFactory((cdf) -> cdf.getValue().getProgressProperty()); progress.setPrefWidth(100); TableColumn size = new TableColumn<>("Size"); size.setCellValueFactory((cdf) -> cdf.getValue().getSizeProperty()); size.setPrefWidth(100); table.getColumns().addAll(name, date, status, progress, size); table.setItems(observableRecordings); table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { Recording recording = table.getSelectionModel().getSelectedItem(); popup = createContextMenu(recording); if(!popup.getItems().isEmpty()) { popup.show(table, event.getScreenX(), event.getScreenY()); } event.consume(); }); table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { if(popup != null) { popup.hide(); } }); table.addEventFilter(KeyEvent.KEY_PRESSED, event -> { if(event.getCode() == KeyCode.DELETE) { JavaFxRecording recording = table.getSelectionModel().getSelectedItem(); if(recording != null) { delete(recording); } } }); scrollPane.setContent(table); BorderPane root = new BorderPane(); root.setPadding(new Insets(5)); root.setCenter(scrollPane); setContent(root); } void initializeUpdateService() { updateService = createUpdateService(); updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2))); updateService.setOnSucceeded((event) -> { List recordings = updateService.getValue(); if (recordings == null) { return; } for (Iterator iterator = observableRecordings.iterator(); iterator.hasNext();) { JavaFxRecording old = iterator.next(); if (!recordings.contains(old)) { // remove deleted recordings iterator.remove(); } } for (JavaFxRecording recording : recordings) { if (!observableRecordings.contains(recording)) { // add new recordings observableRecordings.add(recording); } else { // update existing ones int index = observableRecordings.indexOf(recording); JavaFxRecording old = observableRecordings.get(index); old.update(recording); } } table.sort(); }); updateService.setOnFailed((event) -> { LOG.info("Couldn't get list of recordings from recorder", event.getSource().getException()); AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR); autosizeAlert.setTitle("Whoopsie!"); autosizeAlert.setHeaderText("Recordings not available"); autosizeAlert.setContentText("An error occured while retrieving the list of recordings"); autosizeAlert.showAndWait(); }); } private ScheduledService> createUpdateService() { ScheduledService> updateService = new ScheduledService>() { @Override protected Task> createTask() { return new Task>() { @Override public List call() throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException { List recordings = new ArrayList<>(); for (Recording rec : recorder.getRecordings()) { recordings.add(new JavaFxRecording(rec)); } return recordings; } }; } }; ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() { @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setDaemon(true); t.setName("RecordingsTab UpdateService"); return t; } }); updateService.setExecutor(executor); return updateService; } @Override public void selected() { if (updateService != null) { updateService.reset(); updateService.restart(); } } @Override public void deselected() { if (updateService != null) { updateService.cancel(); } } private ContextMenu createContextMenu(Recording recording) { ContextMenu contextMenu = new ContextMenu(); contextMenu.setHideOnEscape(true); contextMenu.setAutoHide(true); contextMenu.setAutoFix(true); MenuItem openInPlayer = new MenuItem("Open in Player"); openInPlayer.setOnAction((e) -> { play(recording); }); if(recording.getStatus() == STATUS.FINISHED || Config.getInstance().getSettings().localRecording) { contextMenu.getItems().add(openInPlayer); } MenuItem stopRecording = new MenuItem("Stop recording"); stopRecording.setOnAction((e) -> { ChaturbateModel m = new ChaturbateModel(); m.setName(recording.getModelName()); m.setUrl(CtbrecApplication.BASE_URI + '/' + recording.getModelName() + '/'); try { recorder.stopRecording(m); } catch (Exception e1) { showErrorDialog("Stop recording", "Couldn't stop recording of model " + m.getName(), e1); } }); if(recording.getStatus() == STATUS.RECORDING) { contextMenu.getItems().add(stopRecording); } MenuItem deleteRecording = new MenuItem("Delete"); deleteRecording.setOnAction((e) -> { delete(recording); }); if(recording.getStatus() == STATUS.FINISHED) { contextMenu.getItems().add(deleteRecording); } MenuItem downloadRecording = new MenuItem("Download"); downloadRecording.setOnAction((e) -> { try { download(recording); } catch (IOException | ParseException | PlaylistException e1) { showErrorDialog("Error while downloading recording", "The recording could not be downloaded", e1); LOG.error("Error while downloading recording", e1); } }); if (!Config.getInstance().getSettings().localRecording && recording.getStatus() == STATUS.FINISHED) { contextMenu.getItems().add(downloadRecording); } return contextMenu; } private void download(Recording recording) throws IOException, ParseException, PlaylistException { String filename = recording.getPath().replaceAll("/", "-") + ".ts"; FileChooser chooser = new FileChooser(); chooser.setInitialFileName(filename); if(config.getSettings().lastDownloadDir != null && !config.getSettings().lastDownloadDir.equals("")) { File dir = new File(config.getSettings().lastDownloadDir); while(!dir.exists()) { dir = dir.getParentFile(); } chooser.setInitialDirectory(dir); } File target = chooser.showSaveDialog(null); if(target != null) { config.getSettings().lastDownloadDir = target.getParent(); String hlsBase = "http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/hls"; URL url = new URL(hlsBase + "/" + recording.getPath() + "/playlist.m3u8"); LOG.info("Downloading {}", recording.getPath()); Thread t = new Thread() { @Override public void run() { try { MergedHlsDownload download = new MergedHlsDownload(HttpClient.getInstance()); download.start(url.toString(), target, (progress) -> { Platform.runLater(() -> { if (progress == 100) { recording.setStatus(STATUS.FINISHED); recording.setProgress(-1); LOG.debug("Download finished for recording {}", recording.getPath()); } else { recording.setStatus(STATUS.DOWNLOADING); recording.setProgress(progress); } }); }); } catch (FileNotFoundException e) { showErrorDialog("Error while downloading recording", "The target file couldn't be created", e); LOG.error("Error while downloading recording", e); } catch (IOException e) { showErrorDialog("Error while downloading recording", "The recording could not be downloaded", e); LOG.error("Error while downloading recording", e); } finally { Platform.runLater(new Runnable() { @Override public void run() { recording.setStatus(STATUS.FINISHED); recording.setProgress(-1); } }); } } }; t.setDaemon(true); t.setName("Download Thread " + recording.getPath()); t.start(); recording.setStatus(STATUS.DOWNLOADING); recording.setProgress(0); } } // private void download(Recording recording) throws IOException, ParseException, PlaylistException { // String filename = recording.getPath().replaceAll("/", "-") + ".ts"; // FileChooser chooser = new FileChooser(); // chooser.setInitialFileName(filename); // if(config.getSettings().lastDownloadDir != null && !config.getSettings().lastDownloadDir.equals("")) { // File dir = new File(config.getSettings().lastDownloadDir); // while(!dir.exists()) { // dir = dir.getParentFile(); // } // chooser.setInitialDirectory(dir); // } // File target = chooser.showSaveDialog(null); // if(target != null) { // config.getSettings().lastDownloadDir = target.getParent(); // String hlsBase = "http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/hls"; // URL url = new URL(hlsBase + "/" + recording.getPath() + "/playlist.m3u8"); // LOG.info("Downloading {}", recording.getPath()); // // PlaylistParser parser = new PlaylistParser(url.openStream(), Format.EXT_M3U, Encoding.UTF_8); // Playlist playlist = parser.parse(); // MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist(); // List tracks = mediaPlaylist.getTracks(); // List segmentUris = new ArrayList<>(); // for (TrackData trackData : tracks) { // String segmentUri = hlsBase + "/" + recording.getPath() + "/" + trackData.getUri(); // segmentUris.add(segmentUri); // } // // Thread t = new Thread() { // @Override // public void run() { // try(FileOutputStream fos = new FileOutputStream(target)) { // for (int i = 0; i < segmentUris.size(); i++) { // URL segment = new URL(segmentUris.get(i)); // InputStream in = segment.openStream(); // byte[] b = new byte[1024]; // int length = -1; // while( (length = in.read(b)) >= 0 ) { // fos.write(b, 0, length); // } // in.close(); // int progress = (int) (i * 100.0 / segmentUris.size()); // Platform.runLater(new Runnable() { // @Override // public void run() { // recording.setStatus(STATUS.DOWNLOADING); // recording.setProgress(progress); // } // }); // } // // } catch (FileNotFoundException e) { // showErrorDialog("Error while downloading recording", "The target file couldn't be created", e); // LOG.error("Error while downloading recording", e); // } catch (IOException e) { // showErrorDialog("Error while downloading recording", "The recording could not be downloaded", e); // LOG.error("Error while downloading recording", e); // } finally { // Platform.runLater(new Runnable() { // @Override // public void run() { // recording.setStatus(STATUS.FINISHED); // recording.setProgress(-1); // } // }); // } // } // }; // t.setDaemon(true); // t.setName("Download Thread " + recording.getPath()); // t.start(); // } // } private void showErrorDialog(final String title, final String msg, final Exception e) { Platform.runLater(new Runnable() { @Override public void run() { AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR); autosizeAlert.setTitle(title); autosizeAlert.setHeaderText(msg); autosizeAlert.setContentText("An error occured: " + e.getLocalizedMessage()); autosizeAlert.showAndWait(); } }); } private void play(Recording recording) { final String url; if (Config.getInstance().getSettings().localRecording) { new Thread() { @Override public void run() { Player.play(recording); } }.start(); } else { String hlsBase = "http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/hls"; url = hlsBase + "/" + recording.getPath() + "/playlist.m3u8"; new Thread() { @Override public void run() { Player.play(url); } }.start(); } } private void delete(Recording r) { if(r.getStatus() != STATUS.FINISHED) { return; } table.setCursor(Cursor.WAIT); String msg = "Delete " + r.getModelName() + "/" + r.getStartDate() + " for good?"; AutosizeAlert confirm = new AutosizeAlert(AlertType.CONFIRMATION, msg, YES, NO); confirm.setTitle("Delete recording?"); confirm.setHeaderText(msg); confirm.setContentText(""); confirm.showAndWait(); if (confirm.getResult() == ButtonType.YES) { Thread deleteThread = new Thread() { @Override public void run() { try { recorder.delete(r); Platform.runLater(() -> observableRecordings.remove(r)); } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { LOG.error("Error while deleting recording", e1); showErrorDialog("Error while deleting recording", "Recording not deleted", e1); } finally { table.setCursor(Cursor.DEFAULT); } } }; deleteThread.start(); } else { table.setCursor(Cursor.DEFAULT); } } }