Compare commits
1 Commits
02ceae73d5
...
dfff51e9d8
| Author | SHA1 | Date | |
|---|---|---|---|
| dfff51e9d8 |
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
@@ -1888,6 +1889,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
@@ -5304,6 +5315,13 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ public class MediaService {
|
|||||||
return new SegmentContext(segments, trimOffset);
|
return new SegmentContext(segments, trimOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Job dispatchSaveJob(Job job, SegmentContext ctx, float duration, Path outputFile, Runnable onSuccess) {
|
private Job dispatchSaveJob(Job job, SegmentContext ctx, float duration, Path outputFile, Runnable onSuccess) throws IOException {
|
||||||
job.setState(JobState.PROCESSING);
|
job.setState(JobState.PROCESSING);
|
||||||
streamActionsService.saveSection(ctx.segments(), ctx.trimOffset(), duration, outputFile, job.getProgressTracker())
|
streamActionsService.saveSection(ctx.segments(), ctx.trimOffset(), duration, outputFile, job.getProgressTracker())
|
||||||
.thenRun(() -> {
|
.thenRun(() -> {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
@@ -31,8 +32,6 @@ public class StreamActionsService {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class);
|
private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class);
|
||||||
private final CommandRunner commandRunner;
|
private final CommandRunner commandRunner;
|
||||||
|
|
||||||
private static final int HLS_SEGMENT_LENGTH = 3;
|
|
||||||
|
|
||||||
public StreamActionsService(CommandRunner commandRunner) {
|
public StreamActionsService(CommandRunner commandRunner) {
|
||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
}
|
}
|
||||||
@@ -65,21 +64,33 @@ public class StreamActionsService {
|
|||||||
float duration,
|
float duration,
|
||||||
Path outputFile,
|
Path outputFile,
|
||||||
ProgressTracker progressTracker
|
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 {
|
try {
|
||||||
String concatInput = segments.stream()
|
// 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())
|
.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",
|
||||||
"-i", "concat:" + concatInput,
|
|
||||||
"-ss", String.valueOf(trimOffset),
|
"-ss", String.valueOf(trimOffset),
|
||||||
|
"-i", "concat:" + concatInput,
|
||||||
"-t", String.valueOf(duration),
|
"-t", String.valueOf(duration),
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
outputFile.toAbsolutePath().toString()
|
outputFile.toAbsolutePath().toString()
|
||||||
@@ -101,6 +112,8 @@ public class StreamActionsService {
|
|||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
||||||
return CompletableFuture.failedFuture(e);
|
return CompletableFuture.failedFuture(e);
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(trimmedFirst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,17 +125,29 @@ public class StreamActionsService {
|
|||||||
throw new IllegalArgumentException("Start time must be before end time");
|
throw new IllegalArgumentException("Start time must be before end time");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Path> results = new ArrayList<>();
|
||||||
|
|
||||||
try (Stream<Path> files = Files.list(streamDirectory)) {
|
try (Stream<Path> files = Files.list(streamDirectory)) {
|
||||||
return files
|
List<Path> streamFiles = files
|
||||||
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
||||||
.filter(Files::isRegularFile)
|
.filter(Files::isRegularFile)
|
||||||
.filter(p -> {
|
|
||||||
long segMs = parseTimestampMs(p);
|
|
||||||
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
|
||||||
})
|
|
||||||
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
||||||
.collect(Collectors.toList());
|
.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) {
|
public long parseTimestampMs(Path path) {
|
||||||
|
|||||||
@@ -124,13 +124,13 @@ class StreamActionsServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getSegmentsInRange_segmentEndsExactlyAtRangeStart_isExcluded() throws IOException {
|
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");
|
createSegment("0.ts");
|
||||||
|
createSegment("3.ts");
|
||||||
|
|
||||||
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(3000), Instant.ofEpochMilli(10_000));
|
List<Path> result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(3000), Instant.ofEpochMilli(10_000));
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
assertThat(result).extracting(p -> p.getFileName().toString())
|
||||||
|
.containsExactly("3.ts");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -170,16 +170,6 @@ class StreamActionsServiceTest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
@Test
|
||||||
void getSegmentsInRange_segmentsOutOfOrder_returnedInOrder() throws IOException {
|
void getSegmentsInRange_segmentsOutOfOrder_returnedInOrder() throws IOException {
|
||||||
createSegment("20.ts");
|
createSegment("20.ts");
|
||||||
@@ -219,6 +209,20 @@ class StreamActionsServiceTest {
|
|||||||
.containsExactly("10.ts", "20.ts", "30.ts");
|
.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");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// parseTimestampMs: unit-based conversion
|
// parseTimestampMs: unit-based conversion
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -275,6 +279,7 @@ class StreamActionsServiceTest {
|
|||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
CommandOutput commandOutput = new CommandOutput();
|
CommandOutput commandOutput = new CommandOutput();
|
||||||
|
|
||||||
|
when(commandRunner.run(anyList())).thenReturn(commandOutput);
|
||||||
when(commandRunner.run(anyList(), any())).thenReturn(commandOutput);
|
when(commandRunner.run(anyList(), any())).thenReturn(commandOutput);
|
||||||
|
|
||||||
var future = service.saveSection(List.of(segmentA, segmentB), 5.0f, 12.0f, output, progress);
|
var future = service.saveSection(List.of(segmentA, segmentB), 5.0f, 12.0f, output, progress);
|
||||||
@@ -287,8 +292,6 @@ class StreamActionsServiceTest {
|
|||||||
Mockito.verify(commandRunner).run(captor.capture(), any());
|
Mockito.verify(commandRunner).run(captor.capture(), any());
|
||||||
List<String> command = captor.getValue();
|
List<String> command = captor.getValue();
|
||||||
|
|
||||||
assertThat(command).containsSequence("-i",
|
|
||||||
"concat:" + segmentA.toAbsolutePath() + "|" + segmentB.toAbsolutePath());
|
|
||||||
assertThat(command).containsSequence("-ss", "5.0");
|
assertThat(command).containsSequence("-ss", "5.0");
|
||||||
assertThat(command).containsSequence("-t", "12.0");
|
assertThat(command).containsSequence("-t", "12.0");
|
||||||
assertThat(command).containsSequence("-c", "copy");
|
assertThat(command).containsSequence("-c", "copy");
|
||||||
@@ -300,6 +303,7 @@ class StreamActionsServiceTest {
|
|||||||
Path output = Path.of("/tmp/out/clip.mp4");
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
|
when(commandRunner.run(anyList())).thenReturn(new CommandOutput());
|
||||||
when(commandRunner.run(anyList(), any())).thenThrow(new IOException("ffmpeg boom"));
|
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(List.of(Path.of("/tmp/a.ts")), 0f, 10f, output, progress);
|
||||||
@@ -311,7 +315,7 @@ class StreamActionsServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void saveSection_emptySegmentList_completesExceptionallyWithIllegalStateException() {
|
void saveSection_emptySegmentList_completesExceptionallyWithIllegalArgumentException() throws IOException {
|
||||||
Path output = Path.of("/tmp/out/clip.mp4");
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
@@ -319,12 +323,12 @@ class StreamActionsServiceTest {
|
|||||||
|
|
||||||
assertThatThrownBy(future::get)
|
assertThatThrownBy(future::get)
|
||||||
.isInstanceOf(ExecutionException.class)
|
.isInstanceOf(ExecutionException.class)
|
||||||
.hasCauseInstanceOf(IllegalStateException.class);
|
.hasCauseInstanceOf(IllegalArgumentException.class);
|
||||||
assertThat(progress.isComplete()).isFalse();
|
assertThat(progress.isComplete()).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void saveSection_nullOutputFile_completesExceptionally() {
|
void saveSection_nullOutputFile_completesExceptionally() throws IOException {
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
var future = service.saveSection(List.of(Path.of("/tmp/a.ts")), 0f, 10f, null, progress);
|
var future = service.saveSection(List.of(Path.of("/tmp/a.ts")), 0f, 10f, null, progress);
|
||||||
|
|||||||
Reference in New Issue
Block a user