From d66fa71ae2d7d71f7fa95de6fd19dd94a251ee19 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Tue, 4 Apr 2023 17:06:38 +0200 Subject: [PATCH] More uploads refactoring. --- .../nl/andrewlalis/gymboardcdn/Config.java | 4 +- .../{api => }/StatusController.java | 2 +- .../gymboardcdn/api/FileUploadResponse.java | 5 -- .../{api => files}/FileController.java | 4 +- .../gymboardcdn/files/FileMetadata.java | 7 ++ .../{api => files}/FileMetadataResponse.java | 2 +- .../FileStorageService.java | 16 ++-- .../{model => files}/FullFileMetadata.java | 2 +- .../gymboardcdn/{ => files}/util/ULID.java | 2 +- .../gymboardcdn/model/FileMetadata.java | 6 -- .../model/VideoProcessingTask.java | 62 ------------- .../{ => uploads}/api/UploadController.java | 17 ++-- .../VideoProcessingTaskStatusResponse.java | 2 +- .../uploads/api/VideoUploadResponse.java | 5 ++ .../uploads/model/VideoProcessingTask.java | 87 +++++++++++++++++++ .../model/VideoProcessingTaskRepository.java | 5 +- .../service/CommandFailedException.java | 2 +- .../{ => uploads}/service/UploadService.java | 65 +++++++++----- .../service/VideoProcessingService.java | 67 +++++++++----- .../service/process/FfmpegVideoProcessor.java | 47 ++++++++++ .../service/process/ThumbnailGenerator.java | 7 ++ .../service/process/VideoProcessor.java | 8 ++ .../service/UploadServiceTest.java | 10 --- 23 files changed, 277 insertions(+), 157 deletions(-) rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{api => }/StatusController.java (91%) delete mode 100644 gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileUploadResponse.java rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{api => files}/FileController.java (88%) create mode 100644 gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileMetadata.java rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{api => files}/FileMetadataResponse.java (77%) rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{service => files}/FileStorageService.java (95%) rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{model => files}/FullFileMetadata.java (72%) rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{ => files}/util/ULID.java (99%) delete mode 100644 gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/FileMetadata.java delete mode 100644 gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTask.java rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{ => uploads}/api/UploadController.java (57%) rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{ => uploads}/api/VideoProcessingTaskStatusResponse.java (59%) create mode 100644 gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoUploadResponse.java create mode 100644 gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/model/VideoProcessingTask.java rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{ => uploads}/model/VideoProcessingTaskRepository.java (76%) rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{ => uploads}/service/CommandFailedException.java (94%) rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{ => uploads}/service/UploadService.java (51%) rename gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/{ => uploads}/service/VideoProcessingService.java (73%) create mode 100644 gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegVideoProcessor.java create mode 100644 gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/ThumbnailGenerator.java create mode 100644 gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/VideoProcessor.java diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java index 109afc1..05c3ced 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java @@ -1,7 +1,7 @@ package nl.andrewlalis.gymboardcdn; -import nl.andrewlalis.gymboardcdn.service.FileStorageService; -import nl.andrewlalis.gymboardcdn.util.ULID; +import nl.andrewlalis.gymboardcdn.files.FileStorageService; +import nl.andrewlalis.gymboardcdn.files.util.ULID; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/StatusController.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/StatusController.java similarity index 91% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/StatusController.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/StatusController.java index 1925b6c..422ff91 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/StatusController.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/StatusController.java @@ -1,4 +1,4 @@ -package nl.andrewlalis.gymboardcdn.api; +package nl.andrewlalis.gymboardcdn; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileUploadResponse.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileUploadResponse.java deleted file mode 100644 index 29ffb6b..0000000 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileUploadResponse.java +++ /dev/null @@ -1,5 +0,0 @@ -package nl.andrewlalis.gymboardcdn.api; - -public record FileUploadResponse( - String id -) {} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileController.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileController.java similarity index 88% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileController.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileController.java index 2fc2d8d..4eb9341 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileController.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileController.java @@ -1,8 +1,6 @@ -package nl.andrewlalis.gymboardcdn.api; +package nl.andrewlalis.gymboardcdn.files; import jakarta.servlet.http.HttpServletResponse; -import nl.andrewlalis.gymboardcdn.model.FullFileMetadata; -import nl.andrewlalis.gymboardcdn.service.FileStorageService; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileMetadata.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileMetadata.java new file mode 100644 index 0000000..442e09b --- /dev/null +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileMetadata.java @@ -0,0 +1,7 @@ +package nl.andrewlalis.gymboardcdn.files; + +public record FileMetadata ( + String filename, + String mimeType, + boolean accessible +) {} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileMetadataResponse.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileMetadataResponse.java similarity index 77% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileMetadataResponse.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileMetadataResponse.java index 6846cfa..bff629c 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/FileMetadataResponse.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileMetadataResponse.java @@ -1,4 +1,4 @@ -package nl.andrewlalis.gymboardcdn.api; +package nl.andrewlalis.gymboardcdn.files; public record FileMetadataResponse( String filename, diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileStorageService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileStorageService.java similarity index 95% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileStorageService.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileStorageService.java index e2df559..1fac261 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileStorageService.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileStorageService.java @@ -1,10 +1,8 @@ -package nl.andrewlalis.gymboardcdn.service; +package nl.andrewlalis.gymboardcdn.files; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletResponse; -import nl.andrewlalis.gymboardcdn.model.FileMetadata; -import nl.andrewlalis.gymboardcdn.model.FullFileMetadata; -import nl.andrewlalis.gymboardcdn.util.ULID; +import nl.andrewlalis.gymboardcdn.files.util.ULID; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; @@ -46,6 +44,10 @@ public class FileStorageService { this.baseStorageDir = baseStorageDir; } + public String generateFileId() { + return ulid.nextULID(); + } + /** * Saves a new file to the storage. * @param in The input stream to the file contents. @@ -106,8 +108,8 @@ public class FileStorageService { FileMetadata metadata = readMetadata(in); LocalDateTime date = dateFromULID(ULID.parseULID(rawId)); return new FullFileMetadata( - metadata.filename, - metadata.mimeType, + metadata.filename(), + metadata.mimeType(), Files.size(filePath) - HEADER_SIZE, date.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) ); @@ -131,7 +133,7 @@ public class FileStorageService { try (var in = Files.newInputStream(filePath)) { FileMetadata metadata = readMetadata(in); - response.setContentType(metadata.mimeType); + response.setContentType(metadata.mimeType()); response.setContentLengthLong(Files.size(filePath) - HEADER_SIZE); response.addHeader("Cache-Control", "max-age=604800, immutable"); var out = response.getOutputStream(); diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/FullFileMetadata.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FullFileMetadata.java similarity index 72% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/FullFileMetadata.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FullFileMetadata.java index acb024f..5610ed9 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/FullFileMetadata.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FullFileMetadata.java @@ -1,4 +1,4 @@ -package nl.andrewlalis.gymboardcdn.model; +package nl.andrewlalis.gymboardcdn.files; public record FullFileMetadata( String filename, diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/util/ULID.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/util/ULID.java similarity index 99% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/util/ULID.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/util/ULID.java index 1819795..1adb435 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/util/ULID.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/util/ULID.java @@ -1,4 +1,4 @@ -package nl.andrewlalis.gymboardcdn.util; +package nl.andrewlalis.gymboardcdn.files.util; /* * sulky-modules - several general-purpose modules. diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/FileMetadata.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/FileMetadata.java deleted file mode 100644 index fdef3ff..0000000 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/FileMetadata.java +++ /dev/null @@ -1,6 +0,0 @@ -package nl.andrewlalis.gymboardcdn.model; - -public class FileMetadata { - public String filename; - public String mimeType; -} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTask.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTask.java deleted file mode 100644 index d6a7e79..0000000 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTask.java +++ /dev/null @@ -1,62 +0,0 @@ -package nl.andrewlalis.gymboardcdn.model; - -import jakarta.persistence.*; -import org.hibernate.annotations.CreationTimestamp; - -import java.time.LocalDateTime; - -/** - * An entity to keep track of a task for processing a raw video into a better - * format for Gymboard to serve. - */ -@Entity -@Table(name = "task_video_processing") -public class VideoProcessingTask { - public enum Status { - WAITING, - IN_PROGRESS, - COMPLETED, - FAILED - } - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @CreationTimestamp - private LocalDateTime createdAt; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private Status status; - - @Column(nullable = false, updatable = false, length = 26) - private String rawUploadFileId; - - public VideoProcessingTask() {} - - public VideoProcessingTask(Status status, String rawUploadFileId) { - this.status = status; - this.rawUploadFileId = rawUploadFileId; - } - - public Long getId() { - return id; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public Status getStatus() { - return status; - } - - public void setStatus(Status status) { - this.status = status; - } - - public String getRawUploadFileId() { - return rawUploadFileId; - } -} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/UploadController.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/UploadController.java similarity index 57% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/UploadController.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/UploadController.java index eb5ade9..c563277 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/UploadController.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/UploadController.java @@ -1,7 +1,7 @@ -package nl.andrewlalis.gymboardcdn.api; +package nl.andrewlalis.gymboardcdn.uploads.api; import jakarta.servlet.http.HttpServletRequest; -import nl.andrewlalis.gymboardcdn.service.UploadService; +import nl.andrewlalis.gymboardcdn.uploads.service.UploadService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -16,12 +16,17 @@ public class UploadController { } @PostMapping(path = "/uploads/video", consumes = {"video/mp4"}) - public FileUploadResponse uploadVideo(HttpServletRequest request) { + public VideoUploadResponse uploadVideo(HttpServletRequest request) { return uploadService.processableVideoUpload(request); } - @GetMapping(path = "/uploads/video/{id}/status") - public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String id) { - return uploadService.getVideoProcessingStatus(id); + @PostMapping(path = "/uploads/video/{taskId}/start") + public void startVideoProcessing(@PathVariable long taskId) { + uploadService.startVideoProcessing(taskId); + } + + @GetMapping(path = "/uploads/video/{taskId}/status") + public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable long taskId) { + return uploadService.getVideoProcessingStatus(taskId); } } diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/VideoProcessingTaskStatusResponse.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoProcessingTaskStatusResponse.java similarity index 59% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/VideoProcessingTaskStatusResponse.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoProcessingTaskStatusResponse.java index 49670d4..fdbfa74 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/VideoProcessingTaskStatusResponse.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoProcessingTaskStatusResponse.java @@ -1,4 +1,4 @@ -package nl.andrewlalis.gymboardcdn.api; +package nl.andrewlalis.gymboardcdn.uploads.api; public record VideoProcessingTaskStatusResponse( String status diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoUploadResponse.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoUploadResponse.java new file mode 100644 index 0000000..9c0729f --- /dev/null +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoUploadResponse.java @@ -0,0 +1,5 @@ +package nl.andrewlalis.gymboardcdn.uploads.api; + +public record VideoUploadResponse( + long taskId +) {} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/model/VideoProcessingTask.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/model/VideoProcessingTask.java new file mode 100644 index 0000000..e4dba71 --- /dev/null +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/model/VideoProcessingTask.java @@ -0,0 +1,87 @@ +package nl.andrewlalis.gymboardcdn.uploads.model; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** + * An entity to keep track of a task for processing a raw video into a better + * format for Gymboard to serve. Generally, tasks are processed like so: + *
    + *
  1. A video is uploaded, and a new task is created with the NOT_STARTED status.
  2. + *
  3. Once the Gymboard API verifies the associated submission, it'll + * request to start the task, bringing it to the WAITING status.
  4. + *
  5. When a task executor picks up the waiting task, its status changes to IN_PROGRESS.
  6. + *
  7. If the video is processed successfully, then the task is COMPLETED, otherwise FAILED.
  8. + *
+ */ +@Entity +@Table(name = "task_video_processing") +public class VideoProcessingTask { + public enum Status { + NOT_STARTED, + WAITING, + IN_PROGRESS, + COMPLETED, + FAILED + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreationTimestamp + private LocalDateTime createdAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + /** + * The file id for the original, raw user-uploaded video file that needs to + * be processed. + */ + @Column(nullable = false, updatable = false, length = 26) + private String uploadFileId; + + /** + * The file id for the final processed video file. This doesn't exist yet, + * but we generate the video id right away, just in case there's a need to + * preemptively link to it. + */ + @Column(nullable = false, updatable = false, length = 26) + private String videoFileId; + + public VideoProcessingTask() {} + + public VideoProcessingTask(Status status, String uploadFileId, String videoFileId) { + this.status = status; + this.uploadFileId = uploadFileId; + this.videoFileId = videoFileId; + } + + public Long getId() { + return id; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public String getUploadFileId() { + return uploadFileId; + } + + public String getVideoFileId() { + return videoFileId; + } +} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTaskRepository.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/model/VideoProcessingTaskRepository.java similarity index 76% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTaskRepository.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/model/VideoProcessingTaskRepository.java index 6c997d6..fc21541 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/model/VideoProcessingTaskRepository.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/model/VideoProcessingTaskRepository.java @@ -1,16 +1,13 @@ -package nl.andrewlalis.gymboardcdn.model; +package nl.andrewlalis.gymboardcdn.uploads.model; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; @Repository public interface VideoProcessingTaskRepository extends JpaRepository { - Optional findByVideoIdentifier(String identifier); - List findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status status); List findAllByCreatedAtBefore(LocalDateTime cutoff); diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/CommandFailedException.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/CommandFailedException.java similarity index 94% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/CommandFailedException.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/CommandFailedException.java index 9ef064b..bca62f2 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/CommandFailedException.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/CommandFailedException.java @@ -1,4 +1,4 @@ -package nl.andrewlalis.gymboardcdn.service; +package nl.andrewlalis.gymboardcdn.uploads.service; import java.io.IOException; import java.nio.file.Path; diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/UploadService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/UploadService.java similarity index 51% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/UploadService.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/UploadService.java index 387dbf4..312ba41 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/UploadService.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/UploadService.java @@ -1,11 +1,12 @@ -package nl.andrewlalis.gymboardcdn.service; +package nl.andrewlalis.gymboardcdn.uploads.service; import jakarta.servlet.http.HttpServletRequest; -import nl.andrewlalis.gymboardcdn.api.FileUploadResponse; -import nl.andrewlalis.gymboardcdn.api.VideoProcessingTaskStatusResponse; -import nl.andrewlalis.gymboardcdn.model.FileMetadata; -import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask; -import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository; +import nl.andrewlalis.gymboardcdn.uploads.api.VideoUploadResponse; +import nl.andrewlalis.gymboardcdn.uploads.api.VideoProcessingTaskStatusResponse; +import nl.andrewlalis.gymboardcdn.files.FileMetadata; +import nl.andrewlalis.gymboardcdn.files.FileStorageService; +import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask; +import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -33,11 +34,12 @@ public class UploadService { * Handles uploading of a processable video file that will be processed * before being stored in the system. * @param request The request from which we can read the file. - * @return A response that contains an identifier that can be used to check - * the status of the video processing, and eventually fetch the video. + * @return A response containing the id of the video processing task, to be + * given to the Gymboard API so that it can further manage processing after + * a submission is completed. */ @Transactional - public FileUploadResponse processableVideoUpload(HttpServletRequest request) { + public VideoUploadResponse processableVideoUpload(HttpServletRequest request) { String contentLengthStr = request.getHeader("Content-Length"); if (contentLengthStr == null || !contentLengthStr.matches("\\d+")) { throw new ResponseStatusException(HttpStatus.LENGTH_REQUIRED); @@ -46,34 +48,51 @@ public class UploadService { if (contentLength > MAX_UPLOAD_SIZE_BYTES) { throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE); } - FileMetadata metadata = new FileMetadata(); - metadata.mimeType = request.getContentType(); - metadata.filename = request.getHeader("X-Gymboard-Filename"); - if (metadata.filename == null) metadata.filename = "unnamed.mp4"; - String fileId; + String filename = request.getHeader("X-Gymboard-Filename"); + if (filename == null) filename = "unnamed.mp4"; + FileMetadata metadata = new FileMetadata( + filename, + request.getContentType(), + false + ); + String uploadFileId; try { - fileId = fileStorageService.save(request.getInputStream(), metadata, contentLength); + uploadFileId = fileStorageService.save(request.getInputStream(), metadata, contentLength); } catch (IOException e) { log.error("Failed to save video upload to temp file.", e); throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR); } - videoTaskRepository.save(new VideoProcessingTask( - VideoProcessingTask.Status.WAITING, - fileId, - "bleh" + var task = videoTaskRepository.save(new VideoProcessingTask( + VideoProcessingTask.Status.NOT_STARTED, + uploadFileId, + fileStorageService.generateFileId() )); - return new FileUploadResponse("bleh"); + return new VideoUploadResponse(task.getId()); } /** * Gets the status of a video processing task. - * @param id The video identifier. + * @param id The task id. * @return The status of the video processing task. */ @Transactional(readOnly = true) - public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) { - VideoProcessingTask task = videoTaskRepository.findByVideoIdentifier(id) + public VideoProcessingTaskStatusResponse getVideoProcessingStatus(long id) { + VideoProcessingTask task = videoTaskRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); return new VideoProcessingTaskStatusResponse(task.getStatus().name()); } + + /** + * Marks this task as waiting to be picked up for processing. The Gymboard + * API should send a message itself to start processing of an uploaded video + * once it validates a submission. + * @param taskId The task id. + */ + @Transactional + public void startVideoProcessing(long taskId) { + VideoProcessingTask task = videoTaskRepository.findById(taskId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + task.setStatus(VideoProcessingTask.Status.WAITING); + videoTaskRepository.save(task); + } } diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/VideoProcessingService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java similarity index 73% rename from gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/VideoProcessingService.java rename to gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java index 5e64960..f3587bb 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/VideoProcessingService.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java @@ -1,7 +1,10 @@ -package nl.andrewlalis.gymboardcdn.service; +package nl.andrewlalis.gymboardcdn.uploads.service; -import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask; -import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository; +import nl.andrewlalis.gymboardcdn.files.FileMetadata; +import nl.andrewlalis.gymboardcdn.files.FileStorageService; +import nl.andrewlalis.gymboardcdn.files.util.ULID; +import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask; +import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -65,7 +68,7 @@ public class VideoProcessingService { private void processVideo(VideoProcessingTask task) { log.info("Started processing task {}.", task.getId()); - Path tempFilePath = fileStorageService.getStoragePathForFile(task.getRawUploadFileId()); + Path tempFilePath = fileStorageService.getStoragePathForFile(task.getUploadFileId()); if (Files.notExists(tempFilePath) || !Files.isReadable(tempFilePath)) { log.error("Temp file {} doesn't exist or isn't readable.", tempFilePath); updateTask(task, VideoProcessingTask.Status.FAILED); @@ -74,19 +77,20 @@ public class VideoProcessingService { // Then begin running the actual FFMPEG processing. Path tempDir = tempFilePath.getParent(); - Files.createTempFile() - Path ffmpegOutputFile = tempDir.resolve(task.getVideoIdentifier()); + Path ffmpegOutputFile = tempDir.resolve(task.getUploadFileId() + "-video-out"); + Path ffmpegThumbnailOutputFile = tempDir.resolve(task.getUploadFileId() + "-thumbnail-out"); try { - processVideoWithFFMPEG(tempDir, tempFile, ffmpegOutputFile); + generateThumbnailWithFFMPEG(tempDir, tempFilePath, ffmpegThumbnailOutputFile); + processVideoWithFFMPEG(tempDir, tempFilePath, ffmpegOutputFile); } catch (Exception e) { e.printStackTrace(); log.error(""" - Video processing failed for video {}: + Video processing failed for task {}: Input file: {} Output file: {} Exception message: {}""", - task.getVideoIdentifier(), - tempFile, + task.getId(), + tempFilePath, ffmpegOutputFile, e.getMessage() ); @@ -95,24 +99,41 @@ public class VideoProcessingService { } // And finally, copy the output to the final location. - try { - StoredFile storedFile = new StoredFile( - task.getVideoIdentifier(), - task.getFilename(), - "video/mp4", - Files.size(ffmpegOutputFile), - task.getCreatedAt() + try ( + var videoIn = Files.newInputStream(ffmpegOutputFile); + var thumbnailIn = Files.newInputStream(ffmpegThumbnailOutputFile) + ) { + // Save the video to a final file location. + var originalMetadata = fileStorageService.getMetadata(task.getUploadFileId()); + FileMetadata metadata = new FileMetadata( + originalMetadata.filename(), + originalMetadata.mimeType(), + true ); - Path finalFilePath = fileService.getStoragePathForFile(storedFile); - Files.move(ffmpegOutputFile, finalFilePath); - Files.deleteIfExists(tempFile); - Files.deleteIfExists(ffmpegOutputFile); - storedFileRepository.saveAndFlush(storedFile); + fileStorageService.save(ULID.parseULID(task.getVideoFileId()), videoIn, metadata, Files.size(ffmpegOutputFile)); + // Save the thumbnail too. + FileMetadata thumbnailMetadata = new FileMetadata( + "thumbnail.jpeg", + "image/jpeg", + true + ); + fileStorageService.save(thumbnailIn, thumbnailMetadata, Files.size(ffmpegThumbnailOutputFile)); updateTask(task, VideoProcessingTask.Status.COMPLETED); - log.info("Finished processing video {}.", task.getVideoIdentifier()); + log.info("Finished processing task {}.", task.getId()); + + // TODO: Send HTTP POST to API, with video id and thumbnail id. } catch (IOException e) { log.error("Failed to copy processed video to final storage location.", e); updateTask(task, VideoProcessingTask.Status.FAILED); + } finally { + try { + fileStorageService.delete(task.getUploadFileId()); + Files.deleteIfExists(ffmpegOutputFile); + Files.deleteIfExists(ffmpegThumbnailOutputFile); + } catch (IOException e) { + log.error("Couldn't delete temporary FFMPEG output file: {}", ffmpegOutputFile); + e.printStackTrace(); + } } } diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegVideoProcessor.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegVideoProcessor.java new file mode 100644 index 0000000..e8d1337 --- /dev/null +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegVideoProcessor.java @@ -0,0 +1,47 @@ +package nl.andrewlalis.gymboardcdn.uploads.service.process; + +import nl.andrewlalis.gymboardcdn.uploads.service.CommandFailedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; + +public class FfmpegVideoProcessor implements VideoProcessor { + private static final Logger log = LoggerFactory.getLogger(FfmpegVideoProcessor.class); + + @Override + public void processVideo(Path inputFilePath, Path outputFilePath) throws IOException { + String inputFilename = inputFilePath.getFileName().toString().strip(); + Path stdoutFile = inputFilePath.resolveSibling(inputFilename + "-ffmpeg-video-stdout.log"); + Path stderrFile = inputFilePath.resolveSibling(inputFilename + "-ffmpeg-video-stderr.log"); + final String[] command = { + "ffmpeg", + "-i", inputFilePath.toAbsolutePath().toString(), + "-vf", "scale=640x480:flags=lanczos", + "-vcodec", "libx264", + "-crf", "28", + "-f", "mp4", + outputFilePath.toAbsolutePath().toString() + }; + long startFileSize = Files.size(inputFilePath); + Instant startTime = Instant.now(); + Process process = new ProcessBuilder(command) + .redirectOutput(stdoutFile.toAbsolutePath().toFile()) + .redirectError(stderrFile.toAbsolutePath().toFile()) + .start(); + int result = process.waitFor(); + if (result != 0) { + throw new CommandFailedException(command, result, stdoutFile, stderrFile); + } + long endFileSize = Files.size(outputFilePath); + Duration duration = Duration.between(startTime, Instant.now()); + double reductionFactor = startFileSize / (double) endFileSize; + String reductionFactorStr = String.format("%.3f%%", reductionFactor * 100); + log.info("Processed video from {} bytes to {} bytes in {} seconds, {} reduction.", startSize, endSize, dur.getSeconds(), reductionFactorStr); + + } +} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/ThumbnailGenerator.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/ThumbnailGenerator.java new file mode 100644 index 0000000..4059336 --- /dev/null +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/ThumbnailGenerator.java @@ -0,0 +1,7 @@ +package nl.andrewlalis.gymboardcdn.uploads.service.process; + +import java.nio.file.Path; + +public interface ThumbnailGenerator { + void generateThumbnailImage(Path videoInputFile, Path outputFilePath); +} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/VideoProcessor.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/VideoProcessor.java new file mode 100644 index 0000000..a8af275 --- /dev/null +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/VideoProcessor.java @@ -0,0 +1,8 @@ +package nl.andrewlalis.gymboardcdn.uploads.service.process; + +import java.io.IOException; +import java.nio.file.Path; + +public interface VideoProcessor { + void processVideo(Path inputFilePath, Path outputFilePath) throws IOException; +} diff --git a/gymboard-cdn/src/test/java/nl/andrewlalis/gymboardcdn/service/UploadServiceTest.java b/gymboard-cdn/src/test/java/nl/andrewlalis/gymboardcdn/service/UploadServiceTest.java index ff4c03b..6fe37c2 100644 --- a/gymboard-cdn/src/test/java/nl/andrewlalis/gymboardcdn/service/UploadServiceTest.java +++ b/gymboard-cdn/src/test/java/nl/andrewlalis/gymboardcdn/service/UploadServiceTest.java @@ -1,21 +1,11 @@ package nl.andrewlalis.gymboardcdn.service; -import jakarta.servlet.ServletInputStream; -import jakarta.servlet.http.HttpServletRequest; -import nl.andrewlalis.gymboardcdn.api.FileUploadResponse; -import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask; -import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.AdditionalAnswers.returnsFirstArg; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; public class UploadServiceTest { /**