fix edge case where first segment is longer than offset
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-07-26 22:19:08 +01:00
parent 41a580a531
commit b4e450a122
3 changed files with 139 additions and 17 deletions

View File

@@ -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<Path> 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<Path> 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(() -> {

View File

@@ -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<Path> segments, float trimOffset) {}
/**
* Selects the recorded {@code .ts} segments overlapping {@code [startTime, endTime)}
* and computes the trim offset into the first segment.
* <p>
* 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<Path> 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();

View File

@@ -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<String> 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();