From 6c1f1112dca1f9ff5fbedcbd8b36dac90eaa63f2 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 21 Jun 2026 18:31:26 +0100 Subject: [PATCH] feat(stream): clipping stream now saves to db --- .../ddf/vodsystem/services/ClipService.java | 25 +++--- .../services/JobOrchestrationService.java | 4 +- .../ddf/vodsystem/services/StreamService.java | 36 ++++++-- .../services/media/StreamActionsService.java | 82 ++++++++++++------- 4 files changed, 99 insertions(+), 48 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index 883d884..7b692ae 100644 --- a/src/main/java/com/ddf/vodsystem/services/ClipService.java +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -173,25 +173,26 @@ public class ClipService { return clipRepository.save(clip); } - public void persistClip(ClipOptions clipOptions, - User user, - File tempFile, - String fileName) { - File clipFile; + public void persistClip(String title, + String description, + User user, + File clipFile, + String fileName) { + File newClipFile; File thumbnailFile; // Move temp file from temp dir to output dir try { - clipFile = directoryService.getUserClipsFile(user.getId(), fileName); + newClipFile = directoryService.getUserClipsFile(user.getId(), fileName); thumbnailFile = directoryService.getUserThumbnailsFile(user.getId(), fileName + ".png"); - directoryService.cutFile(tempFile, clipFile); + directoryService.cutFile(clipFile, newClipFile); } catch (IOException e) { throw new StorageException("Failed to move clip from temporary directory to output directory", e); } ClipOptions clipMetadata; try { - clipMetadata = metadataService.getVideoMetadata(clipFile).get(); + clipMetadata = metadataService.getVideoMetadata(newClipFile).get(); } catch (InterruptedException | ExecutionException e) { Thread.currentThread().interrupt(); throw new FFMPEGException("Error retrieving video metadata for clip: " + e.getMessage()); @@ -199,7 +200,7 @@ public class ClipService { // Thumbnail generation can fail with error propagation try { - thumbnailService.createThumbnail(clipFile, thumbnailFile, 0.0f); + thumbnailService.createThumbnail(newClipFile, thumbnailFile, 0.0f); } catch (InterruptedException e) { logger.error("Thumbnail generation interrupted for user: {}", user.getId(), e); Thread.currentThread().interrupt(); @@ -207,10 +208,10 @@ public class ClipService { logger.error("Error generating thumbnail for user: {}", user.getId(), e); } - clipMetadata.setTitle(clipOptions.getTitle()); - clipMetadata.setDescription(clipOptions.getDescription()); + clipMetadata.setTitle(title); + clipMetadata.setDescription(description); - Clip clip = saveClip(clipMetadata, user, clipFile.getAbsolutePath(), thumbnailFile.getAbsolutePath()); + Clip clip = saveClip(clipMetadata, user, newClipFile.getAbsolutePath(), thumbnailFile.getAbsolutePath()); logger.info("Clip created successfully with ID: {}", clip.getId()); } diff --git a/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java b/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java index 85bc83a..09e577b 100644 --- a/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java +++ b/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java @@ -5,7 +5,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; import com.ddf.vodsystem.dto.Job; import com.ddf.vodsystem.entities.User; @@ -133,7 +132,8 @@ public class JobOrchestrationService { compressionService.compress(job.getInputFile(), job.getOutputFile(), job.getOutputClipOptions(), job.getStatus().getProcess()) .thenRun(() -> user.ifPresent(userVal -> clipService.persistClip( - job.getOutputClipOptions(), + job.getOutputClipOptions().getTitle(), + job.getOutputClipOptions().getDescription(), userVal, job.getOutputFile(), job.getInputFile().getName() diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index 5cd06ea..309ffa9 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -33,15 +33,18 @@ public class StreamService { private final UserService userService; private final StreamActionsService streamActionsService; private final DirectoryService directoryService; + private final ClipService clipService; public StreamService(StreamRepository streamRepository, UserService userService, StreamActionsService streamActionsService, - DirectoryService directoryService) { + DirectoryService directoryService, + ClipService clipService) { this.streamRepository = streamRepository; this.userService = userService; this.streamActionsService = streamActionsService; this.directoryService = directoryService; + this.clipService = clipService; } public Stream startStream(String streamKey) { @@ -121,9 +124,8 @@ public class StreamService { * or if no stream segments exist in the given range * @throws NotAuthenticated if no user is currently authenticated * @throws IOException if reading the stream directory or its segments fails - * @throws InterruptedException if the thread is interrupted while performing the save */ - public void saveSection(Instant startTime, Instant endTime) throws IOException, InterruptedException { + public void saveSection(Instant startTime, Instant endTime) throws IOException { if (startTime.isAfter(endTime)) { throw new IllegalArgumentException("Start time must be before end time"); } @@ -159,9 +161,8 @@ public class StreamService { * or if no stream segments exist in the computed window * @throws NotAuthenticated if no user is currently authenticated * @throws IOException if reading the stream directory or its segments fails - * @throws InterruptedException if the thread is interrupted while performing the save */ - public void clip(float duration) throws IOException, InterruptedException { + public void clip(float duration) throws IOException { if (duration <= 0 || duration > CLIP_MAX_LENGTH) { throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds"); } @@ -170,7 +171,30 @@ public class StreamService { Instant endTime = Instant.now(); Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000))); - saveSection(startTime, endTime); + User user = userService.getLoggedInUser() + .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); + + File streamDirectory = directoryService.getStreamDir(user.getStreamKey()); + List fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime); + + if (fileSegments.isEmpty()) { + throw new IllegalArgumentException("No stream segments found in the given time range"); + } + + long firstSegmentMs = parseTimestampMs(fileSegments.getFirst()); + float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); + + File outputFile = directoryService.getTempOutputFile(UUID.randomUUID() + ".mp4"); + streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile) + .thenRun(() -> + clipService.persistClip( + Instant.now().toString(), + "", + user, + outputFile, + outputFile.getName() + ) + ); } @Scheduled(fixedDelay = 30_000) diff --git a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java index 00a6f33..091c1bb 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -1,54 +1,80 @@ package com.ddf.vodsystem.services.media; -import com.ddf.vodsystem.services.DirectoryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.io.File; -import java.io.IOException; import java.util.List; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +/** + * Service responsible for performing stream-related media actions, such as + * extracting and saving a trimmed section of video from a sequence of + * recorded segments. + *

+ * Operations in this service are backed by {@code ffmpeg} and run + * asynchronously on a dedicated executor so that potentially long-running + * encoding/concatenation work does not block request-handling threads. + */ @Service public class StreamActionsService { private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class); - private final DirectoryService directoryService; - - public StreamActionsService(DirectoryService directoryService) { - this.directoryService = directoryService; - } - + /** + * Concatenates a list of video segments and extracts a trimmed section + * from the result, writing the output to {@code outputFile}. + *

+ * Failures (process launch errors, ffmpeg failures, interruption) are + * surfaced via the returned future rather than thrown, in line with + * normal {@link CompletableFuture} conventions — callers should use + * {@code .exceptionally()} / {@code .handle()} rather than a try/catch. + * + * @param segments the ordered list of segment files to concatenate, + * in playback order + * @param trimOffset the offset, in seconds, from the start of the + * concatenated stream at which to begin the trimmed + * section + * @param duration the duration, in seconds, of the trimmed section + * to extract + * @param outputFile the destination file the trimmed section will be + * written to; overwritten if it already exists + * @return a {@link CompletableFuture} that completes with + * {@code outputFile} on success, or completes exceptionally on + * failure + */ @Async("ffmpegTaskExecutor") public CompletableFuture saveSection( List segments, float trimOffset, float duration, File outputFile - ) throws IOException, InterruptedException { - String concatInput = segments.stream() - .map(File::getAbsolutePath) - .collect(Collectors.joining("|")); + ) { + try { + String concatInput = segments.stream() + .map(File::getAbsolutePath) + .collect(Collectors.joining("|")); - List command = List.of( - "ffmpeg", - "-y", - "-i", "concat:" + concatInput, - "-ss", String.valueOf(trimOffset), - "-t", String.valueOf(duration), - "-c", "copy", - outputFile.getAbsolutePath() - ); + List command = List.of( + "ffmpeg", + "-y", + "-i", "concat:" + concatInput, + "-ss", String.valueOf(trimOffset), + "-t", String.valueOf(duration), + "-c", "copy", + outputFile.getAbsolutePath() + ); - logger.info("Saving section with ({} segments) to '{}'", - segments.size(), outputFile.getAbsolutePath()); - CommandRunner.run(command, line -> logger.debug("{}", line)); + logger.info("Saving section with ({} segments) to '{}'", + segments.size(), outputFile.getAbsolutePath()); + CommandRunner.run(command, line -> logger.debug("{}", line)); - return CompletableFuture.completedFuture(outputFile); + return CompletableFuture.completedFuture(outputFile); + } catch (Exception e) { + logger.error("Failed to save section to '{}'", outputFile.getAbsolutePath(), e); + return CompletableFuture.failedFuture(e); + } } - -} +} \ No newline at end of file