test(unit): StreamActionsService tests
This commit is contained in:
@@ -71,6 +71,10 @@ public class StreamActionsService {
|
|||||||
.map(p -> p.toAbsolutePath().toString())
|
.map(p -> p.toAbsolutePath().toString())
|
||||||
.collect(Collectors.joining("|"));
|
.collect(Collectors.joining("|"));
|
||||||
|
|
||||||
|
if (concatInput.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Must have at least one segment");
|
||||||
|
}
|
||||||
|
|
||||||
List<String> command = List.of(
|
List<String> command = List.of(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
@@ -97,9 +101,14 @@ public class StreamActionsService {
|
|||||||
long startMs = startTime.toEpochMilli();
|
long startMs = startTime.toEpochMilli();
|
||||||
long endMs = endTime.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)) {
|
try (Stream<Path> files = Files.list(streamDirectory)) {
|
||||||
return files
|
return files
|
||||||
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
.filter(p -> {
|
.filter(p -> {
|
||||||
long segMs = parseTimestampMs(p);
|
long segMs = parseTimestampMs(p);
|
||||||
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
||||||
@@ -110,7 +119,13 @@ public class StreamActionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public long parseTimestampMs(Path path) {
|
public long parseTimestampMs(Path path) {
|
||||||
String name = path.getFileName().toString().replace(".ts", "");
|
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);
|
long value = Long.parseLong(name);
|
||||||
// nginx hls_fragment_naming system uses seconds; convert to ms
|
// nginx hls_fragment_naming system uses seconds; convert to ms
|
||||||
return value < 1_000_000_000_000L ? value * 1000L : value;
|
return value < 1_000_000_000_000L ? value * 1000L : value;
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
package com.ddf.vodsystem.services.media;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.CommandOutput;
|
||||||
|
import com.ddf.vodsystem.dto.ProgressTracker;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.NoSuchFileException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class StreamActionsServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CommandRunner commandRunner;
|
||||||
|
|
||||||
|
private StreamActionsService service;
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new StreamActionsService(commandRunner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createSegment(String name) throws IOException {
|
||||||
|
Files.createFile(tempDir.resolve(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// getSegmentsInRange: missing / invalid directories
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_directoryDoesNotExist_throwsIOException() {
|
||||||
|
Path missing = tempDir.resolve("does-not-exist");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.getSegmentsInRange(missing, Instant.EPOCH, Instant.now()))
|
||||||
|
.isInstanceOf(NoSuchFileException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_pathIsAFileNotADirectory_throwsIOException() throws IOException {
|
||||||
|
Path file = tempDir.resolve("not-a-directory.txt");
|
||||||
|
Files.createFile(file);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.getSegmentsInRange(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
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_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));
|
||||||
|
|
||||||
|
assertThat(result).extracting(p -> p.getFileName().toString()).containsExactly("1.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_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)))
|
||||||
|
.isInstanceOf(NumberFormatException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_emptyFilenameBeforeExtension_throwsNumberFormatException() throws IOException {
|
||||||
|
createSegment(".ts");
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)))
|
||||||
|
.isInstanceOf(NumberFormatException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// getSegmentsInRange: 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.
|
||||||
|
createSegment("0.ts");
|
||||||
|
|
||||||
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(3000), Instant.ofEpochMilli(10_000));
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_segmentOverlapsRangeStartByOneMs_isIncluded() throws IOException {
|
||||||
|
createSegment("0.ts"); // spans [0, 3000)
|
||||||
|
|
||||||
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(2999), Instant.ofEpochMilli(10_000));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_segmentStartsOneMsBeforeRangeEnd_isIncluded() throws IOException {
|
||||||
|
createSegment("5.ts"); // starts at 5000ms
|
||||||
|
|
||||||
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5001));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_startAfterEnd_throwsIllegalArgument() throws IOException {
|
||||||
|
createSegment("10.ts");
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
service.getSegmentsInRange(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 {
|
||||||
|
createSegment("20.ts");
|
||||||
|
createSegment("10.ts");
|
||||||
|
createSegment("0.ts");
|
||||||
|
|
||||||
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(25_000));
|
||||||
|
|
||||||
|
assertThat(result)
|
||||||
|
.extracting(p -> p.getFileName().toString())
|
||||||
|
.containsExactly("0.ts", "10.ts", "20.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_mixOfOverlappingAndNonOverlappingSegments_returnsOnlyOverlapping() throws IOException {
|
||||||
|
createSegment("0.ts"); // [0, 3000)
|
||||||
|
createSegment("3.ts"); // [3000, 6000)
|
||||||
|
createSegment("6.ts"); // [6000, 9000)
|
||||||
|
createSegment("9.ts"); // [9000, 12000)
|
||||||
|
|
||||||
|
|
||||||
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(4000), Instant.ofEpochMilli(8000));
|
||||||
|
|
||||||
|
assertThat(result).extracting(p -> p.getFileName().toString())
|
||||||
|
.containsExactly("3.ts", "6.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSegmentsInRange_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));
|
||||||
|
|
||||||
|
assertThat(result).extracting(p -> p.getFileName().toString())
|
||||||
|
.containsExactly("10.ts", "20.ts", "30.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// parseTimestampMs: unit-based conversion
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseTimestampMs_secondsBasedName_convertsToMillis() {
|
||||||
|
long result = service.parseTimestampMs(Path.of("42.ts"));
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(42_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseTimestampMs_millisecondBasedName_usedAsIs() {
|
||||||
|
long result = service.parseTimestampMs(Path.of("1700000000000.ts"));
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(1_700_000_000_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseTimestampMs_valueExactlyAtUnitThreshold_treatedAsMillis() {
|
||||||
|
long result = service.parseTimestampMs(Path.of("1000000000000.ts"));
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(1_000_000_000_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseTimestampMs_valueOneBelowUnitThreshold_treatedAsSecondsAndMultiplied() {
|
||||||
|
long result = service.parseTimestampMs(Path.of("999999999999.ts"));
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(999_999_999_999_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseTimestampMs_nonNumericName_throwsNumberFormatException() {
|
||||||
|
assertThatThrownBy(() -> service.parseTimestampMs(Path.of("abc.ts")))
|
||||||
|
.isInstanceOf(NumberFormatException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseTimestampMs_filenameContainsExtraTsSubstring_throwsIllegalArgumentException() {
|
||||||
|
assertThatThrownBy(() -> service.parseTimestampMs(Path.of("1.ts5.ts")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// saveSection
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveSection_success_buildsExpectedCommandAndCompletesWithOutputFile() throws Exception {
|
||||||
|
Path segmentA = Path.of("/tmp/streams/a.ts");
|
||||||
|
Path segmentB = Path.of("/tmp/streams/b.ts");
|
||||||
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
|
when(commandRunner.run(anyList(), any())).thenReturn(new CommandOutput());
|
||||||
|
|
||||||
|
var future = service.saveSection(List.of(segmentA, segmentB), 5.0f, 12.0f, output, progress);
|
||||||
|
|
||||||
|
assertThat(future.get()).isEqualTo(output);
|
||||||
|
assertThat(progress.isComplete()).isTrue();
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
||||||
|
Mockito.verify(commandRunner).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());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveSection_commandRunnerThrowsIOException_completesExceptionallyWithoutMarkingProgressComplete() throws Exception {
|
||||||
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
assertThatThrownBy(future::get)
|
||||||
|
.isInstanceOf(ExecutionException.class)
|
||||||
|
.hasCauseInstanceOf(IOException.class);
|
||||||
|
assertThat(progress.isComplete()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveSection_emptySegmentList_throwsIllegalStateException() {
|
||||||
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
|
var future = service.saveSection(List.of(), 0f, 10f, output, progress);
|
||||||
|
|
||||||
|
assertThatThrownBy(future::get).isInstanceOf(ExecutionException.class);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveSection_nullOutputFile_completesExceptionally() {
|
||||||
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
|
var future = service.saveSection(List.of(Path.of("/tmp/a.ts")), 0f, 10f, null, progress);
|
||||||
|
|
||||||
|
assertThatThrownBy(future::get).isInstanceOf(ExecutionException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user