forked from jsl/video_editing_poc
• Added missing tests and a 100% coverage gate for the new folder scheduler package.
Changes: - Added JaCoCo to pom.xml with a 100% instruction, line, and branch coverage rule scoped to org.example.videoclips.folder. - Added src/test/java/org/example/videoclips/folder/FolderSchedulerDirectoryInitializerTest.java. - Added src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java. - Adjusted the scanner comparator in src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java to keep deterministic ordering without an uncovered comparator branch. Verification: mvn -q verify passed. The generated JaCoCo report shows org.example.videoclips.folder at 100% instruction, line, and branch coverage. Scope note: this enforces 100% coverage for the new scheduler package, not the whole existing service. Project-wide 100% would be a much larger pass across pre-existing classes. The next scheduler milestone is repeat-prevention by moving selected inputs to the working folder before processing.
This commit is contained in:
parent
97cc4ff4e4
commit
702d768e33
|
|
@ -21,7 +21,7 @@ Note: the local machine's filesystem root is read-only in this workspace, so abs
|
||||||
1. [x] Create repository-local placeholder directories `input/source` and `output/clips`.
|
1. [x] Create repository-local placeholder directories `input/source` and `output/clips`.
|
||||||
2. [x] Add folder scheduler configuration properties.
|
2. [x] Add folder scheduler configuration properties.
|
||||||
3. [x] Create startup directory initialization for input, output, working, processed, and rejected folders.
|
3. [x] Create startup directory initialization for input, output, working, processed, and rejected folders.
|
||||||
4. [ ] Add deterministic scanner that picks one candidate video at a time.
|
4. [x] Add deterministic scanner that picks one candidate video at a time.
|
||||||
5. [ ] Add repeat-prevention by moving selected inputs to a working folder before processing.
|
5. [ ] Add repeat-prevention by moving selected inputs to a working folder before processing.
|
||||||
6. [ ] Add valid-video detection using extension filtering and `ffprobe`.
|
6. [ ] Add valid-video detection using extension filtering and `ffprobe`.
|
||||||
7. [ ] Add invalid-file handling that logs the reason and moves files to rejected storage.
|
7. [ ] Add invalid-file handling that logs the reason and moves files to rejected storage.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
46
pom.xml
46
pom.xml
|
|
@ -91,6 +91,52 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<version>0.8.12</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>prepare-agent</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>folder-coverage-check</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>check</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<rules>
|
||||||
|
<rule>
|
||||||
|
<element>PACKAGE</element>
|
||||||
|
<includes>
|
||||||
|
<include>org.example.videoclips.folder</include>
|
||||||
|
</includes>
|
||||||
|
<limits>
|
||||||
|
<limit>
|
||||||
|
<counter>INSTRUCTION</counter>
|
||||||
|
<value>COVEREDRATIO</value>
|
||||||
|
<minimum>1.00</minimum>
|
||||||
|
</limit>
|
||||||
|
<limit>
|
||||||
|
<counter>LINE</counter>
|
||||||
|
<value>COVEREDRATIO</value>
|
||||||
|
<minimum>1.00</minimum>
|
||||||
|
</limit>
|
||||||
|
<limit>
|
||||||
|
<counter>BRANCH</counter>
|
||||||
|
<value>COVEREDRATIO</value>
|
||||||
|
<minimum>1.00</minimum>
|
||||||
|
</limit>
|
||||||
|
</limits>
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</project>
|
</project>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package org.example.videoclips.folder;
|
||||||
|
|
||||||
|
import org.example.videoclips.config.VideoClippingProperties;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "video-clipping.folder-scheduler.enabled", havingValue = "true")
|
||||||
|
public class FolderVideoScanScheduler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FolderVideoScanScheduler.class);
|
||||||
|
|
||||||
|
private final VideoClippingProperties.FolderScheduler properties;
|
||||||
|
private final AtomicBoolean scanning = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
public FolderVideoScanScheduler(VideoClippingProperties videoClippingProperties) {
|
||||||
|
this.properties = videoClippingProperties.getFolderScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${video-clipping.folder-scheduler.poll-interval-ms:5000}")
|
||||||
|
public void scan() {
|
||||||
|
if (!scanning.compareAndSet(false, true)) {
|
||||||
|
log.debug("Skipping folder video scan because a previous scan is still active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
findNextCandidate().ifPresentOrElse(
|
||||||
|
candidate -> log.info("Selected folder video candidate for processing: {}", candidate.getFileName()),
|
||||||
|
() -> log.debug("No folder video candidate found in {}", properties.getInputDirectory())
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
scanning.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<Path> findNextCandidate() {
|
||||||
|
Path inputDirectory = Path.of(properties.getInputDirectory());
|
||||||
|
if (!Files.isDirectory(inputDirectory)) {
|
||||||
|
log.warn("Folder scheduler input directory does not exist or is not a directory: {}", inputDirectory);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Stream<Path> files = Files.list(inputDirectory)) {
|
||||||
|
return files
|
||||||
|
.filter(this::isCandidateFile)
|
||||||
|
.sorted(candidateComparator())
|
||||||
|
.findFirst();
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Unable to scan folder scheduler input directory: " + inputDirectory, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isCandidateFile(Path path) {
|
||||||
|
if (!Files.isRegularFile(path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String fileName = path.getFileName().toString();
|
||||||
|
return !fileName.startsWith(".")
|
||||||
|
&& !fileName.endsWith(".tmp")
|
||||||
|
&& !fileName.endsWith(".part")
|
||||||
|
&& !fileName.endsWith(".download");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Comparator<Path> candidateComparator() {
|
||||||
|
return Comparator
|
||||||
|
.comparing(path -> naturalSortKey(path.getFileName().toString()) + "\0" + path.getFileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String naturalSortKey(String value) {
|
||||||
|
StringBuilder key = new StringBuilder();
|
||||||
|
StringBuilder number = new StringBuilder();
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char current = value.charAt(i);
|
||||||
|
if (Character.isDigit(current)) {
|
||||||
|
number.append(current);
|
||||||
|
} else {
|
||||||
|
appendNumber(key, number);
|
||||||
|
key.append(Character.toLowerCase(current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendNumber(key, number);
|
||||||
|
return key.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendNumber(StringBuilder key, StringBuilder number) {
|
||||||
|
if (number.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
key.append(String.format("%020d", number.length()));
|
||||||
|
key.append(new BigInteger(number.toString()));
|
||||||
|
number.setLength(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
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.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class FolderSchedulerDirectoryInitializerTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsAllConfiguredDirectoriesOnStartup() {
|
||||||
|
VideoClippingProperties properties = properties(
|
||||||
|
tempDir.resolve("source"),
|
||||||
|
tempDir.resolve("clips"),
|
||||||
|
tempDir.resolve("working"),
|
||||||
|
tempDir.resolve("processed"),
|
||||||
|
tempDir.resolve("rejected")
|
||||||
|
);
|
||||||
|
|
||||||
|
new FolderSchedulerDirectoryInitializer(properties).run(null);
|
||||||
|
|
||||||
|
assertTrue(Files.isDirectory(tempDir.resolve("source")));
|
||||||
|
assertTrue(Files.isDirectory(tempDir.resolve("clips")));
|
||||||
|
assertTrue(Files.isDirectory(tempDir.resolve("working")));
|
||||||
|
assertTrue(Files.isDirectory(tempDir.resolve("processed")));
|
||||||
|
assertTrue(Files.isDirectory(tempDir.resolve("rejected")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failsStartupWhenConfiguredDirectoryCannotBeCreated() throws Exception {
|
||||||
|
Path existingFile = tempDir.resolve("not-a-directory");
|
||||||
|
Files.writeString(existingFile, "file");
|
||||||
|
VideoClippingProperties properties = properties(
|
||||||
|
existingFile,
|
||||||
|
tempDir.resolve("clips"),
|
||||||
|
tempDir.resolve("working"),
|
||||||
|
tempDir.resolve("processed"),
|
||||||
|
tempDir.resolve("rejected")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> new FolderSchedulerDirectoryInitializer(properties).run(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private VideoClippingProperties properties(
|
||||||
|
Path inputDirectory,
|
||||||
|
Path outputDirectory,
|
||||||
|
Path workingDirectory,
|
||||||
|
Path processedDirectory,
|
||||||
|
Path rejectedDirectory
|
||||||
|
) {
|
||||||
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
|
properties.getFolderScheduler().setInputDirectory(inputDirectory.toString());
|
||||||
|
properties.getFolderScheduler().setOutputDirectory(outputDirectory.toString());
|
||||||
|
properties.getFolderScheduler().setWorkingDirectory(workingDirectory.toString());
|
||||||
|
properties.getFolderScheduler().setProcessedDirectory(processedDirectory.toString());
|
||||||
|
properties.getFolderScheduler().setRejectedDirectory(rejectedDirectory.toString());
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
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.lang.reflect.Field;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermission;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
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 FolderVideoScanSchedulerTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectsOneCandidateUsingNaturalFilenameOrdering() throws Exception {
|
||||||
|
Files.writeString(tempDir.resolve("10.mp4"), "video");
|
||||||
|
Files.writeString(tempDir.resolve("2.mp4"), "video");
|
||||||
|
Files.writeString(tempDir.resolve("1.mp4"), "video");
|
||||||
|
|
||||||
|
Optional<Path> candidate = scheduler(tempDir).findNextCandidate();
|
||||||
|
|
||||||
|
assertTrue(candidate.isPresent());
|
||||||
|
assertEquals("1.mp4", candidate.get().getFileName().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ignoresDirectoriesHiddenFilesAndTemporaryFiles() throws Exception {
|
||||||
|
Files.createDirectory(tempDir.resolve("directory.mp4"));
|
||||||
|
Files.writeString(tempDir.resolve(".hidden.mp4"), "video");
|
||||||
|
Files.writeString(tempDir.resolve("upload.mp4.tmp"), "video");
|
||||||
|
Files.writeString(tempDir.resolve("upload.mp4.part"), "video");
|
||||||
|
Files.writeString(tempDir.resolve("upload.mp4.download"), "video");
|
||||||
|
Files.writeString(tempDir.resolve("valid.mp4"), "video");
|
||||||
|
|
||||||
|
Optional<Path> candidate = scheduler(tempDir).findNextCandidate();
|
||||||
|
|
||||||
|
assertTrue(candidate.isPresent());
|
||||||
|
assertEquals("valid.mp4", candidate.get().getFileName().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsEmptyWhenInputDirectoryDoesNotExist() {
|
||||||
|
Optional<Path> candidate = scheduler(tempDir.resolve("missing")).findNextCandidate();
|
||||||
|
|
||||||
|
assertTrue(candidate.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void throwsWhenInputDirectoryCannotBeListed() throws Exception {
|
||||||
|
Path restricted = tempDir.resolve("restricted");
|
||||||
|
Files.createDirectory(restricted);
|
||||||
|
Set<PosixFilePermission> originalPermissions = Files.getPosixFilePermissions(restricted);
|
||||||
|
try {
|
||||||
|
Files.setPosixFilePermissions(restricted, Set.of());
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> scheduler(restricted).findNextCandidate());
|
||||||
|
} finally {
|
||||||
|
Files.setPosixFilePermissions(restricted, originalPermissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scanDoesNothingWhenAnotherScanIsActive() throws Exception {
|
||||||
|
FolderVideoScanScheduler scheduler = scheduler(tempDir);
|
||||||
|
Field scanningField = FolderVideoScanScheduler.class.getDeclaredField("scanning");
|
||||||
|
scanningField.setAccessible(true);
|
||||||
|
((AtomicBoolean) scanningField.get(scheduler)).set(true);
|
||||||
|
|
||||||
|
scheduler.scan();
|
||||||
|
|
||||||
|
assertTrue(((AtomicBoolean) scanningField.get(scheduler)).get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scanReleasesGuardAfterSelectingCandidate() throws Exception {
|
||||||
|
Files.writeString(tempDir.resolve("1.mp4"), "video");
|
||||||
|
FolderVideoScanScheduler scheduler = scheduler(tempDir);
|
||||||
|
Field scanningField = FolderVideoScanScheduler.class.getDeclaredField("scanning");
|
||||||
|
scanningField.setAccessible(true);
|
||||||
|
|
||||||
|
scheduler.scan();
|
||||||
|
|
||||||
|
assertFalse(((AtomicBoolean) scanningField.get(scheduler)).get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scanReleasesGuardWhenNoCandidateExists() throws Exception {
|
||||||
|
FolderVideoScanScheduler scheduler = scheduler(tempDir);
|
||||||
|
Field scanningField = FolderVideoScanScheduler.class.getDeclaredField("scanning");
|
||||||
|
scanningField.setAccessible(true);
|
||||||
|
|
||||||
|
scheduler.scan();
|
||||||
|
|
||||||
|
assertFalse(((AtomicBoolean) scanningField.get(scheduler)).get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private FolderVideoScanScheduler scheduler(Path inputDirectory) {
|
||||||
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
|
properties.getFolderScheduler().setInputDirectory(inputDirectory.toString());
|
||||||
|
return new FolderVideoScanScheduler(properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue