diff --git a/docs/input-folder-scheduler-plan.md b/docs/input-folder-scheduler-plan.md index 32b34c8..9adef70 100644 --- a/docs/input-folder-scheduler-plan.md +++ b/docs/input-folder-scheduler-plan.md @@ -27,7 +27,7 @@ Note: the local machine's filesystem root is read-only in this workspace, so abs 7. [x] Add invalid-file handling that logs the reason and moves files to rejected storage. 8. [x] Add FFmpeg clipping into `/output/clips//` using 8-second MP4/H.264/AAC segments. 9. [x] Add success handling that moves processed source files to processed storage. -10. [ ] Add failure handling for FFmpeg or filesystem errors. +10. [x] Add failure handling for FFmpeg or filesystem errors. 11. [ ] Add unit tests for ordering, invalid-file handling, repeat prevention, and state transitions. 12. [ ] Add optional FFmpeg integration test for a short fixture. 13. [ ] Add local profile or documented runtime config for enabling the scheduler. diff --git a/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java b/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java index de6e539..f5637aa 100644 --- a/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java +++ b/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java @@ -48,9 +48,7 @@ public class FolderVideoScanScheduler { try { findNextCandidate().ifPresentOrElse( candidate -> { - Path workingFile = moveToWorking(candidate); - log.info("Claimed folder video candidate for processing: {}", workingFile.getFileName()); - validateCandidate(workingFile); + processCandidate(candidate); }, () -> log.debug("No folder video candidate found in {}", properties.getInputDirectory()) ); @@ -59,6 +57,42 @@ public class FolderVideoScanScheduler { } } + private void processCandidate(Path candidate) { + Path activeFile = candidate; + try { + activeFile = moveToWorking(candidate); + log.info("Claimed folder video candidate for processing: {}", activeFile.getFileName()); + validateCandidate(activeFile); + } catch (RuntimeException ex) { + handleFailure(activeFile, ex); + } + } + + private void handleFailure(Path activeFile, RuntimeException failure) { + log.error("Folder video processing failed for {} ({})", activeFile.getFileName(), + failure.getClass().getSimpleName()); + try { + Path rejectedFile = moveToDirectory(activeFile, Path.of(properties.getRejectedDirectory())); + log.warn("Moved failed folder video source to rejected storage: {}", rejectedFile.getFileName()); + } catch (RuntimeException rejectionFailure) { + markFailed(activeFile); + } + } + + void markFailed(Path source) { + Path marker = source.resolveSibling(source.getFileName() + ".failed"); + if (Files.exists(marker)) { + log.error("Unable to mark failed folder video source because marker already exists: {}", marker.getFileName()); + return; + } + try { + Files.move(source, marker, StandardCopyOption.ATOMIC_MOVE); + log.warn("Marked folder video source as failed: {}", marker.getFileName()); + } catch (IOException ex) { + log.error("Unable to quarantine or mark failed folder video source: {}", source.getFileName()); + } + } + private void validateCandidate(Path workingFile) { FolderVideoValidator.ValidationResult result = validator.validate(workingFile); if (result.valid()) { @@ -116,7 +150,8 @@ public class FolderVideoScanScheduler { return !fileName.startsWith(".") && !fileName.endsWith(".tmp") && !fileName.endsWith(".part") - && !fileName.endsWith(".download"); + && !fileName.endsWith(".download") + && !fileName.endsWith(".failed"); } private Comparator candidateComparator() { diff --git a/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java b/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java index ce7e9c6..aaaa25a 100644 --- a/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java +++ b/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java @@ -44,6 +44,7 @@ class FolderVideoScanSchedulerTest { 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("upload.mp4.failed"), "video"); Files.writeString(tempDir.resolve("valid.mp4"), "video"); Optional candidate = scheduler(tempDir).findNextCandidate(); @@ -143,8 +144,9 @@ class FolderVideoScanSchedulerTest { FolderVideoValidator validator = mock(FolderVideoValidator.class); when(validator.validate(any())).thenReturn(FolderVideoValidator.ValidationResult.invalid("invalid")); - assertThrows(IllegalStateException.class, () -> scheduler(tempDir, validator).scan()); - assertTrue(Files.exists(tempDir.resolve("working/invalid.mp4"))); + scheduler(tempDir, validator).scan(); + + assertTrue(Files.exists(tempDir.resolve("working/invalid.mp4.failed"))); } @Test @@ -153,10 +155,58 @@ class FolderVideoScanSchedulerTest { Files.createDirectory(tempDir.resolve("processed")); Files.writeString(tempDir.resolve("processed/1.mp4"), "existing video"); - assertThrows(IllegalStateException.class, () -> scheduler(tempDir).scan()); + scheduler(tempDir).scan(); assertEquals("existing video", Files.readString(tempDir.resolve("processed/1.mp4"))); - assertEquals("new video", Files.readString(tempDir.resolve("working/1.mp4"))); + assertEquals("new video", Files.readString(tempDir.resolve("rejected/1.mp4"))); + } + + @Test + void quarantinesSourceWhenClipGenerationFails() throws Exception { + Files.writeString(tempDir.resolve("broken.mp4"), "video"); + FolderFfmpegClipper clipper = mock(FolderFfmpegClipper.class); + when(clipper.clip(any())).thenThrow(new IllegalStateException("ffmpeg failed")); + + scheduler(tempDir, acceptedValidator(), clipper).scan(); + + assertEquals("video", Files.readString(tempDir.resolve("rejected/broken.mp4"))); + assertFalse(Files.exists(tempDir.resolve("working/broken.mp4"))); + } + + @Test + void quarantinesInputWhenClaimingFails() throws Exception { + Files.writeString(tempDir.resolve("broken.mp4"), "video"); + Files.createDirectory(tempDir.resolve("working")); + Files.writeString(tempDir.resolve("working/broken.mp4"), "collision"); + + scheduler(tempDir).scan(); + + assertEquals("video", Files.readString(tempDir.resolve("rejected/broken.mp4"))); + } + + @Test + void ignoresFailureWhenActiveSourceHasAlreadyDisappeared() throws Exception { + Files.writeString(tempDir.resolve("vanished.mp4"), "video"); + FolderVideoValidator validator = mock(FolderVideoValidator.class); + when(validator.validate(any())).thenAnswer(invocation -> { + Files.delete(invocation.getArgument(0)); + throw new IllegalStateException("vanished"); + }); + + scheduler(tempDir, validator).scan(); + + assertFalse(Files.exists(tempDir.resolve("working/vanished.mp4.failed"))); + } + + @Test + void doesNotOverwriteExistingFailureMarker() throws Exception { + Path source = Files.writeString(tempDir.resolve("video.mp4"), "source"); + Files.writeString(tempDir.resolve("video.mp4.failed"), "marker"); + + scheduler(tempDir).markFailed(source); + + assertEquals("source", Files.readString(source)); + assertEquals("marker", Files.readString(tempDir.resolve("video.mp4.failed"))); } @Test @@ -169,9 +219,7 @@ class FolderVideoScanSchedulerTest { } private FolderVideoScanScheduler scheduler(Path inputDirectory) { - FolderVideoValidator validator = mock(FolderVideoValidator.class); - when(validator.validate(any())).thenReturn(FolderVideoValidator.ValidationResult.accepted()); - return scheduler(inputDirectory, validator, successfulClipper()); + return scheduler(inputDirectory, acceptedValidator(), successfulClipper()); } private FolderVideoScanScheduler scheduler(Path inputDirectory, FolderVideoValidator validator) { @@ -216,4 +264,10 @@ class FolderVideoScanSchedulerTest { when(clipper.clip(any())).thenReturn(new FolderFfmpegClipper.ClipResult(tempDir.resolve("output/1"), 1)); return clipper; } + + private FolderVideoValidator acceptedValidator() { + FolderVideoValidator validator = mock(FolderVideoValidator.class); + when(validator.validate(any())).thenReturn(FolderVideoValidator.ValidationResult.accepted()); + return validator; + } }