Files
vod-system/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java
Dylan De Faoite b4e450a122
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
fix edge case where first segment is longer than offset
2026-07-26 22:19:08 +01:00

262 lines
11 KiB
Java

package com.ddf.vodsystem.services.media;
import com.ddf.vodsystem.dto.CommandOutput;
import com.ddf.vodsystem.dto.ProgressTracker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
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;
/**
* Service responsible for performing stream-related media actions, such as
* extracting and saving a trimmed section of video from a sequence of
* recorded segments.
* <p>
* 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 CommandRunner commandRunner;
private final MetadataService metadataService;
public StreamActionsService(CommandRunner commandRunner, MetadataService metadataService) {
this.commandRunner = commandRunner;
this.metadataService = metadataService;
}
/**
* Concatenates a list of video segments and extracts a trimmed section
* from the result, writing the output to {@code outputFile}.
* <p>
* 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 the ffmpeg
* command output on success, or completes exceptionally on failure
*/
@Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> saveSection(
List<Path> segments,
float trimOffset,
float duration,
Path outputFile,
ProgressTracker progressTracker
) {
if (segments.isEmpty()) {
return CompletableFuture.failedFuture(new IllegalArgumentException("Segment list cannot be empty"));
}
if (trimOffset < 0 || duration <= 0) {
return CompletableFuture.failedFuture(new IllegalArgumentException(
"trimOffset must be >= 0 and duration must be > 0 (got " + trimOffset + ", " + duration + ")"));
}
Path headSegment = null;
try {
// Unique per invocation so concurrent saves by different users never
// collide on the same head fragment; cleaned up in the finally block.
headSegment = Files.createTempFile("reencode-section-", ".ts");
encodeHead(segments.getFirst(), trimOffset, headSegment);
String concatInput = Stream.concat(Stream.of(headSegment), segments.stream().skip(1))
.map(p -> p.toAbsolutePath().toString())
.collect(Collectors.joining("|"));
List<String> command = List.of(
"ffmpeg",
"-y",
"-i", "concat:" + concatInput,
"-t", String.valueOf(duration),
"-c", "copy",
outputFile.toAbsolutePath().toString()
);
logger.info("Saving section with ({} segments) to '{}'",
segments.size(), outputFile);
CommandOutput output = commandRunner.run(command, line -> commandRunner.setProgress(line, progressTracker, duration));
progressTracker.markComplete();
return CompletableFuture.completedFuture(output);
} catch (IOException e) {
logger.error("IO error on saveSection call: {}", e.toString());
return CompletableFuture.failedFuture(e);
} catch (InterruptedException e) {
logger.error("Thread error on saveSection call: {}", e.toString());
Thread.currentThread().interrupt();
return CompletableFuture.failedFuture(e);
} catch (RuntimeException e) {
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
return CompletableFuture.failedFuture(e);
} finally {
if (headSegment != null) {
try {
Files.deleteIfExists(headSegment);
} catch (IOException e) {
logger.warn("Failed to delete temp head fragment '{}': {}", headSegment, e.toString());
}
}
}
}
public List<Path> getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
long startMs = startTime.toEpochMilli();
long endMs = endTime.toEpochMilli();
if (endTime.toEpochMilli() <= startTime.toEpochMilli()) {
throw new IllegalArgumentException("Start time must be before end time");
}
List<Path> results = new ArrayList<>();
try (Stream<Path> files = Files.list(streamDirectory)) {
List<Path> streamFiles = files
.filter(p -> p.getFileName().toString().endsWith(".ts"))
.filter(Files::isRegularFile)
.sorted(Comparator.comparingLong(this::parseTimestampMs))
.toList();
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))
: Long.MAX_VALUE;
if (segmentStart < endMs && segmentEnd > startMs) {
results.add(streamFile);
}
}
}
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();
if (!fullName.endsWith(".ts")) {
throw new IllegalArgumentException("File must end in ts");
}
String name = fullName.substring(0, fullName.length() - 3);
long value = Long.parseLong(name);
// nginx hls_fragment_naming system uses seconds; convert to ms
return value < 1_000_000_000_000L ? value * 1000L : value;
}
/**
* Re-encodes {@code firstSegment} from {@code trimOffset} to its end,
* writing an MPEG-TS fragment that can be stream-copy concatenated with
* the untouched fragments that follow it.
*/
private void encodeHead(Path firstSegment, float trimOffset, Path head)
throws IOException, InterruptedException {
List<String> command = List.of(
"ffmpeg",
"-y",
"-i", firstSegment.toAbsolutePath().toString(),
"-ss", String.valueOf(trimOffset),
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "20",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "160k",
"-muxdelay", "0",
"-muxpreload", "0",
"-f", "mpegts",
head.toAbsolutePath().toString()
);
logger.debug("Re-encoding head fragment '{}' from {}s", firstSegment, trimOffset);
commandRunner.run(command, line -> { /* head pass is short; not worth reporting */ });
if (!Files.exists(head) || Files.size(head) == 0) {
throw new IOException("Head re-encode produced no output — trimOffset " + trimOffset
+ "s may exceed the duration of " + firstSegment);
}
}
}