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