Files
vod-system/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java
Dylan De Faoite 02ceae73d5
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 incorrect hls segment handling
2026-07-25 13:44:34 +01:00

166 lines
6.8 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.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;
public StreamActionsService(CommandRunner commandRunner) {
this.commandRunner = commandRunner;
}
/**
* 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
) throws IOException {
if (segments.isEmpty()) {
return CompletableFuture.failedFuture(new IllegalArgumentException("Segment list cannot be empty"));
}
Path firstSegment = segments.getFirst();
Path trimmedFirst = Files.createTempFile("trimmed-first-", ".ts");
try {
// First segment must be trimmed manually due to keyframe issues with the concat protocol
List<String> trimCommand = List.of(
"ffmpeg", "-y",
"-ss", String.valueOf(trimOffset),
"-i", firstSegment.toAbsolutePath().toString(),
"-c", "copy",
trimmedFirst.toAbsolutePath().toString()
);
commandRunner.run(trimCommand);
String concatInput = Stream.concat(Stream.of(trimmedFirst), segments.stream().skip(1))
.map(p -> p.toAbsolutePath().toString())
.collect(Collectors.joining("|"));
List<String> command = List.of(
"ffmpeg",
"-y",
"-ss", String.valueOf(trimOffset),
"-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 {
Files.deleteIfExists(trimmedFirst);
}
}
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;
}
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;
}
}