diff --git a/docs/input-folder-scheduler-plan.md b/docs/input-folder-scheduler-plan.md
index c2df9a5..a2c8c7c 100644
--- a/docs/input-folder-scheduler-plan.md
+++ b/docs/input-folder-scheduler-plan.md
@@ -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`.
2. [x] Add folder scheduler configuration properties.
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.
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.
diff --git a/input/source/.gitkeep b/input/source/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/input/source/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/output/clips/.gitkeep b/output/clips/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/output/clips/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/pom.xml b/pom.xml
index 15a49e8..2b8e5a5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -91,6 +91,52 @@
org.springframework.boot
spring-boot-maven-plugin
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.12
+
+
+
+ prepare-agent
+
+
+
+ folder-coverage-check
+ verify
+
+ check
+
+
+
+
+ PACKAGE
+
+ org.example.videoclips.folder
+
+
+
+ INSTRUCTION
+ COVEREDRATIO
+ 1.00
+
+
+ LINE
+ COVEREDRATIO
+ 1.00
+
+
+ BRANCH
+ COVEREDRATIO
+ 1.00
+
+
+
+
+
+
+
+
diff --git a/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java b/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java
new file mode 100644
index 0000000..0178e07
--- /dev/null
+++ b/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java
@@ -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 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 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 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);
+ }
+}
diff --git a/src/test/java/org/example/videoclips/folder/FolderSchedulerDirectoryInitializerTest.java b/src/test/java/org/example/videoclips/folder/FolderSchedulerDirectoryInitializerTest.java
new file mode 100644
index 0000000..7d70c24
--- /dev/null
+++ b/src/test/java/org/example/videoclips/folder/FolderSchedulerDirectoryInitializerTest.java
@@ -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;
+ }
+}
diff --git a/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java b/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java
new file mode 100644
index 0000000..b2e6765
--- /dev/null
+++ b/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java
@@ -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 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 candidate = scheduler(tempDir).findNextCandidate();
+
+ assertTrue(candidate.isPresent());
+ assertEquals("valid.mp4", candidate.get().getFileName().toString());
+ }
+
+ @Test
+ void returnsEmptyWhenInputDirectoryDoesNotExist() {
+ Optional candidate = scheduler(tempDir.resolve("missing")).findNextCandidate();
+
+ assertTrue(candidate.isEmpty());
+ }
+
+ @Test
+ void throwsWhenInputDirectoryCannotBeListed() throws Exception {
+ Path restricted = tempDir.resolve("restricted");
+ Files.createDirectory(restricted);
+ Set 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);
+ }
+}