diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/SubmissionPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/SubmissionPayload.java index 1fea3c6..9ac0a48 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/SubmissionPayload.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/SubmissionPayload.java @@ -8,5 +8,5 @@ public record SubmissionPayload( float weight, String weightUnit, int reps, - String videoFileId + long taskId ) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/submission/Submission.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/submission/Submission.java index 3a98a48..209a02d 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/submission/Submission.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/submission/Submission.java @@ -32,13 +32,24 @@ public class Submission { @Column(nullable = false) private LocalDateTime performedAt; + /** + * The id of the video processing task that a user gives to us when they + * create the submission, so that when the task finishes processing, we can + * route its data to the right submission. + */ + @Column(nullable = false, updatable = false) + private long videoProcessingTaskId; + /** * The id of the video file that was submitted for this submission. It lives * on the gymboard-cdn service as a stored file, which can be * accessed via GET https://CDN-HOST/files/{videoFileId}. */ - @Column(nullable = false, updatable = false, length = 26) - private String videoFileId; + @Column(length = 26) + private String videoFileId = null; + + @Column(length = 26) + private String thumbnailFileId = null; @Column(nullable = false, precision = 7, scale = 2) private BigDecimal rawWeight; @@ -64,7 +75,7 @@ public class Submission { Exercise exercise, User user, LocalDateTime performedAt, - String videoFileId, + long videoProcessingTaskId, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, @@ -73,9 +84,9 @@ public class Submission { this.id = id; this.gym = gym; this.exercise = exercise; - this.videoFileId = videoFileId; this.user = user; this.performedAt = performedAt; + this.videoProcessingTaskId = videoProcessingTaskId; this.rawWeight = rawWeight; this.weightUnit = unit; this.metricWeight = metricWeight; @@ -99,10 +110,26 @@ public class Submission { return exercise; } + public long getVideoProcessingTaskId() { + return videoProcessingTaskId; + } + public String getVideoFileId() { return videoFileId; } + public String getThumbnailFileId() { + return thumbnailFileId; + } + + public void setVideoFileId(String videoFileId) { + this.videoFileId = videoFileId; + } + + public void setThumbnailFileId(String thumbnailFileId) { + this.thumbnailFileId = thumbnailFileId; + } + public User getUser() { return user; } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java index 9f355de..4208730 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java @@ -50,4 +50,14 @@ public class CdnClient { HttpResponse response = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); return objectMapper.readValue(response.body(), responseType); } + + public void post(String urlPath) throws IOException, InterruptedException { + HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath)) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + HttpResponse response = httpClient.send(req, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() != 200) { + throw new IOException("Request failed with code " + response.statusCode()); + } + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java index a08b25d..903d1fc 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java @@ -3,8 +3,12 @@ package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client; import java.nio.file.Path; public record UploadsClient(CdnClient client) { - public record FileUploadResponse(String id) {} - public record VideoProcessingTaskStatusResponse(String status) {} + public record FileUploadResponse(long taskId) {} + public record VideoProcessingTaskStatusResponse( + String status, + String videoFileId, + String thumbnailFileId + ) {} public record FileMetadataResponse( String filename, @@ -14,15 +18,19 @@ public record UploadsClient(CdnClient client) { boolean availableForDownload ) {} - public FileUploadResponse uploadVideo(Path filePath, String contentType) throws Exception { - return client.postFile("/uploads/video", filePath, contentType, FileUploadResponse.class); + public long uploadVideo(Path filePath, String contentType) throws Exception { + return client.postFile("/uploads/video", filePath, contentType, FileUploadResponse.class).taskId(); } - public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) throws Exception { + public VideoProcessingTaskStatusResponse getVideoProcessingTaskStatus(long id) throws Exception { return client.get("/uploads/video/" + id + "/status", VideoProcessingTaskStatusResponse.class); } public FileMetadataResponse getFileMetadata(String id) throws Exception { return client.get("/files/" + id + "/metadata", FileMetadataResponse.class); } + + public void startTask(long taskId) throws Exception { + client.post("/uploads/video/" + taskId + "/start"); + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java index b82a6a9..1df518e 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java @@ -91,9 +91,14 @@ public class ExerciseSubmissionService { Submission submission = submissionRepository.saveAndFlush(new Submission( ulid.nextULID(), gym, exercise, user, performedAt, - payload.videoFileId(), + payload.taskId(), rawWeight, weightUnit, metricWeight, payload.reps() )); + try { + cdnClient.uploads.startTask(submission.getVideoProcessingTaskId()); + } catch (Exception e) { + log.error("Failed to start video processing task for submission " + submission.getId(), e); + } return new SubmissionResponse(submission); } @@ -118,17 +123,13 @@ public class ExerciseSubmissionService { } try { - UploadsClient.FileMetadataResponse metadata = cdnClient.uploads.getFileMetadata(data.videoFileId()); - if (metadata == null) { - response.addMessage("Missing video file."); - } else if (!metadata.availableForDownload()) { - response.addMessage("File not yet available for download."); - } else if (!"video/mp4".equals(metadata.mimeType())) { - response.addMessage("Invalid video file format."); + var status = cdnClient.uploads.getVideoProcessingTaskStatus(data.taskId()); + if (!status.status().equalsIgnoreCase("NOT_STARTED")) { + response.addMessage("Invalid video processing task."); } } catch (Exception e) { - log.error("Error fetching file metadata.", e); - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error fetching uploaded video file metadata."); + log.error("Error fetching task status.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error fetching uploaded video task status."); } return response; } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataLoader.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataLoader.java index 285e174..70303cb 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataLoader.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleDataLoader.java @@ -56,7 +56,7 @@ public class SampleDataLoader implements ApplicationListener videoIds = new ArrayList<>(); - var video1 = cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_curl.mp4"), "video/mp4"); - var video2 = cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_ohp.mp4"), "video/mp4"); - videoIds.add(video1.id()); - videoIds.add(video2.id()); + var uploads = generateUploads(); + // Now that uploads are complete, we can proceed with generating the submissions. List gyms = gymRepository.findAll(); List users = userRepository.findAll(); List exercises = exerciseRepository.findAll(); @@ -65,24 +64,27 @@ public class SampleSubmissionGenerator implements SampleDataGenerator { Random random = new Random(1); List submissions = new ArrayList<>(count); for (int i = 0; i < count; i++) { - submissions.add(generateRandomSubmission( + Submission submission = generateRandomSubmission( gyms, users, exercises, - videoIds, + uploads, earliestSubmission, latestSubmission, random - )); + ); + submissions.add(submission); } submissionRepository.saveAll(submissions); + + // After adding all the submissions, we'll signal to CDN that it can start processing. } private Submission generateRandomSubmission( List gyms, List users, List exercises, - List videoIds, + Map> uploads, LocalDateTime earliestSubmission, LocalDateTime latestSubmission, Random random @@ -102,13 +104,16 @@ public class SampleSubmissionGenerator implements SampleDataGenerator { randomChoice(exercises, random), randomChoice(users, random), time, - randomChoice(videoIds, random), + randomChoice(new ArrayList<>(uploads.keySet()), random), rawWeight, weightUnit, metricWeight, random.nextInt(13) + 1 ); submission.setVerified(true); + var uploadData = uploads.get(submission.getVideoProcessingTaskId()); + submission.setVideoFileId(uploadData.getFirst()); + submission.setThumbnailFileId(uploadData.getSecond()); return submission; } @@ -125,4 +130,48 @@ public class SampleSubmissionGenerator implements SampleDataGenerator { Duration dur = Duration.between(start, end); return start.plusSeconds(rand.nextLong(dur.toSeconds() + 1)); } + + /** + * Generates a set of sample video uploads to use for all the sample + * submissions. + * @return A map containing keys representing video processing task ids, and + * values being a pair of video and thumbnail file ids. + * @throws Exception If an error occurs. + */ + private Map> generateUploads() throws Exception { + final CdnClient cdnClient = new CdnClient(cdnOrigin); + + List taskIds = new ArrayList<>(); + taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_curl.mp4"), "video/mp4")); + taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_ohp.mp4"), "video/mp4")); + + Map taskStatus = new HashMap<>(); + for (long taskId : taskIds) { + cdnClient.uploads.startTask(taskId); + taskStatus.put(taskId, cdnClient.uploads.getVideoProcessingTaskStatus(taskId)); + } + + // Wait for all video uploads to complete. + while ( + taskStatus.values().stream() + .map(UploadsClient.VideoProcessingTaskStatusResponse::status) + .anyMatch(status -> !List.of("COMPLETED", "FAILED").contains(status.toUpperCase())) + ) { + log.info("Waiting for sample video upload tasks to finish..."); + Thread.sleep(1000); + for (long taskId : taskIds) taskStatus.put(taskId, cdnClient.uploads.getVideoProcessingTaskStatus(taskId)); + } + + // If any upload failed, throw an exception and cancel this generator. + if (taskStatus.values().stream().anyMatch(r -> r.status().equalsIgnoreCase("FAILED"))) { + throw new IOException("Video upload task processing failed."); + } + + // Prepare the final data structure. + Map> finalResults = new HashMap<>(); + for (var entry : taskStatus.entrySet()) { + finalResults.put(entry.getKey(), Pair.of(entry.getValue().videoFileId(), entry.getValue().thumbnailFileId())); + } + return finalResults; + } } diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileStorageService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileStorageService.java index a1a4d46..3f1d6ed 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileStorageService.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/files/FileStorageService.java @@ -180,6 +180,20 @@ public class FileStorageService { Files.deleteIfExists(filePath); } + public void copyTo(String fileId, Path filePath) throws IOException { + Path inputFilePath = getStoragePathForFile(fileId); + if (Files.notExists(inputFilePath)) { + throw new IOException("File " + fileId + " not found."); + } + try ( + var in = Files.newInputStream(inputFilePath); + var out = Files.newOutputStream(filePath) + ) { + readMetadata(in); + in.transferTo(out); + } + } + private static LocalDateTime dateFromULID(ULID.Value value) { return Instant.ofEpochMilli(value.timestamp()) .atOffset(ZoneOffset.UTC) diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoProcessingTaskStatusResponse.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoProcessingTaskStatusResponse.java index fdbfa74..74b1621 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoProcessingTaskStatusResponse.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/api/VideoProcessingTaskStatusResponse.java @@ -1,5 +1,7 @@ package nl.andrewlalis.gymboardcdn.uploads.api; public record VideoProcessingTaskStatusResponse( - String status + String status, + String videoFileId, + String thumbnailFileId ) {} 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 index c0ca5d4..1f202f9 100644 --- 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 @@ -45,6 +45,12 @@ public class VideoProcessingTask { @Column(nullable = false, updatable = false, length = 26) private String uploadFileId; + @Column(length = 26) + private String videoFileId; + + @Column(length = 26) + private String thumbnailFileId; + public VideoProcessingTask() {} public VideoProcessingTask(Status status, String uploadFileId) { @@ -71,4 +77,20 @@ public class VideoProcessingTask { public String getUploadFileId() { return uploadFileId; } + + public String getVideoFileId() { + return videoFileId; + } + + public void setVideoFileId(String videoFileId) { + this.videoFileId = videoFileId; + } + + public String getThumbnailFileId() { + return thumbnailFileId; + } + + public void setThumbnailFileId(String thumbnailFileId) { + this.thumbnailFileId = thumbnailFileId; + } } diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/UploadService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/UploadService.java index 324fe43..e52f754 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/UploadService.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/UploadService.java @@ -78,7 +78,11 @@ public class UploadService { public VideoProcessingTaskStatusResponse getVideoProcessingStatus(long id) { VideoProcessingTask task = videoTaskRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - return new VideoProcessingTaskStatusResponse(task.getStatus().name()); + return new VideoProcessingTaskStatusResponse( + task.getStatus().name(), + task.getVideoFileId(), + task.getThumbnailFileId() + ); } /** @@ -91,7 +95,9 @@ public class UploadService { public void startVideoProcessing(long taskId) { VideoProcessingTask task = videoTaskRepository.findById(taskId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - task.setStatus(VideoProcessingTask.Status.WAITING); - videoTaskRepository.save(task); + if (task.getStatus() == VideoProcessingTask.Status.NOT_STARTED) { + task.setStatus(VideoProcessingTask.Status.WAITING); + videoTaskRepository.save(task); + } } } diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java index d2961e8..f9b8703 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java @@ -89,20 +89,29 @@ public class VideoProcessingService { log.info("Started processing task {}.", task.getId()); Path uploadFile = fileStorageService.getStoragePathForFile(task.getUploadFileId()); + Path rawUploadFile = uploadFile.resolveSibling(task.getUploadFileId() + "-vid-in"); if (Files.notExists(uploadFile) || !Files.isReadable(uploadFile)) { log.error("Uploaded video file {} doesn't exist or isn't readable.", uploadFile); updateTask(task, VideoProcessingTask.Status.FAILED); return; } + try { + fileStorageService.copyTo(task.getUploadFileId(), rawUploadFile); + } catch (IOException e) { + log.error("Failed to copy raw video file {} to {}.", uploadFile, rawUploadFile); + e.printStackTrace(); + updateTask(task, VideoProcessingTask.Status.FAILED); + return; + } // Run the actual processing here. - Path videoFile = uploadFile.resolveSibling(task.getUploadFileId() + "-vid-out"); - Path thumbnailFile = uploadFile.resolveSibling(task.getUploadFileId() + "-thm-out"); + Path videoFile = uploadFile.resolveSibling(task.getUploadFileId() + "-vid-out.mp4"); + Path thumbnailFile = uploadFile.resolveSibling(task.getUploadFileId() + "-thm-out.jpeg"); try { log.info("Processing video for uploaded video file {}.", uploadFile.getFileName()); - videoProcessor.processVideo(uploadFile, videoFile); + videoProcessor.processVideo(rawUploadFile, videoFile); log.info("Generating thumbnail for uploaded video file {}.", uploadFile.getFileName()); - thumbnailGenerator.generateThumbnailImage(uploadFile, thumbnailFile); + thumbnailGenerator.generateThumbnailImage(videoFile, thumbnailFile); } catch (Exception e) { e.printStackTrace(); log.error(""" @@ -111,7 +120,7 @@ public class VideoProcessingService { Output file: {} Exception message: {}""", task.getId(), - uploadFile, + rawUploadFile, videoFile, e.getMessage() ); @@ -129,6 +138,9 @@ public class VideoProcessingService { // Save the thumbnail too. FileMetadata thumbnailMetadata = new FileMetadata("thumbnail.jpeg", "image/jpeg", true); String thumbnailFileId = fileStorageService.save(thumbnailFile, thumbnailMetadata); + + task.setVideoFileId(videoFileId); + task.setThumbnailFileId(thumbnailFileId); updateTask(task, VideoProcessingTask.Status.COMPLETED); log.info("Finished processing task {}.", task.getId()); @@ -140,6 +152,7 @@ public class VideoProcessingService { } finally { try { fileStorageService.delete(task.getUploadFileId()); + Files.deleteIfExists(rawUploadFile); Files.deleteIfExists(videoFile); Files.deleteIfExists(thumbnailFile); } catch (IOException e) {