first segment's duration is probed to exclude it if needed
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

This commit is contained in:
2026-07-27 22:10:07 +01:00
parent b4e450a122
commit e19bc68006
2 changed files with 40 additions and 60 deletions

View File

@@ -125,7 +125,7 @@ public class StreamActionsService {
}
}
public List<Path> getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
private List<Path> getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
long startMs = startTime.toEpochMilli();
long endMs = endTime.toEpochMilli();
@@ -189,7 +189,7 @@ public class StreamActionsService {
long firstMs = parseTimestampMs(segments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f);
// if the first segment is longer than the trim-offset, then ggs
// if the first segment is longer than the trim-offset
if (trimOffset > 0 && probeDurationSeconds(segments.getFirst()) < trimOffset) {
segments.removeFirst();
if (segments.isEmpty()) {

View File

@@ -75,137 +75,131 @@ class StreamActionsServiceTest {
}
// ---------------------------------------------------------------
// 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 {
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).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_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())
@@ -213,45 +207,31 @@ 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");
}
@Test
void getSegmentsInRange_gapInStream_returnsTheGap() throws IOException {
createSegment("20.ts");
createSegment("30.ts");
// user stops streaming
createSegment("100.ts");
createSegment("110.ts");
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(70_000), Instant.ofEpochMilli(111_000));
assertThat(result).extracting(p -> p.getFileName().toString())
.containsExactly("30.ts", "100.ts", "110.ts");
}
// ---------------------------------------------------------------
// selectSegments: head selection and trim-offset
// ---------------------------------------------------------------