166 lines
6.8 KiB
Java
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;
|
|
}
|
|
}
|