diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/AsyncConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/AsyncConfig.java new file mode 100644 index 0000000..6a04d0f --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/AsyncConfig.java @@ -0,0 +1,23 @@ +package nl.andrewlalis.gymboard_api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + @Bean + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(3); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("gymboard-api-"); + executor.initialize(); + return executor; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java index 357068b..ab04020 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java @@ -50,7 +50,7 @@ public class GymController { @PathVariable String cityCode, @PathVariable String gymName, @RequestParam MultipartFile file - ) throws IOException { - return uploadService.handleUpload(new RawGymId(countryCode, cityCode, gymName), file); + ) { + return uploadService.handleSubmissionUpload(new RawGymId(countryCode, cityCode, gymName), file); } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionPayload.java index a8ef574..9760f29 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionPayload.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionPayload.java @@ -4,5 +4,6 @@ public record ExerciseSubmissionPayload( String name, String exerciseShortName, float weight, + int reps, long videoId ) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionTempFileRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionTempFileRepository.java new file mode 100644 index 0000000..8f58403 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionTempFileRepository.java @@ -0,0 +1,16 @@ +package nl.andrewlalis.gymboard_api.dao.exercise; + +import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; +import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionTempFile; +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 ExerciseSubmissionTempFileRepository extends JpaRepository { + List findAllByCreatedAtBefore(LocalDateTime timestamp); + Optional findBySubmission(ExerciseSubmission submission); +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java index ce3e9f4..72de18e 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java @@ -2,7 +2,6 @@ package nl.andrewlalis.gymboard_api.model.exercise; import jakarta.persistence.*; import nl.andrewlalis.gymboard_api.model.Gym; -import nl.andrewlalis.gymboard_api.model.StoredFile; import org.hibernate.annotations.CreationTimestamp; import java.math.BigDecimal; @@ -24,26 +23,30 @@ public class ExerciseSubmission { @ManyToOne(optional = false, fetch = FetchType.LAZY) private Exercise exercise; - @Column(nullable = false, updatable = false, length = 63) - private String submitterName; + @Column(nullable = false) + private String status; @Column(nullable = false) private boolean verified; + @Column(nullable = false, updatable = false, length = 63) + private String submitterName; + @Column(nullable = false, precision = 7, scale = 2) private BigDecimal weight; - @OneToOne(optional = false, fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - private StoredFile videoFile; + @Column(nullable = false) + private int reps; public ExerciseSubmission() {} - public ExerciseSubmission(Gym gym, Exercise exercise, String submitterName, BigDecimal weight, StoredFile videoFile) { + public ExerciseSubmission(Gym gym, Exercise exercise, String submitterName, BigDecimal weight, int reps) { this.gym = gym; this.exercise = exercise; this.submitterName = submitterName; this.weight = weight; - this.videoFile = videoFile; + this.reps = reps; + this.status = "PROCESSING"; } public Long getId() { @@ -62,6 +65,14 @@ public class ExerciseSubmission { return exercise; } + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + public String getSubmitterName() { return submitterName; } @@ -74,7 +85,7 @@ public class ExerciseSubmission { return weight; } - public StoredFile getVideoFile() { - return videoFile; + public int getReps() { + return reps; } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmissionTempFile.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmissionTempFile.java new file mode 100644 index 0000000..fdf292e --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmissionTempFile.java @@ -0,0 +1,58 @@ +package nl.andrewlalis.gymboard_api.model.exercise; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** + * Tracks the temporary file on disk that's stored while a user is preparing + * their submission. This file will be removed after the submission is + * processed. + */ +@Entity +@Table(name = "exercise_submission_temp_file") +public class ExerciseSubmissionTempFile { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreationTimestamp + private LocalDateTime createdAt; + + @Column(nullable = false, updatable = false, length = 1024) + private String path; + + /** + * The submission that this temporary file is for. This will initially be + * null, but will be set as soon as the submission is finalized. + */ + @OneToOne(fetch = FetchType.LAZY) + private ExerciseSubmission submission; + + public ExerciseSubmissionTempFile() {} + + public ExerciseSubmissionTempFile(String path) { + this.path = path; + } + + public Long getId() { + return id; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public String getPath() { + return path; + } + + public ExerciseSubmission getSubmission() { + return submission; + } + + public void setSubmission(ExerciseSubmission submission) { + this.submission = submission; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/GymService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/GymService.java index 69ed144..14048c9 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/GymService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/GymService.java @@ -8,11 +8,16 @@ import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.dao.StoredFileRepository; import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository; +import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionTempFileRepository; import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.model.StoredFile; import nl.andrewlalis.gymboard_api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; +import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionTempFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; @@ -21,19 +26,28 @@ import java.io.IOException; import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; @Service public class GymService { + private static final Logger log = LoggerFactory.getLogger(GymService.class); + private final GymRepository gymRepository; private final StoredFileRepository fileRepository; private final ExerciseRepository exerciseRepository; private final ExerciseSubmissionRepository exerciseSubmissionRepository; + private final ExerciseSubmissionTempFileRepository tempFileRepository; - public GymService(GymRepository gymRepository, StoredFileRepository fileRepository, ExerciseRepository exerciseRepository, ExerciseSubmissionRepository exerciseSubmissionRepository) { + public GymService(GymRepository gymRepository, + StoredFileRepository fileRepository, + ExerciseRepository exerciseRepository, + ExerciseSubmissionRepository exerciseSubmissionRepository, + ExerciseSubmissionTempFileRepository tempFileRepository) { this.gymRepository = gymRepository; this.fileRepository = fileRepository; this.exerciseRepository = exerciseRepository; this.exerciseSubmissionRepository = exerciseSubmissionRepository; + this.tempFileRepository = tempFileRepository; } @Transactional(readOnly = true) @@ -43,27 +57,69 @@ public class GymService { return new GymResponse(gym); } + /** + * Handles the creation of a new exercise submission. This involves a few steps: + *
    + *
  1. Pre-fetch all of the referenced data, like exercise and video file.
  2. + *
  3. Check that the submission is legitimate.
  4. + *
  5. Begin video processing.
  6. + *
  7. Save the submission with the PROCESSING status.
  8. + *
+ * Once the asynchronous submission processing is complete, the submission + * status will change to COMPLETE. + * @param id The gym id. + * @param payload The submission data. + * @return The saved submission, which will be in the PROCESSING state at first. + */ @Transactional public ExerciseSubmissionResponse createSubmission(RawGymId id, ExerciseSubmissionPayload payload) throws IOException { Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); Exercise exercise = exerciseRepository.findById(payload.exerciseShortName()) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); - // TODO: Implement legitimate file storage. - Path path = Path.of("sample_data", "sample_curl_14kg.MP4"); - StoredFile file = fileRepository.save(new StoredFile( - "sample_curl_14kg.MP4", - "video/mp4", - Files.size(path), - Files.readAllBytes(path) - )); + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid exercise.")); + ExerciseSubmissionTempFile tempFile = tempFileRepository.findById(payload.videoId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid video id.")); + + // TODO: Validate the submission data. + + + // Create the submission. ExerciseSubmission submission = exerciseSubmissionRepository.save(new ExerciseSubmission( gym, exercise, payload.name(), BigDecimal.valueOf(payload.weight()), - file + payload.reps() )); + // Then link it to the temporary video file so the async task can find it. + tempFile.setSubmission(submission); + tempFileRepository.save(tempFile); + return new ExerciseSubmissionResponse(submission); } + + /** + * Asynchronous task that's started after a submission is submitted, which + * handles video processing and anything else that might need to be done + * before the submission can be marked as COMPLETED. + * @param submissionId The submission's id. + */ + @Async @Transactional + public void processSubmission(long submissionId) { + Optional optionalSubmission = exerciseSubmissionRepository.findById(submissionId); + if (optionalSubmission.isEmpty()) { + log.warn("Submission id {} is not associated with a submission.", submissionId); + return; + } + ExerciseSubmission submission = optionalSubmission.get(); + Optional optionalTempFile = tempFileRepository.findBySubmission(submission); + if (optionalTempFile.isEmpty()) { + log.warn("Submission {} failed because the temporary video file couldn't be found.", submission.getId()); + submission.setStatus("FAILED"); + return; + } + ExerciseSubmissionTempFile tempFile = optionalTempFile.get(); + + // TODO: Finish this! + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/UploadService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/UploadService.java index b79dbf3..c0c742b 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/UploadService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/UploadService.java @@ -3,13 +3,12 @@ package nl.andrewlalis.gymboard_api.service; import nl.andrewlalis.gymboard_api.controller.dto.RawGymId; import nl.andrewlalis.gymboard_api.controller.dto.UploadedFileResponse; import nl.andrewlalis.gymboard_api.dao.GymRepository; -import nl.andrewlalis.gymboard_api.dao.StoredFileRepository; +import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionTempFileRepository; import nl.andrewlalis.gymboard_api.model.Gym; -import nl.andrewlalis.gymboard_api.model.StoredFile; +import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionTempFile; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.FileSystemUtils; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -17,52 +16,85 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; - /** * Service for handling large file uploads. - * TODO: Use this instead of simple multipart form data. */ @Service public class UploadService { - private final StoredFileRepository fileRepository; + private static final String[] ALLOWED_VIDEO_TYPES = { + "video/mp4" + }; + + private final ExerciseSubmissionTempFileRepository tempFileRepository; private final GymRepository gymRepository; - public UploadService(StoredFileRepository fileRepository, GymRepository gymRepository) { - this.fileRepository = fileRepository; + public UploadService(ExerciseSubmissionTempFileRepository tempFileRepository, GymRepository gymRepository) { + this.tempFileRepository = tempFileRepository; this.gymRepository = gymRepository; } + /** + * Handles the upload of an exercise submission's video file by saving the + * file to a temporary location, and recording that location in the + * database for when the exercise submission is completed. We'll only do + * the computationally expensive video processing if a user successfully + * submits their submission; otherwise, the raw video is discarded after a + * while. + * @param gymId The gym's id. + * @param multipartFile The uploaded file. + * @return A response containing the uploaded file's id, to be included in + * the user's submission. + */ @Transactional - public UploadedFileResponse handleUpload(RawGymId gymId, MultipartFile multipartFile) throws IOException { + public UploadedFileResponse handleSubmissionUpload(RawGymId gymId, MultipartFile multipartFile) { Gym gym = gymRepository.findByRawId(gymId.gymName(), gymId.cityCode(), gymId.countryCode()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // TODO: Check that user is allowed to upload. - // TODO: Robust file type check. - if (!"video/mp4".equalsIgnoreCase(multipartFile.getContentType())) { + boolean fileTypeAcceptable = false; + for (String allowedType : ALLOWED_VIDEO_TYPES) { + if (allowedType.equalsIgnoreCase(multipartFile.getContentType())) { + fileTypeAcceptable = true; + break; + } + } + if (!fileTypeAcceptable) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid content type."); } - Path tempDir = Files.createTempDirectory("gymboard-file-upload"); - Path tempFile = tempDir.resolve("video-file"); - multipartFile.transferTo(tempFile); - Process ffmpegProcess = new ProcessBuilder() - .command("ffmpeg", "-i", "video-file", "-vf", "scale=640x480:flags=lanczos", "-vcodec", "libx264", "-crf", "28", "output.mp4") - .inheritIO() - .directory(tempDir.toFile()) - .start(); try { - int result = ffmpegProcess.waitFor(); - if (result != 0) throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg exited with code " + result); - } catch (InterruptedException e) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg process interrupted", e); + Path tempFileDir = Path.of("exercise_submission_temp_files"); + if (!Files.exists(tempFileDir)) { + Files.createDirectory(tempFileDir); + } + Path tempFilePath = Files.createTempFile(tempFileDir, null, null); + multipartFile.transferTo(tempFilePath); + ExerciseSubmissionTempFile tempFileEntity = tempFileRepository.save(new ExerciseSubmissionTempFile(tempFilePath.toString())); + return new UploadedFileResponse(tempFileEntity.getId()); + } catch (IOException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "File upload failed.", e); } - Path compressedFile = tempDir.resolve("output.mp4"); - StoredFile file = fileRepository.save(new StoredFile( - "compressed.mp4", - "video/mp4", - Files.size(compressedFile), - Files.readAllBytes(compressedFile) - )); - FileSystemUtils.deleteRecursively(tempDir); - return new UploadedFileResponse(file.getId()); + +// Path tempDir = Files.createTempDirectory("gymboard-file-upload"); +// Path tempFile = tempDir.resolve("video-file"); +// multipartFile.transferTo(tempFile); +// Process ffmpegProcess = new ProcessBuilder() +// .command("ffmpeg", "-i", "video-file", "-vf", "scale=640x480:flags=lanczos", "-vcodec", "libx264", "-crf", "28", "output.mp4") +// .inheritIO() +// .directory(tempDir.toFile()) +// .start(); +// try { +// int result = ffmpegProcess.waitFor(); +// if (result != 0) throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg exited with code " + result); +// } catch (InterruptedException e) { +// throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg process interrupted", e); +// } +// Path compressedFile = tempDir.resolve("output.mp4"); +// StoredFile file = fileRepository.save(new StoredFile( +// "compressed.mp4", +// "video/mp4", +// Files.size(compressedFile), +// Files.readAllBytes(compressedFile) +// )); +// FileSystemUtils.deleteRecursively(tempDir); +// return new UploadedFileResponse(file.getId()); } }