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; 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.CompletableFuture; 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; @Mock private MetadataService metadataService; private StreamActionsService service; @TempDir Path tempDir; @BeforeEach void setUp() { 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)); } // --------------------------------------------------------------- // selectSegments: missing / invalid directories // --------------------------------------------------------------- @Test void selectSegments_directoryDoesNotExist_throwsIOException() { Path missing = tempDir.resolve("does-not-exist"); assertThatThrownBy(() -> service.selectSegments(missing, Instant.EPOCH, Instant.now())) .isInstanceOf(NoSuchFileException.class); } @Test void selectSegments_pathIsAFileNotADirectory_throwsIOException() throws IOException { Path file = tempDir.resolve("not-a-directory.txt"); Files.createFile(file); assertThatThrownBy(() -> service.selectSegments(file, Instant.EPOCH, Instant.now())) .isInstanceOf(IOException.class); } // --------------------------------------------------------------- // selectSegments: filename filtering // --------------------------------------------------------------- @Test void selectSegments_ignoresFilesNotEndingInTs() throws IOException { createSegment("1.ts"); createSegment("playlist.m3u8"); createSegment("1.ts.tmp"); createSegment("readme.txt"); var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000)).segments(); assertThat(result).extracting(p -> p.getFileName().toString()).containsExactly("1.ts"); } @Test void selectSegments_directoryNamedLikeASegment_isNotIncluded() throws IOException { Files.createDirectory(tempDir.resolve("5.ts")); // 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 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.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000))) .isInstanceOf(NumberFormatException.class); } @Test void selectSegments_emptyFilenameBeforeExtension_throwsNumberFormatException() throws IOException { createSegment(".ts"); assertThatThrownBy(() -> service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000))) .isInstanceOf(NumberFormatException.class); } // --------------------------------------------------------------- // selectSegments: range-boundary arithmetic // --------------------------------------------------------------- @Test void selectSegments_segmentEndsExactlyAtRangeStart_isExcluded() throws IOException { createSegment("0.ts"); createSegment("3.ts"); var result = service.selectSegments(tempDir, Instant.ofEpochMilli(3000), Instant.ofEpochMilli(10_000)).segments(); assertThat(result).extracting(p -> p.getFileName().toString()) .containsExactly("3.ts"); } @Test 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 var result = service.selectSegments(tempDir, Instant.ofEpochMilli(2999), Instant.ofEpochMilli(10_000)).segments(); assertThat(result).hasSize(1); } @Test void selectSegments_segmentStartsExactlyAtRangeEnd_isExcluded() throws IOException { createSegment("5.ts"); // starts at 5000ms assertThatThrownBy(() -> service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000))) .isInstanceOf(IllegalArgumentException.class); } @Test void selectSegments_segmentStartsOneMsBeforeRangeEnd_isIncluded() throws IOException { createSegment("5.ts"); // starts at 5000ms var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5001)).segments(); assertThat(result).hasSize(1); } @Test void selectSegments_startAfterEnd_throwsIllegalArgument() throws IOException { createSegment("10.ts"); assertThatThrownBy(() -> service.selectSegments(tempDir, Instant.ofEpochMilli(20_000), Instant.ofEpochMilli(10_000))) .isInstanceOf(IllegalArgumentException.class); } @Test void selectSegments_segmentsOutOfOrder_returnedInOrder() throws IOException { createSegment("20.ts"); createSegment("10.ts"); createSegment("0.ts"); var result = service.selectSegments(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(25_000)).segments(); assertThat(result) .extracting(p -> p.getFileName().toString()) .containsExactly("0.ts", "10.ts", "20.ts"); } @Test 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 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 selectSegments_resultsAreSortedByTimestampRegardlessOfDirectoryOrder() throws IOException { createSegment("30.ts"); createSegment("10.ts"); createSegment("20.ts"); 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); // 30.ts is copied whole, so the section starts at 30s rather than the // requested 31.5s; duration runs from there to endTime: 40 - 30. The // 1.5s trimOffset is carried as lead-in, not cut away. assertThat(selection.duration()).isEqualTo(10f); } @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 // --------------------------------------------------------------- @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_buildsExpectedCommandAndCompletesWithCommandOutput() 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(); CommandOutput commandOutput = new CommandOutput(); when(commandRunner.run(anyList(), any())).thenReturn(commandOutput); 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(); // The section is one stream-copy pass over the byte-concatenated segments: // the concat: protocol joins them without rebasing the timestamps nginx // wrote across fragments, which is what keeps the joins seamless. @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); Mockito.verify(commandRunner, Mockito.times(1)).run(captor.capture(), any()); List command = captor.getValue(); assertThat(command).containsSequence( "-i", "concat:" + segmentA.toAbsolutePath() + "|" + segmentB.toAbsolutePath()); assertThat(command).containsSequence("-t", "12.0"); assertThat(command).containsSequence("-c", "copy"); assertThat(command).containsSequence("-avoid_negative_ts", "make_zero"); assertThat(command).endsWith(output.toAbsolutePath().toString()); // The leading segment is copied whole, so nothing seeks and the concat // demuxer (-f concat) is deliberately not used. assertThat(command).doesNotContain("-ss"); assertThat(command).doesNotContainSequence("-f", "concat"); } @Test void saveSection_zeroTrimOffset_buildsTheSameSingleCopyPass() 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 no longer changes the command shape: with or without lead-in // the section is the same single copy pass over the joined segments. @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); Mockito.verify(commandRunner, Mockito.times(1)).run(captor.capture(), any()); List command = captor.getValue(); assertThat(command).containsSequence("-i", "concat:/tmp/a.ts|/tmp/b.ts"); assertThat(command).containsSequence("-t", "10.0"); assertThat(command).doesNotContain("-ss"); } @Test void saveSection_commandRunnerThrowsIOException_completesExceptionallyWithoutMarkingProgressComplete() throws Exception { Path output = Path.of("/tmp/out/clip.mp4"); ProgressTracker progress = new ProgressTracker(); // The copy pass is the only ffmpeg invocation, so its failure must surface. when(commandRunner.run(anyList(), any())).thenThrow(new IOException("ffmpeg boom")); var future = service.saveSection( new StreamActionsService.SegmentSelection(List.of(Path.of("/tmp/a.ts")), 2f, 10f), output, progress); assertThatThrownBy(future::get) .isInstanceOf(ExecutionException.class) .hasCauseInstanceOf(IOException.class); assertThat(progress.isComplete()).isFalse(); } @Test void saveSection_emptySegmentList_completesExceptionallyWithIllegalArgumentException() { Path output = Path.of("/tmp/out/clip.mp4"); ProgressTracker progress = new ProgressTracker(); var future = service.saveSection( new StreamActionsService.SegmentSelection(List.of(), 0f, 10f), output, progress); assertThatThrownBy(future::get) .isInstanceOf(ExecutionException.class) .hasCauseInstanceOf(IllegalArgumentException.class); assertThat(progress.isComplete()).isFalse(); } @Test void saveSection_nullOutputFile_completesExceptionally() { ProgressTracker progress = new ProgressTracker(); // Building the 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) .hasCauseInstanceOf(NullPointerException.class); assertThat(progress.isComplete()).isFalse(); } }