From b4e450a12212ebc2f274a69c814e907a4cc1a069 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 26 Jul 2026 22:19:08 +0100 Subject: [PATCH] fix edge case where first segment is longer than offset --- .../ddf/vodsystem/services/MediaService.java | 18 ++--- .../services/media/StreamActionsService.java | 59 +++++++++++++- .../media/StreamActionsServiceTest.java | 79 ++++++++++++++++++- 3 files changed, 139 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java index 195d89b..57deb71 100644 --- a/src/main/java/com/ddf/vodsystem/services/MediaService.java +++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java @@ -133,7 +133,7 @@ public class MediaService { .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); Job job = jobRegistryService.generateJob(); - SegmentContext ctx = resolveSegments(user, startTime, endTime); + StreamActionsService.SegmentSelection ctx = resolveSegments(user, startTime, endTime); float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f; Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4"); @@ -211,7 +211,7 @@ public class MediaService { .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); Job job = jobRegistryService.generateJob(); - SegmentContext ctx = resolveSegments(user, startTime, endTime); + StreamActionsService.SegmentSelection ctx = resolveSegments(user, startTime, endTime); Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4"); return dispatchSaveJob(job, ctx, duration, outputFile, () -> @@ -225,20 +225,12 @@ public class MediaService { ); } - private record SegmentContext(List segments, float trimOffset) {} - - private SegmentContext resolveSegments(User user, Instant startTime, Instant endTime) throws IOException { + private StreamActionsService.SegmentSelection resolveSegments(User user, Instant startTime, Instant endTime) throws IOException { Path streamDir = directoryService.getStreamDir(user.getStreamKey()); - List segments = streamActionsService.getSegmentsInRange(streamDir, startTime, endTime); - if (segments.isEmpty()) { - throw new IllegalArgumentException("No stream segments found in the given time range"); - } - long firstMs = streamActionsService.parseTimestampMs(segments.getFirst()); - float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f); - return new SegmentContext(segments, trimOffset); + return streamActionsService.selectSegments(streamDir, startTime, endTime); } - private Job dispatchSaveJob(Job job, SegmentContext ctx, float duration, Path outputFile, Runnable onSuccess) throws IOException { + private Job dispatchSaveJob(Job job, StreamActionsService.SegmentSelection ctx, float duration, Path outputFile, Runnable onSuccess) { job.setState(JobState.PROCESSING); streamActionsService.saveSection(ctx.segments(), ctx.trimOffset(), duration, outputFile, job.getProgressTracker()) .thenRun(() -> { 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 aeb8339..dcd6664 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -31,9 +32,11 @@ import java.util.stream.Stream; public class StreamActionsService { private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class); private final CommandRunner commandRunner; + private final MetadataService metadataService; - public StreamActionsService(CommandRunner commandRunner) { + public StreamActionsService(CommandRunner commandRunner, MetadataService metadataService) { this.commandRunner = commandRunner; + this.metadataService = metadataService; } /** @@ -141,6 +144,7 @@ public class StreamActionsService { for (int i = 0; i < streamFiles.size(); i++) { Path streamFile = streamFiles.get(i); + long segmentStart = parseTimestampMs(streamFile); long segmentEnd = (i + 1 < streamFiles.size()) ? parseTimestampMs(streamFiles.get(i + 1)) @@ -155,6 +159,59 @@ public class StreamActionsService { return results; } + /** + * The segments backing a save and the offset into the first one at which + * the requested section begins. + * + * @param segments the ordered segments to concatenate, in playback order + * @param trimOffset the offset, in seconds, into {@code segments.getFirst()} + * at which the requested section starts + */ + public record SegmentSelection(List segments, float trimOffset) {} + + /** + * Selects the recorded {@code .ts} segments overlapping {@code [startTime, endTime)} + * and computes the trim offset into the first segment. + *

+ * If {@code startTime} falls in a stream gap after the earliest overlapping segment + * (so that segment holds no footage for the requested start), the segment is dropped + * and the selection begins at the first genuinely-recorded footage instead. + * + * @throws IllegalArgumentException if no segments hold footage for the requested range + * @throws IOException if listing the stream directory or probing a segment fails + */ + public SegmentSelection selectSegments(Path streamDirectory, Instant startTime, Instant endTime) throws IOException { + List segments = getSegmentsInRange(streamDirectory, startTime, endTime); + if (segments.isEmpty()) { + throw new IllegalArgumentException("No stream segments found in the given time range"); + } + + long firstMs = parseTimestampMs(segments.getFirst()); + float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f); + + // if the first segment is longer than the trim-offset, then ggs + if (trimOffset > 0 && probeDurationSeconds(segments.getFirst()) < trimOffset) { + segments.removeFirst(); + if (segments.isEmpty()) { + throw new IllegalArgumentException("No recorded footage at the requested start time"); + } + trimOffset = 0f; + } + + return new SegmentSelection(segments, trimOffset); + } + + private float probeDurationSeconds(Path segment) throws IOException { + try { + return metadataService.getVideoMetadata(segment).get().getDuration(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while probing segment duration: " + segment, e); + } catch (ExecutionException e) { + throw new IOException("Failed to probe segment duration: " + segment, e); + } + } + public long parseTimestampMs(Path path) { String fullName = path.getFileName().toString(); diff --git a/src/test/java/com/ddf/vodsystem/services/media/StreamActionsServiceTest.java b/src/test/java/com/ddf/vodsystem/services/media/StreamActionsServiceTest.java index 0d9e02c..2afe492 100644 --- a/src/test/java/com/ddf/vodsystem/services/media/StreamActionsServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/services/media/StreamActionsServiceTest.java @@ -1,5 +1,6 @@ package com.ddf.vodsystem.services.media; +import com.ddf.vodsystem.dto.ClipOptions; import com.ddf.vodsystem.dto.CommandOutput; import com.ddf.vodsystem.dto.ProgressTracker; import org.junit.jupiter.api.BeforeEach; @@ -17,6 +18,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.time.Instant; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import static org.assertj.core.api.Assertions.assertThat; @@ -31,6 +33,9 @@ class StreamActionsServiceTest { @Mock private CommandRunner commandRunner; + @Mock + private MetadataService metadataService; + private StreamActionsService service; @TempDir @@ -38,13 +43,20 @@ class StreamActionsServiceTest { @BeforeEach void setUp() { - service = new StreamActionsService(commandRunner); + service = new StreamActionsService(commandRunner, metadataService); } private void createSegment(String name) throws IOException { Files.createFile(tempDir.resolve(name)); } + /** Stubs the (async) metadata probe so {@code segments.getFirst()} reports {@code seconds}. */ + private void stubHeadDuration(float seconds) { + ClipOptions meta = new ClipOptions(); + meta.setDuration(seconds); + when(metadataService.getVideoMetadata(any())).thenReturn(CompletableFuture.completedFuture(meta)); + } + /** * Stubs the two-arg {@link CommandRunner#run} so the head re-encode pass * actually writes a non-empty file at its output path — {@code encodeHead} @@ -56,7 +68,7 @@ class StreamActionsServiceTest { when(commandRunner.run(anyList(), any())).thenAnswer(inv -> { List cmd = inv.getArgument(0); if (cmd.contains("mpegts")) { - Files.writeString(Path.of(cmd.get(cmd.size() - 1)), "x"); + Files.writeString(Path.of(cmd.getLast()), "x"); } return output; }); @@ -240,6 +252,67 @@ class StreamActionsServiceTest { .containsExactly("30.ts", "100.ts", "110.ts"); } + // --------------------------------------------------------------- + // selectSegments: head selection and trim-offset + // --------------------------------------------------------------- + + @Test + void selectSegments_startWithinFirstSegment_keepsItAndComputesOffset() throws IOException { + createSegment("30.ts"); + createSegment("33.ts"); + stubHeadDuration(3f); // 30.ts spans [30s, 33s) + + var selection = service.selectSegments(tempDir, Instant.ofEpochMilli(31_500), Instant.ofEpochMilli(40_000)); + + assertThat(selection.segments()).extracting(p -> p.getFileName().toString()) + .containsExactly("30.ts", "33.ts"); + assertThat(selection.trimOffset()).isEqualTo(1.5f); + } + + @Test + void selectSegments_startInGapAfterFirstSegment_dropsItAndStartsAtNext() throws IOException { + createSegment("30.ts"); + // user stops streaming; a gap follows 30.ts + createSegment("100.ts"); + createSegment("110.ts"); + stubHeadDuration(3f); // 30.ts only holds [30s, 33s); requested start 70s is past it + + var selection = service.selectSegments(tempDir, Instant.ofEpochMilli(70_000), Instant.ofEpochMilli(111_000)); + + assertThat(selection.segments()).extracting(p -> p.getFileName().toString()) + .containsExactly("100.ts", "110.ts"); + assertThat(selection.trimOffset()).isZero(); + } + + @Test + void selectSegments_startPastOnlySegment_throwsIllegalArgument() throws IOException { + createSegment("0.ts"); + stubHeadDuration(3f); // 0.ts holds [0s, 3s); requested start 50s has no footage + + assertThatThrownBy(() -> + service.selectSegments(tempDir, Instant.ofEpochMilli(50_000), Instant.ofEpochMilli(60_000))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void selectSegments_noSegmentsInRange_throwsIllegalArgument() { + assertThatThrownBy(() -> + service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void selectSegments_probeFailure_surfacesAsIOException() throws IOException { + createSegment("30.ts"); + createSegment("100.ts"); + when(metadataService.getVideoMetadata(any())) + .thenReturn(CompletableFuture.failedFuture(new IOException("ffprobe boom"))); + + assertThatThrownBy(() -> + service.selectSegments(tempDir, Instant.ofEpochMilli(70_000), Instant.ofEpochMilli(111_000))) + .isInstanceOf(IOException.class); + } + // --------------------------------------------------------------- // parseTimestampMs: unit-based conversion // --------------------------------------------------------------- @@ -334,7 +407,7 @@ class StreamActionsServiceTest { } @Test - void saveSection_emptySegmentList_completesExceptionallyWithIllegalArgumentException() throws IOException { + void saveSection_emptySegmentList_completesExceptionallyWithIllegalArgumentException() { Path output = Path.of("/tmp/out/clip.mp4"); ProgressTracker progress = new ProgressTracker();