diff --git a/client/src/main/java/ctbrec/ui/JavaFxRecording.java b/client/src/main/java/ctbrec/ui/JavaFxRecording.java index aa696760..57a563ed 100644 --- a/client/src/main/java/ctbrec/ui/JavaFxRecording.java +++ b/client/src/main/java/ctbrec/ui/JavaFxRecording.java @@ -95,6 +95,9 @@ public class JavaFxRecording extends Recording { statusProperty.set("unknown"); break; } + if (isPinned()) { + statusProperty.set(statusProperty.get() + " šŸ”’"); + } } @Override @@ -184,6 +187,17 @@ public class JavaFxRecording extends Recording { return delegate.isSingleFile(); } + @Override + public boolean isPinned() { + return delegate.isPinned(); + } + + @Override + public void setPinned(boolean pinned) { + delegate.setPinned(pinned); + setStatus(getStatus()); + } + public boolean valueChanged() { boolean changed = getSizeInByte() != lastValue; lastValue = getSizeInByte(); diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java index 7c023338..cc1475e8 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java @@ -13,6 +13,7 @@ import java.security.NoSuchAlgorithmException; import java.text.DecimalFormat; import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Objects; @@ -31,6 +32,7 @@ import ctbrec.Recording.State; import ctbrec.StringUtil; import ctbrec.recorder.ProgressListener; import ctbrec.recorder.Recorder; +import ctbrec.recorder.RecordingPinnedException; import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; import ctbrec.sites.Site; import ctbrec.ui.AutosizeAlert; @@ -387,6 +389,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener { deleteRecording.setOnAction(e -> delete(recordings)); if(first.getStatus() == State.FINISHED || first.getStatus() == State.WAITING || first.getStatus() == State.FAILED || recordings.size() > 1) { contextMenu.getItems().add(deleteRecording); + deleteRecording.setDisable(recordings.stream().allMatch(Recording::isPinned)); } MenuItem openDir = new MenuItem("Open directory"); @@ -401,6 +404,16 @@ public class RecordingsTab extends Tab implements TabSelectionListener { contextMenu.getItems().add(downloadRecording); } + if (first.isPinned()) { + MenuItem unpinRecording = new MenuItem("Unpin"); + unpinRecording.setOnAction(e -> unpin(recordings)); + contextMenu.getItems().add(unpinRecording); + } else { + MenuItem pinRecording = new MenuItem("Pin"); + pinRecording.setOnAction(e -> pin(recordings)); + contextMenu.getItems().add(pinRecording); + } + MenuItem rerunPostProcessing = new MenuItem("Rerun Post-Processing"); rerunPostProcessing.setOnAction(e -> triggerPostProcessing(first)); if (first.getStatus() == FAILED || first.getStatus() == WAITING || first.getStatus() == FINISHED) { @@ -418,6 +431,58 @@ public class RecordingsTab extends Tab implements TabSelectionListener { return contextMenu; } + private void pin(List recordings) { + table.setCursor(Cursor.WAIT); + Thread backgroundThread = new Thread(() -> { + List exceptions = new ArrayList<>(); + try { + for (JavaFxRecording javaFxRecording : recordings) { + Recording rec = javaFxRecording.getDelegate(); + try { + recorder.pin(rec); + javaFxRecording.setPinned(true); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + exceptions.add(e); + } + } + } finally { + Platform.runLater(() -> { + table.setCursor(Cursor.DEFAULT); + if (!exceptions.isEmpty()) { + showErrorDialog("Error while pinning recordings", "At least one recording couldn't be pinned", exceptions); + } + }); + } + }); + backgroundThread.start(); + } + + private void unpin(List recordings) { + table.setCursor(Cursor.WAIT); + Thread backgroundThread = new Thread(() -> { + List exceptions = new ArrayList<>(); + try { + for (JavaFxRecording javaFxRecording : recordings) { + Recording rec = javaFxRecording.getDelegate(); + try { + recorder.unpin(rec); + javaFxRecording.setPinned(false); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + exceptions.add(e); + } + } + } finally { + Platform.runLater(() -> { + table.setCursor(Cursor.DEFAULT); + if (!exceptions.isEmpty()) { + showErrorDialog("Error while unpinning recordings", "At least one recording couldn't be unpinned", exceptions); + } + }); + } + }); + backgroundThread.start(); + } + private void jumpToNextModel(KeyCode code) { if (!table.getItems().isEmpty() && (code.isLetterKey() || code.isDigitKey())) { // determine where to start looking for the next model @@ -544,11 +609,19 @@ public class RecordingsTab extends Tab implements TabSelectionListener { } private void showErrorDialog(final String title, final String msg, final Exception e) { + showErrorDialog(title, msg, Collections.singletonList(e)); + } + + private void showErrorDialog(final String title, final String msg, final List exceptions) { Platform.runLater(() -> { AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR, getTabPane().getScene()); autosizeAlert.setTitle(title); autosizeAlert.setHeaderText(msg); - autosizeAlert.setContentText("An error occured: " + e.getLocalizedMessage()); + StringBuilder contentText = new StringBuilder("On or more error(s) occured:"); + for (Exception exception : exceptions) { + contentText.append("\nā€¢ ").append(exception.getLocalizedMessage()); + } + autosizeAlert.setContentText(contentText.toString()); autosizeAlert.showAndWait(); }); } @@ -591,19 +664,23 @@ public class RecordingsTab extends Tab implements TabSelectionListener { recordingsLock.lock(); try { List deleted = new ArrayList<>(); + List exceptions = new ArrayList<>(); for (Iterator iterator = recordings.iterator(); iterator.hasNext();) { JavaFxRecording r = iterator.next(); - if(r.getStatus() != FINISHED && r.getStatus() != FAILED && r.getStatus() != State.WAITING) { + if (r.getStatus() != FINISHED && r.getStatus() != FAILED && r.getStatus() != State.WAITING) { continue; } try { recorder.delete(r.getDelegate()); deleted.add(r); - } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + } catch (RecordingPinnedException | IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + exceptions.add(e1); LOG.error("Error while deleting recording", e1); - showErrorDialog("Error while deleting recording", "Recording not deleted", e1); } } + if (!exceptions.isEmpty()) { + showErrorDialog("Error while deleting recording", "Recording not deleted", exceptions); + } observableRecordings.removeAll(deleted); } finally { recordingsLock.unlock(); diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java index 7ae551f6..a7caec1e 100644 --- a/common/src/main/java/ctbrec/Config.java +++ b/common/src/main/java/ctbrec/Config.java @@ -58,7 +58,7 @@ public class Config { .build(); JsonAdapter adapter = moshi.adapter(Settings.class).lenient(); File configFile = new File(configDir, filename); - LOG.debug("Loading config from {}", configFile.getAbsolutePath()); + LOG.info("Loading config from {}", configFile.getAbsolutePath()); if (configFile.exists()) { try { byte[] fileContent = Files.readAllBytes(configFile.toPath()); diff --git a/common/src/main/java/ctbrec/Recording.java b/common/src/main/java/ctbrec/Recording.java index 9f040956..db013651 100644 --- a/common/src/main/java/ctbrec/Recording.java +++ b/common/src/main/java/ctbrec/Recording.java @@ -35,6 +35,7 @@ public class Recording implements Serializable { private long sizeInByte = -1; private String metaDataFile; private boolean singleFile = false; + private boolean pinned = false; public enum State { RECORDING("recording"), @@ -152,6 +153,14 @@ public class Recording implements Serializable { this.metaDataFile = metaDataFile; } + public boolean isPinned() { + return pinned; + } + + public void setPinned(boolean pinned) { + this.pinned = pinned; + } + public Duration getLength() { if (getDownload() != null) { return getDownload().getLength(); diff --git a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java index ae106dbd..0a3e96a9 100644 --- a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java @@ -678,4 +678,14 @@ public class NextGenLocalRecorder implements Recorder { recorderLock.unlock(); } } + + @Override + public void pin(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + recordingManager.pin(recording); + } + + @Override + public void unpin(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + recordingManager.unpin(recording); + } } diff --git a/common/src/main/java/ctbrec/recorder/Recorder.java b/common/src/main/java/ctbrec/recorder/Recorder.java index f7b191a9..2da77254 100644 --- a/common/src/main/java/ctbrec/recorder/Recorder.java +++ b/common/src/main/java/ctbrec/recorder/Recorder.java @@ -34,6 +34,23 @@ public interface Recorder { public void delete(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException; + /** + * Pins a recording. A pinned recording cannot be deleted. + * @param recording + * @throws IOException + * @throws InvalidKeyException + * @throws NoSuchAlgorithmException + */ + public void pin(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException; + /** + * Unpins a previously pinned recording. A pinned recording cannot be deleted. + * @param recording + * @throws IOException + * @throws InvalidKeyException + * @throws NoSuchAlgorithmException + */ + public void unpin(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException; + public void shutdown(); public void suspendRecording(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException; diff --git a/common/src/main/java/ctbrec/recorder/RecordingManager.java b/common/src/main/java/ctbrec/recorder/RecordingManager.java index f3482d5e..9c99c451 100644 --- a/common/src/main/java/ctbrec/recorder/RecordingManager.java +++ b/common/src/main/java/ctbrec/recorder/RecordingManager.java @@ -103,6 +103,10 @@ public class RecordingManager { } public void delete(Recording recording) throws IOException { + if (recording.isPinned()) { + throw new RecordingPinnedException(recording); + } + recordingsLock.lock(); try { int idx = recordings.indexOf(recording); @@ -182,4 +186,24 @@ public class RecordingManager { throw new IOException("Couldn't delete all files in " + directory); } } + + public void pin(Recording recording) throws IOException { + recordingsLock.lock(); + try { + recording.setPinned(true); + saveRecording(recording); + } finally { + recordingsLock.unlock(); + } + } + + public void unpin(Recording recording) throws IOException { + recordingsLock.lock(); + try { + recording.setPinned(false); + saveRecording(recording); + } finally { + recordingsLock.unlock(); + } + } } diff --git a/common/src/main/java/ctbrec/recorder/RecordingPinnedException.java b/common/src/main/java/ctbrec/recorder/RecordingPinnedException.java new file mode 100644 index 00000000..d1316bb1 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/RecordingPinnedException.java @@ -0,0 +1,10 @@ +package ctbrec.recorder; + +import ctbrec.Recording; + +public class RecordingPinnedException extends RuntimeException { + + public RecordingPinnedException(Recording rec) { + super("Recording is pinned: " + rec); + } +} diff --git a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java index 891c27dc..34ac684f 100644 --- a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java +++ b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java @@ -111,6 +111,30 @@ public class RemoteRecorder implements Recorder { } } + private void sendRequest(String action, Recording recording, Runnable... onSuccess) throws InvalidKeyException, NoSuchAlgorithmException, IOException { + RecordingRequest recReq = new RecordingRequest(action, recording); + String msg = recordingRequestAdapter.toJson(recReq); + RequestBody body = RequestBody.create(JSON, msg); + Request.Builder builder = new Request.Builder().url(getRecordingEndpoint()).post(body); + addHmacIfNeeded(msg, builder); + Request request = builder.build(); + try (Response response = client.execute(request)) { + String json = response.body().string(); + RecordingListResponse resp = recordingListResponseAdapter.fromJson(json); + if (response.isSuccessful()) { + if (!resp.status.equals(SUCCESS)) { + throw new IOException("Request failed: " + resp.msg); + } else { + for (Runnable callback : onSuccess) { + callback.run(); + } + } + } else { + throw new IOException("Request failed: " + resp.msg); + } + } + } + private void addHmacIfNeeded(String msg, Builder builder) throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { if (Config.getInstance().getSettings().requireAuthentication) { byte[] key = Config.getInstance().getSettings().key; @@ -336,26 +360,8 @@ public class RemoteRecorder implements Recorder { } @Override - public void delete(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException { - RecordingRequest recReq = new RecordingRequest("delete", recording); - String msg = recordingRequestAdapter.toJson(recReq); - RequestBody body = RequestBody.create(JSON, msg); - Request.Builder builder = new Request.Builder().url(getRecordingEndpoint()).post(body); - addHmacIfNeeded(msg, builder); - Request request = builder.build(); - try (Response response = client.execute(request)) { - String json = response.body().string(); - RecordingListResponse resp = recordingListResponseAdapter.fromJson(json); - if (response.isSuccessful()) { - if (!resp.status.equals(SUCCESS)) { - throw new IOException("Couldn't delete recording: " + resp.msg); - } else { - recordings.remove(recording); - } - } else { - throw new IOException("Couldn't delete recording: " + resp.msg); - } - } + public void delete(Recording recording) throws InvalidKeyException, NoSuchAlgorithmException, IOException { + sendRequest("delete", recording, () -> recordings.remove(recording)); } public static class ModelRequest { @@ -461,6 +467,16 @@ public class RemoteRecorder implements Recorder { return spaceFree; } + @Override + public void pin(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + sendRequest("pin", recording); + } + + @Override + public void unpin(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + sendRequest("unpin", recording); + } + @Override public void rerunPostProcessing(Recording recording) throws IOException, InvalidKeyException, NoSuchAlgorithmException { RecordingRequest recReq = new RecordingRequest("rerunPostProcessing", recording); diff --git a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java index da9dd7dd..044424fd 100644 --- a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java @@ -136,6 +136,20 @@ public class RecorderServlet extends AbstractCtbrecServlet { resp.getWriter().write(recAdapter.toJson(request.recording)); resp.getWriter().write("]}"); break; + case "pin": + recorder.pin(request.recording); + recAdapter = moshi.adapter(Recording.class); + resp.getWriter().write("{\"status\": \"success\", \"msg\": \"List of recordings\", \"recordings\": ["); + resp.getWriter().write(recAdapter.toJson(request.recording)); + resp.getWriter().write("]}"); + break; + case "unpin": + recorder.unpin(request.recording); + recAdapter = moshi.adapter(Recording.class); + resp.getWriter().write("{\"status\": \"success\", \"msg\": \"List of recordings\", \"recordings\": ["); + resp.getWriter().write(recAdapter.toJson(request.recording)); + resp.getWriter().write("]}"); + break; case "rerunPostProcessing": recorder.rerunPostProcessing(request.recording); recAdapter = moshi.adapter(Recording.class);