Add ffprobe video validation
This commit is contained in:
parent
45092e23b0
commit
146c37b091
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue