fix(streaming): missing HLS segments in edge requests (#10)
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/10
This commit was merged in pull request #10.
This commit is contained in:
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
@@ -1888,6 +1889,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
@@ -5304,6 +5315,13 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
|
||||
@@ -133,11 +133,10 @@ public class MediaService {
|
||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||
|
||||
Job job = jobRegistryService.generateJob();
|
||||
SegmentContext ctx = resolveSegments(user, startTime, endTime);
|
||||
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
|
||||
StreamActionsService.SegmentSelection ctx = resolveSegments(user, startTime, endTime);
|
||||
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
|
||||
|
||||
return dispatchSaveJob(job, ctx, duration, outputFile, () ->
|
||||
return dispatchSaveJob(job, ctx, outputFile, () ->
|
||||
vodService.persist(
|
||||
title == null ? Instant.now().toString() : title,
|
||||
description == null ? "" : description,
|
||||
@@ -211,10 +210,10 @@ 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, () ->
|
||||
return dispatchSaveJob(job, ctx, outputFile, () ->
|
||||
clipService.persistClip(
|
||||
title == null ? Instant.now().toString() : title,
|
||||
description == null ? "" : description,
|
||||
@@ -225,22 +224,14 @@ 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) {
|
||||
private Job dispatchSaveJob(Job job, StreamActionsService.SegmentSelection ctx, Path outputFile, Runnable onSuccess) {
|
||||
job.setState(JobState.PROCESSING);
|
||||
streamActionsService.saveSection(ctx.segments(), ctx.trimOffset(), duration, outputFile, job.getProgressTracker())
|
||||
streamActionsService.saveSection(ctx, outputFile, job.getProgressTracker())
|
||||
.thenRun(() -> {
|
||||
job.setDownload(outputFile);
|
||||
job.setState(JobState.SUCCEEDED);
|
||||
|
||||
@@ -11,82 +11,161 @@ 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.concurrent.ExecutionException;
|
||||
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.
|
||||
* Extracts and saves a trimmed section of a stream from its recorded HLS
|
||||
* 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.
|
||||
* The usual flow is {@link #selectSegments} to resolve the segments covering a
|
||||
* time range into a {@link SegmentSelection}, then {@link #saveSection} to mux
|
||||
* that selection to a file. Both are backed by {@code ffmpeg} and run on a
|
||||
* dedicated executor so long-running encodes never block request threads.
|
||||
*/
|
||||
@Service
|
||||
public class StreamActionsService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class);
|
||||
private final CommandRunner commandRunner;
|
||||
private final MetadataService metadataService;
|
||||
|
||||
private static final int HLS_SEGMENT_LENGTH = 3;
|
||||
|
||||
public StreamActionsService(CommandRunner commandRunner) {
|
||||
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.
|
||||
* A resolved section of a stream, ready to be muxed by {@link #saveSection}.
|
||||
*
|
||||
* @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
|
||||
* @param segments the segments to concatenate, in playback order; never empty
|
||||
* @param trimOffset seconds to trim from the start of {@code segments.getFirst()}
|
||||
* so playback begins at the requested moment, or {@code 0}
|
||||
* when the first segment already starts there
|
||||
* @param duration seconds of footage to keep, measured from the trim point.
|
||||
* When the requested start fell in a recording gap and the
|
||||
* leading segment was dropped, this is measured from the
|
||||
* later real start so the section still ends where requested
|
||||
*/
|
||||
public record SegmentSelection(List<Path> segments, float trimOffset, float duration) {}
|
||||
|
||||
/**
|
||||
* Resolves the recorded {@code .ts} segments covering {@code [startTime, endTime)}
|
||||
* into a {@link SegmentSelection} ready for {@link #saveSection}.
|
||||
* <p>
|
||||
* The trim offset into the first segment is derived from {@code startTime}. If
|
||||
* that start falls in a recording gap after the earliest overlapping segment
|
||||
* (so the segment holds no footage for it), the segment is dropped and the
|
||||
* selection instead begins at the next recorded footage.
|
||||
*
|
||||
* @param streamDirectory directory holding the stream's {@code .ts} segments
|
||||
* @param startTime inclusive start of the requested section
|
||||
* @param endTime exclusive end of the requested section; must be after {@code startTime}
|
||||
* @return the segments, trim offset, and duration describing the section
|
||||
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
|
||||
* or no segment holds footage for the range
|
||||
* @throws IOException if listing the directory or probing a segment fails
|
||||
*/
|
||||
public SegmentSelection selectSegments(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
|
||||
List<Path> segments = findOverlappingSegments(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);
|
||||
|
||||
// A first segment shorter than the trim offset means the requested start
|
||||
// lands in a gap after it; drop it and begin at the next recorded footage.
|
||||
if (trimOffset > 0 && probeDurationSeconds(segments.getFirst()) < trimOffset) {
|
||||
segments.removeFirst();
|
||||
if (segments.isEmpty()) {
|
||||
throw new IllegalArgumentException("No recorded footage at the requested start time");
|
||||
}
|
||||
firstMs = parseTimestampMs(segments.getFirst());
|
||||
trimOffset = 0f;
|
||||
}
|
||||
|
||||
// Measure duration from where playback actually begins so a dropped
|
||||
// leading segment shortens the section rather than overrunning endTime.
|
||||
long effectiveStartMs = firstMs + (long) (trimOffset * 1000);
|
||||
float duration = (endTime.toEpochMilli() - effectiveStartMs) / 1000f;
|
||||
|
||||
return new SegmentSelection(segments, trimOffset, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Muxes a {@link SegmentSelection} to {@code outputFile}, trimming the leading
|
||||
* segment to {@code selection.trimOffset()} and keeping {@code selection.duration()}
|
||||
* seconds of footage.
|
||||
* <p>
|
||||
* Runs asynchronously; failures (process-launch errors, ffmpeg failures,
|
||||
* interruption) complete the returned future exceptionally rather than being
|
||||
* thrown, so callers should use {@link CompletableFuture#exceptionally} or
|
||||
* {@link CompletableFuture#handle} rather than try/catch.
|
||||
*
|
||||
* @param selection the segments, trim offset, and duration to write
|
||||
* (see {@link #selectSegments})
|
||||
* @param outputFile destination for the muxed section; overwritten if present
|
||||
* @param progressTracker receives progress updates as ffmpeg runs
|
||||
* @return a future completing with the ffmpeg output, or exceptionally on failure
|
||||
*/
|
||||
@Async("ffmpegTaskExecutor")
|
||||
public CompletableFuture<CommandOutput> saveSection(
|
||||
List<Path> segments,
|
||||
float trimOffset,
|
||||
float duration,
|
||||
SegmentSelection selection,
|
||||
Path outputFile,
|
||||
ProgressTracker progressTracker
|
||||
) {
|
||||
try {
|
||||
String concatInput = segments.stream()
|
||||
.map(p -> p.toAbsolutePath().toString())
|
||||
.collect(Collectors.joining("|"));
|
||||
List<Path> segments = selection.segments();
|
||||
float trimOffset = selection.trimOffset();
|
||||
float duration = selection.duration();
|
||||
|
||||
if (concatInput.isEmpty()) {
|
||||
throw new IllegalStateException("Must have at least one segment");
|
||||
if (segments.isEmpty()) {
|
||||
return CompletableFuture.failedFuture(new IllegalArgumentException("Selection must contain at least one segment"));
|
||||
}
|
||||
if (trimOffset < 0 || duration <= 0) {
|
||||
return CompletableFuture.failedFuture(new IllegalArgumentException(
|
||||
"trimOffset must be >= 0 and duration must be > 0 (got " + trimOffset + ", " + duration + ")"));
|
||||
}
|
||||
|
||||
Path headFragment = null;
|
||||
Path concatList = null;
|
||||
try {
|
||||
// Only the first segment is trimmed, so re-encode just that fragment and
|
||||
// stream-copy the rest. A zero offset means the first segment already
|
||||
// starts at the requested point, so re-encoding it would be wasted work.
|
||||
List<Path> fragments;
|
||||
if (trimOffset > 0) {
|
||||
headFragment = Files.createTempFile("reencode-section-", ".ts");
|
||||
encodeHead(segments.getFirst(), trimOffset, headFragment);
|
||||
|
||||
fragments = new ArrayList<>();
|
||||
fragments.add(headFragment);
|
||||
fragments.addAll(segments.subList(1, segments.size()));
|
||||
} else {
|
||||
fragments = segments;
|
||||
}
|
||||
|
||||
// The concat demuxer (unlike the concat: byte protocol) rebases each
|
||||
// fragment's timestamps to run continuously across the joins, absorbing
|
||||
// the discontinuity between the PTS-0 head and the copied fragments.
|
||||
concatList = writeConcatList(fragments);
|
||||
|
||||
List<String> command = List.of(
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i", "concat:" + concatInput,
|
||||
"-ss", String.valueOf(trimOffset),
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", concatList.toAbsolutePath().toString(),
|
||||
"-t", String.valueOf(duration),
|
||||
"-c", "copy",
|
||||
outputFile.toAbsolutePath().toString()
|
||||
);
|
||||
|
||||
logger.info("Saving section with ({} segments) to '{}'",
|
||||
segments.size(), outputFile);
|
||||
logger.info("Saving section ({} segments) to '{}'", segments.size(), outputFile);
|
||||
CommandOutput output = commandRunner.run(command, line -> commandRunner.setProgress(line, progressTracker, duration));
|
||||
|
||||
progressTracker.markComplete();
|
||||
@@ -101,30 +180,22 @@ public class StreamActionsService {
|
||||
} catch (RuntimeException e) {
|
||||
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
||||
return CompletableFuture.failedFuture(e);
|
||||
} finally {
|
||||
deleteTempQuietly(headFragment);
|
||||
deleteTempQuietly(concatList);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
try (Stream<Path> files = Files.list(streamDirectory)) {
|
||||
return files
|
||||
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(p -> {
|
||||
long segMs = parseTimestampMs(p);
|
||||
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
||||
})
|
||||
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the start timestamp encoded in an HLS segment's filename (e.g.
|
||||
* {@code 1712345678.ts}). nginx names fragments in whole seconds, so
|
||||
* second-scale values are scaled up to milliseconds.
|
||||
*
|
||||
* @param path a {@code .ts} segment path
|
||||
* @return the segment's start time in epoch milliseconds
|
||||
* @throws IllegalArgumentException if {@code path} is not a {@code .ts} file
|
||||
* @throws NumberFormatException if the filename stem is not numeric
|
||||
*/
|
||||
public long parseTimestampMs(Path path) {
|
||||
String fullName = path.getFileName().toString();
|
||||
|
||||
@@ -134,7 +205,104 @@ public class StreamActionsService {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the {@code .ts} segments whose playback interval overlaps
|
||||
* {@code [startTime, endTime)}, in playback order. A segment spans from its
|
||||
* own timestamp up to the next segment's timestamp (or open-ended for the last).
|
||||
*/
|
||||
private List<Path> findOverlappingSegments(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
|
||||
long startMs = startTime.toEpochMilli();
|
||||
long endMs = endTime.toEpochMilli();
|
||||
|
||||
if (endMs <= startMs) {
|
||||
throw new IllegalArgumentException("Start time must be before end time");
|
||||
}
|
||||
|
||||
List<Path> overlapping = 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) {
|
||||
overlapping.add(streamFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return overlapping;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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; no progress 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);
|
||||
}
|
||||
}
|
||||
|
||||
private Path writeConcatList(List<Path> fragments) throws IOException {
|
||||
Path listFile = Files.createTempFile("concat-list-", ".txt");
|
||||
List<String> lines = fragments.stream()
|
||||
.map(p -> "file '" + p.toAbsolutePath().toString().replace("'", "'\\''") + "'")
|
||||
.toList();
|
||||
Files.write(listFile, lines);
|
||||
return listFile;
|
||||
}
|
||||
|
||||
private void deleteTempQuietly(Path tempFile) {
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to delete temp file '{}': {}", tempFile, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,155 +43,163 @@ 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}
|
||||
* verifies its output exists and is non-empty before continuing. The concat
|
||||
* pass (no {@code mpegts} muxer) is left untouched. Returns {@code output}
|
||||
* for both invocations.
|
||||
*/
|
||||
private void stubRunnerProducingHead(CommandOutput output) throws IOException, InterruptedException {
|
||||
when(commandRunner.run(anyList(), any())).thenAnswer(inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
if (cmd.contains("mpegts")) {
|
||||
Files.writeString(Path.of(cmd.getLast()), "x");
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// getSegmentsInRange: missing / invalid directories
|
||||
// selectSegments: missing / invalid directories
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_directoryDoesNotExist_throwsIOException() {
|
||||
void selectSegments_directoryDoesNotExist_throwsIOException() {
|
||||
Path missing = tempDir.resolve("does-not-exist");
|
||||
|
||||
assertThatThrownBy(() -> service.getSegmentsInRange(missing, Instant.EPOCH, Instant.now()))
|
||||
assertThatThrownBy(() -> service.selectSegments(missing, Instant.EPOCH, Instant.now()))
|
||||
.isInstanceOf(NoSuchFileException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_pathIsAFileNotADirectory_throwsIOException() throws IOException {
|
||||
void selectSegments_pathIsAFileNotADirectory_throwsIOException() throws IOException {
|
||||
Path file = tempDir.resolve("not-a-directory.txt");
|
||||
Files.createFile(file);
|
||||
|
||||
assertThatThrownBy(() -> service.getSegmentsInRange(file, Instant.EPOCH, Instant.now()))
|
||||
assertThatThrownBy(() -> service.selectSegments(file, Instant.EPOCH, Instant.now()))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_emptyDirectory_returnsEmptyList() throws IOException {
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(100_000));
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// getSegmentsInRange: filename filtering
|
||||
// selectSegments: filename filtering
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_ignoresFilesNotEndingInTs() throws IOException {
|
||||
void selectSegments_ignoresFilesNotEndingInTs() throws IOException {
|
||||
createSegment("1.ts");
|
||||
createSegment("playlist.m3u8");
|
||||
createSegment("1.ts.tmp");
|
||||
createSegment("readme.txt");
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000));
|
||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000)).segments();
|
||||
|
||||
assertThat(result).extracting(p -> p.getFileName().toString()).containsExactly("1.ts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_directoryNamedLikeASegment_isNotIncluded() throws IOException {
|
||||
void selectSegments_directoryNamedLikeASegment_isNotIncluded() throws IOException {
|
||||
Files.createDirectory(tempDir.resolve("5.ts"));
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000));
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
// the directory is the only ".ts" entry; excluding it leaves nothing to select
|
||||
assertThatThrownBy(() ->
|
||||
service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_malformedNonNumericFilename_throwsNumberFormatException() throws IOException {
|
||||
void selectSegments_malformedNonNumericFilename_throwsNumberFormatException() throws IOException {
|
||||
// A real HLS fragment name is always numeric. A corrupted or partially-written
|
||||
// file (e.g. "index.ts") crashes the whole listing instead of just being skipped.
|
||||
createSegment("index.ts");
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
||||
service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
||||
.isInstanceOf(NumberFormatException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_emptyFilenameBeforeExtension_throwsNumberFormatException() throws IOException {
|
||||
void selectSegments_emptyFilenameBeforeExtension_throwsNumberFormatException() throws IOException {
|
||||
createSegment(".ts");
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
||||
service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
||||
.isInstanceOf(NumberFormatException.class);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// getSegmentsInRange: range-boundary arithmetic
|
||||
// selectSegments: range-boundary arithmetic
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_segmentEndsExactlyAtRangeStart_isExcluded() throws IOException {
|
||||
// "0.ts" spans [0, 3000)ms. A request starting exactly at 3000ms should not
|
||||
// pull in a segment that ended the instant before.
|
||||
void selectSegments_segmentEndsExactlyAtRangeStart_isExcluded() throws IOException {
|
||||
createSegment("0.ts");
|
||||
createSegment("3.ts");
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(3000), Instant.ofEpochMilli(10_000));
|
||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(3000), Instant.ofEpochMilli(10_000)).segments();
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
assertThat(result).extracting(p -> p.getFileName().toString())
|
||||
.containsExactly("3.ts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_segmentOverlapsRangeStartByOneMs_isIncluded() throws IOException {
|
||||
void selectSegments_segmentOverlapsRangeStartByOneMs_isIncluded() throws IOException {
|
||||
createSegment("0.ts"); // spans [0, 3000)
|
||||
stubHeadDuration(3f); // start 2999ms trims into 0.ts, so its duration is probed
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(2999), Instant.ofEpochMilli(10_000));
|
||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(2999), Instant.ofEpochMilli(10_000)).segments();
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_segmentStartsExactlyAtRangeEnd_isExcluded() throws IOException {
|
||||
void selectSegments_segmentStartsExactlyAtRangeEnd_isExcluded() throws IOException {
|
||||
createSegment("5.ts"); // starts at 5000ms
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000));
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
assertThatThrownBy(() ->
|
||||
service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_segmentStartsOneMsBeforeRangeEnd_isIncluded() throws IOException {
|
||||
void selectSegments_segmentStartsOneMsBeforeRangeEnd_isIncluded() throws IOException {
|
||||
createSegment("5.ts"); // starts at 5000ms
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5001));
|
||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5001)).segments();
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_startAfterEnd_throwsIllegalArgument() throws IOException {
|
||||
void selectSegments_startAfterEnd_throwsIllegalArgument() throws IOException {
|
||||
createSegment("10.ts");
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(20_000), Instant.ofEpochMilli(10_000)))
|
||||
service.selectSegments(tempDir, Instant.ofEpochMilli(20_000), Instant.ofEpochMilli(10_000)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_segmentsWhollyOutsideRange_areExcluded() throws IOException {
|
||||
createSegment("0.ts"); // [0, 3000)
|
||||
createSegment("100.ts"); // [100000, 103000)
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(50_000), Instant.ofEpochMilli(60_000));
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_segmentsOutOfOrder_returnedInOrder() throws IOException {
|
||||
void selectSegments_segmentsOutOfOrder_returnedInOrder() throws IOException {
|
||||
createSegment("20.ts");
|
||||
createSegment("10.ts");
|
||||
createSegment("0.ts");
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(25_000));
|
||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(25_000)).segments();
|
||||
|
||||
assertThat(result)
|
||||
.extracting(p -> p.getFileName().toString())
|
||||
@@ -194,31 +207,98 @@ class StreamActionsServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_mixOfOverlappingAndNonOverlappingSegments_returnsOnlyOverlapping() throws IOException {
|
||||
void selectSegments_mixOfOverlappingAndNonOverlappingSegments_returnsOnlyOverlapping() throws IOException {
|
||||
createSegment("0.ts"); // [0, 3000)
|
||||
createSegment("3.ts"); // [3000, 6000)
|
||||
createSegment("6.ts"); // [6000, 9000)
|
||||
createSegment("9.ts"); // [9000, 12000)
|
||||
stubHeadDuration(3f); // start 4000ms trims into 3.ts, so its duration is probed
|
||||
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(4000), Instant.ofEpochMilli(8000));
|
||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(4000), Instant.ofEpochMilli(8000)).segments();
|
||||
|
||||
assertThat(result).extracting(p -> p.getFileName().toString())
|
||||
.containsExactly("3.ts", "6.ts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSegmentsInRange_resultsAreSortedByTimestampRegardlessOfDirectoryOrder() throws IOException {
|
||||
void selectSegments_resultsAreSortedByTimestampRegardlessOfDirectoryOrder() throws IOException {
|
||||
createSegment("30.ts");
|
||||
createSegment("10.ts");
|
||||
createSegment("20.ts");
|
||||
|
||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(100_000));
|
||||
var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(100_000)).segments();
|
||||
|
||||
assertThat(result).extracting(p -> p.getFileName().toString())
|
||||
.containsExactly("10.ts", "20.ts", "30.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);
|
||||
// effective start equals the requested 31.5s, so duration runs to endTime: 40 - 31.5
|
||||
assertThat(selection.duration()).isEqualTo(8.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();
|
||||
// the dropped head shifts the effective start to 100s, so the duration
|
||||
// shrinks to end at endTime (111 - 100 = 11s) instead of the 41s the
|
||||
// original 70s->111s window would have implied
|
||||
assertThat(selection.duration()).isEqualTo(11f);
|
||||
}
|
||||
|
||||
@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
|
||||
// ---------------------------------------------------------------
|
||||
@@ -275,24 +355,55 @@ class StreamActionsServiceTest {
|
||||
ProgressTracker progress = new ProgressTracker();
|
||||
CommandOutput commandOutput = new CommandOutput();
|
||||
|
||||
when(commandRunner.run(anyList(), any())).thenReturn(commandOutput);
|
||||
stubRunnerProducingHead(commandOutput);
|
||||
|
||||
var future = service.saveSection(List.of(segmentA, segmentB), 5.0f, 12.0f, output, progress);
|
||||
var future = service.saveSection(
|
||||
new StreamActionsService.SegmentSelection(List.of(segmentA, segmentB), 5.0f, 12.0f), output, progress);
|
||||
|
||||
assertThat(future.get()).isSameAs(commandOutput);
|
||||
assertThat(progress.isComplete()).isTrue();
|
||||
|
||||
// With a non-zero trim offset saveSection issues two ffmpeg passes: the
|
||||
// head re-encode (trims the first segment via -ss) followed by the
|
||||
// concat-demuxer stream copy (-f concat / -t / -c copy).
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
||||
Mockito.verify(commandRunner).run(captor.capture(), any());
|
||||
Mockito.verify(commandRunner, Mockito.times(2)).run(captor.capture(), any());
|
||||
List<String> headCommand = captor.getAllValues().get(0);
|
||||
List<String> concatCommand = captor.getAllValues().get(1);
|
||||
|
||||
assertThat(headCommand).containsSequence("-ss", "5.0");
|
||||
assertThat(concatCommand).containsSequence("-f", "concat");
|
||||
assertThat(concatCommand).containsSequence("-t", "12.0");
|
||||
assertThat(concatCommand).containsSequence("-c", "copy");
|
||||
assertThat(concatCommand).endsWith(output.toAbsolutePath().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveSection_zeroTrimOffset_skipsHeadReencodeAndRunsSingleConcatPass() throws Exception {
|
||||
Path output = Path.of("/tmp/out/clip.mp4");
|
||||
ProgressTracker progress = new ProgressTracker();
|
||||
CommandOutput commandOutput = new CommandOutput();
|
||||
|
||||
when(commandRunner.run(anyList(), any())).thenReturn(commandOutput);
|
||||
|
||||
var future = service.saveSection(
|
||||
new StreamActionsService.SegmentSelection(
|
||||
List.of(Path.of("/tmp/a.ts"), Path.of("/tmp/b.ts")), 0f, 10f), output, progress);
|
||||
|
||||
assertThat(future.get()).isSameAs(commandOutput);
|
||||
assertThat(progress.isComplete()).isTrue();
|
||||
|
||||
// trimOffset 0 means the first segment already starts at the requested point:
|
||||
// no head re-encode pass, just a single concat-demuxer copy (no -ss).
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
||||
Mockito.verify(commandRunner, Mockito.times(1)).run(captor.capture(), any());
|
||||
List<String> command = captor.getValue();
|
||||
|
||||
assertThat(command).containsSequence("-i",
|
||||
"concat:" + segmentA.toAbsolutePath() + "|" + segmentB.toAbsolutePath());
|
||||
assertThat(command).containsSequence("-ss", "5.0");
|
||||
assertThat(command).containsSequence("-t", "12.0");
|
||||
assertThat(command).containsSequence("-c", "copy");
|
||||
assertThat(command).endsWith(output.toAbsolutePath().toString());
|
||||
assertThat(command).containsSequence("-f", "concat");
|
||||
assertThat(command).containsSequence("-t", "10.0");
|
||||
assertThat(command).doesNotContain("-ss");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -300,9 +411,12 @@ class StreamActionsServiceTest {
|
||||
Path output = Path.of("/tmp/out/clip.mp4");
|
||||
ProgressTracker progress = new ProgressTracker();
|
||||
|
||||
// With a non-zero trim offset the head re-encode is the first ffmpeg pass;
|
||||
// its failure must surface.
|
||||
when(commandRunner.run(anyList(), any())).thenThrow(new IOException("ffmpeg boom"));
|
||||
|
||||
var future = service.saveSection(List.of(Path.of("/tmp/a.ts")), 0f, 10f, output, progress);
|
||||
var future = service.saveSection(
|
||||
new StreamActionsService.SegmentSelection(List.of(Path.of("/tmp/a.ts")), 2f, 10f), output, progress);
|
||||
|
||||
assertThatThrownBy(future::get)
|
||||
.isInstanceOf(ExecutionException.class)
|
||||
@@ -311,15 +425,16 @@ class StreamActionsServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveSection_emptySegmentList_completesExceptionallyWithIllegalStateException() {
|
||||
void saveSection_emptySegmentList_completesExceptionallyWithIllegalArgumentException() {
|
||||
Path output = Path.of("/tmp/out/clip.mp4");
|
||||
ProgressTracker progress = new ProgressTracker();
|
||||
|
||||
var future = service.saveSection(List.of(), 0f, 10f, output, progress);
|
||||
var future = service.saveSection(
|
||||
new StreamActionsService.SegmentSelection(List.of(), 0f, 10f), output, progress);
|
||||
|
||||
assertThatThrownBy(future::get)
|
||||
.isInstanceOf(ExecutionException.class)
|
||||
.hasCauseInstanceOf(IllegalStateException.class);
|
||||
.hasCauseInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(progress.isComplete()).isFalse();
|
||||
}
|
||||
|
||||
@@ -327,7 +442,10 @@ class StreamActionsServiceTest {
|
||||
void saveSection_nullOutputFile_completesExceptionally() {
|
||||
ProgressTracker progress = new ProgressTracker();
|
||||
|
||||
var future = service.saveSection(List.of(Path.of("/tmp/a.ts")), 0f, 10f, null, progress);
|
||||
// trimOffset 0 skips the head pass; building the concat command dereferences
|
||||
// the null output path, so no ffmpeg pass ever runs.
|
||||
var future = service.saveSection(
|
||||
new StreamActionsService.SegmentSelection(List.of(Path.of("/tmp/a.ts")), 0f, 10f), null, progress);
|
||||
|
||||
assertThatThrownBy(future::get)
|
||||
.isInstanceOf(ExecutionException.class)
|
||||
|
||||
Reference in New Issue
Block a user