Add ffprobe video validation

This commit is contained in:
JSLMPR 2026-07-10 02:06:24 +02:00
parent 45092e23b0
commit 146c37b091
3 changed files with 222 additions and 1 deletions

View File

@ -23,7 +23,7 @@ Note: the local machine's filesystem root is read-only in this workspace, so abs
3. [x] Create startup directory initialization for input, output, working, processed, and rejected folders.
4. [x] Add deterministic scanner that picks one candidate video at a time.
5. [x] Add repeat-prevention by moving selected inputs to a working folder before processing.
6. [ ] Add valid-video detection using extension filtering and `ffprobe`.
6. [x] Add valid-video detection using extension filtering and `ffprobe`.
7. [ ] Add invalid-file handling that logs the reason and moves files to rejected storage.
8. [ ] Add FFmpeg clipping into `/output/clips/<video-name>/` using 8-second MP4/H.264/AAC segments.
9. [ ] Add success handling that moves processed source files to processed storage.

View File

@ -0,0 +1,99 @@
package org.example.videoclips.folder;
import org.example.videoclips.config.VideoClippingProperties;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class FolderVideoValidator {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of("mp4", "mov", "m4v", "webm", "mkv");
private final VideoClippingProperties.FolderScheduler properties;
private final ProcessExecutor processExecutor;
public FolderVideoValidator(VideoClippingProperties videoClippingProperties) {
this(videoClippingProperties, FolderVideoValidator::execute);
}
FolderVideoValidator(VideoClippingProperties videoClippingProperties, ProcessExecutor processExecutor) {
this.properties = videoClippingProperties.getFolderScheduler();
this.processExecutor = processExecutor;
}
ValidationResult validate(Path videoFile) {
String fileName = videoFile.getFileName().toString();
int extensionSeparator = fileName.lastIndexOf('.');
String extension = extensionSeparator >= 0
? fileName.substring(extensionSeparator + 1).toLowerCase(Locale.ROOT)
: "";
if (!ALLOWED_EXTENSIONS.contains(extension)) {
return ValidationResult.invalid("unsupported file extension");
}
List<String> command = List.of(
properties.getFfprobeBinary(),
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height:format=duration",
"-of", "default=noprint_wrappers=1",
videoFile.toString()
);
ProcessResult result;
try {
result = processExecutor.execute(command);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while validating video file", ex);
} catch (IOException ex) {
throw new IllegalStateException("Unable to run ffprobe", ex);
}
if (result.exitCode() != 0) {
return ValidationResult.invalid("ffprobe exited with code " + result.exitCode());
}
if (!hasPositiveValue(result.output(), "width")
|| !hasPositiveValue(result.output(), "height")
|| !hasPositiveValue(result.output(), "duration")) {
return ValidationResult.invalid("ffprobe did not report valid video dimensions and duration");
}
return ValidationResult.accepted();
}
private boolean hasPositiveValue(String output, String key) {
Matcher matcher = Pattern.compile("(?m)^" + key + "=([0-9]+(?:\\.[0-9]+)?)$").matcher(output);
return matcher.find() && Double.parseDouble(matcher.group(1)) > 0;
}
private static ProcessResult execute(List<String> command) throws IOException, InterruptedException {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
return new ProcessResult(process.waitFor(), output);
}
record ValidationResult(boolean valid, String reason) {
static ValidationResult accepted() {
return new ValidationResult(true, "valid video");
}
static ValidationResult invalid(String reason) {
return new ValidationResult(false, reason);
}
}
record ProcessResult(int exitCode, String output) {
}
@FunctionalInterface
interface ProcessExecutor {
ProcessResult execute(List<String> command) throws IOException, InterruptedException;
}
}

View File

@ -0,0 +1,122 @@
package org.example.videoclips.folder;
import org.example.videoclips.config.VideoClippingProperties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FolderVideoValidatorTest {
@TempDir
Path tempDir;
@Test
void acceptsAllowedExtensionAndValidProbeMetadata() {
AtomicReference<List<String>> command = new AtomicReference<>();
FolderVideoValidator validator = validator(arguments -> {
command.set(arguments);
return new FolderVideoValidator.ProcessResult(0, "width=1920\nheight=1080\nduration=8.500000\n");
});
FolderVideoValidator.ValidationResult result = validator.validate(Path.of("movie.MP4"));
assertTrue(result.valid());
assertEquals("valid video", result.reason());
assertEquals("ffprobe", command.get().getFirst());
assertEquals("movie.MP4", command.get().getLast());
}
@Test
void rejectsUnsupportedAndMissingExtensionsWithoutProbing() {
FolderVideoValidator validator = validator(arguments -> {
throw new AssertionError("ffprobe must not be called");
});
assertFalse(validator.validate(Path.of("notes.txt")).valid());
assertFalse(validator.validate(Path.of("video")).valid());
}
@Test
void acceptsEveryConfiguredVideoExtension() {
FolderVideoValidator validator = validator(arguments ->
new FolderVideoValidator.ProcessResult(0, "width=1\nheight=1\nduration=1\n"));
for (String extension : List.of("mp4", "mov", "m4v", "webm", "mkv")) {
assertTrue(validator.validate(Path.of("video." + extension)).valid());
}
}
@Test
void rejectsProbeFailure() {
FolderVideoValidator.ValidationResult result = validator(arguments ->
new FolderVideoValidator.ProcessResult(12, "invalid data")).validate(Path.of("bad.mp4"));
assertFalse(result.valid());
assertEquals("ffprobe exited with code 12", result.reason());
}
@Test
void rejectsMissingOrNonPositiveMetadata() {
assertInvalidMetadata("height=1\nduration=1\n");
assertInvalidMetadata("width=1\nduration=1\n");
assertInvalidMetadata("width=1\nheight=1\n");
assertInvalidMetadata("width=0\nheight=1\nduration=1\n");
assertInvalidMetadata("width=1\nheight=0\nduration=1\n");
assertInvalidMetadata("width=1\nheight=1\nduration=0\n");
}
@Test
void reportsProcessStartupFailure() {
FolderVideoValidator validator = validator(arguments -> {
throw new IOException("missing executable");
});
assertThrows(IllegalStateException.class, () -> validator.validate(Path.of("video.mp4")));
}
@Test
void restoresInterruptFlagWhenProbeIsInterrupted() {
FolderVideoValidator validator = validator(arguments -> {
throw new InterruptedException("stopped");
});
try {
assertThrows(IllegalStateException.class, () -> validator.validate(Path.of("video.mp4")));
assertTrue(Thread.currentThread().isInterrupted());
} finally {
Thread.interrupted();
}
}
@Test
void defaultExecutorRunsConfiguredProbeBinary() throws Exception {
Path probe = tempDir.resolve("fake-ffprobe");
Files.writeString(probe, "#!/bin/sh\nprintf 'width=2\\nheight=2\\nduration=2\\n'\n");
assertTrue(probe.toFile().setExecutable(true));
VideoClippingProperties properties = new VideoClippingProperties();
properties.getFolderScheduler().setFfprobeBinary(probe.toString());
assertTrue(new FolderVideoValidator(properties).validate(Path.of("video.mp4")).valid());
}
private void assertInvalidMetadata(String output) {
FolderVideoValidator.ValidationResult result = validator(arguments ->
new FolderVideoValidator.ProcessResult(0, output)).validate(Path.of("bad.mp4"));
assertFalse(result.valid());
assertEquals("ffprobe did not report valid video dimensions and duration", result.reason());
}
private FolderVideoValidator validator(FolderVideoValidator.ProcessExecutor executor) {
return new FolderVideoValidator(new VideoClippingProperties(), executor);
}
}