From e612c084da0c0f40e52eb88843b720c55e5d1946 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Wed, 25 Jan 2023 11:55:14 +0100 Subject: [PATCH] Implemented backend submission flow. --- .../gymboard_api/config/AsyncConfig.java | 2 + .../controller/GymController.java | 9 +- .../dto/ExerciseSubmissionResponse.java | 8 +- .../ExerciseSubmissionRepository.java | 3 + ...ExerciseSubmissionVideoFileRepository.java | 13 + .../model/exercise/ExerciseSubmission.java | 23 +- .../exercise/ExerciseSubmissionVideoFile.java | 41 +++ .../service/CommandFailedException.java | 35 +++ .../service/ExerciseSubmissionService.java | 247 ++++++++++++++++++ .../gymboard_api/service/GymService.java | 95 +------ .../gymboard_api/service/UploadService.java | 24 -- gymboard-app/src/api/gymboard-api.ts | 20 +- .../{gymboard-search.ts => search/index.ts} | 17 +- gymboard-app/src/api/search/models.ts | 12 + ...ymItem.vue => GymSearchResultListItem.vue} | 2 +- gymboard-app/src/pages/IndexPage.vue | 7 +- .../gymboardsearch/dto/GymResponse.java | 2 + 17 files changed, 399 insertions(+), 161 deletions(-) create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionVideoFileRepository.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmissionVideoFile.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/CommandFailedException.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/ExerciseSubmissionService.java rename gymboard-app/src/api/{gymboard-search.ts => search/index.ts} (55%) create mode 100644 gymboard-app/src/api/search/models.ts rename gymboard-app/src/components/{SimpleGymItem.vue => GymSearchResultListItem.vue} (91%) 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 index 6a04d0f..9784500 100644 --- 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 @@ -3,12 +3,14 @@ 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.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @Configuration @EnableAsync +@EnableScheduling public class AsyncConfig { @Bean public Executor taskExecutor() { 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 ab04020..a827ceb 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 @@ -1,6 +1,7 @@ package nl.andrewlalis.gymboard_api.controller; import nl.andrewlalis.gymboard_api.controller.dto.*; +import nl.andrewlalis.gymboard_api.service.ExerciseSubmissionService; import nl.andrewlalis.gymboard_api.service.GymService; import nl.andrewlalis.gymboard_api.service.UploadService; import org.springframework.http.MediaType; @@ -16,10 +17,12 @@ import java.io.IOException; public class GymController { private final GymService gymService; private final UploadService uploadService; + private final ExerciseSubmissionService submissionService; - public GymController(GymService gymService, UploadService uploadService) { + public GymController(GymService gymService, UploadService uploadService, ExerciseSubmissionService submissionService) { this.gymService = gymService; this.uploadService = uploadService; + this.submissionService = submissionService; } @GetMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}") @@ -37,8 +40,8 @@ public class GymController { @PathVariable String cityCode, @PathVariable String gymName, @RequestBody ExerciseSubmissionPayload payload - ) throws IOException { - return gymService.createSubmission(new RawGymId(countryCode, cityCode, gymName), payload); + ) { + return submissionService.createSubmission(new RawGymId(countryCode, cityCode, gymName), payload); } @PostMapping( diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java index cd44377..e1c7452 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java @@ -9,10 +9,10 @@ public record ExerciseSubmissionResponse( String createdAt, GymSimpleResponse gym, ExerciseResponse exercise, + String status, String submitterName, - boolean verified, double weight, - String videoFileUrl + int reps ) { public ExerciseSubmissionResponse(ExerciseSubmission submission) { this( @@ -20,10 +20,10 @@ public record ExerciseSubmissionResponse( submission.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), new GymSimpleResponse(submission.getGym()), new ExerciseResponse(submission.getExercise()), + submission.getStatus().name(), submission.getSubmitterName(), - submission.isVerified(), submission.getWeight().doubleValue(), - "bleh" + submission.getReps() ); } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java index 60ecc7d..6c80c86 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java @@ -4,6 +4,9 @@ import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface ExerciseSubmissionRepository extends JpaRepository { + List findAllByStatus(ExerciseSubmission.Status status); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionVideoFileRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionVideoFileRepository.java new file mode 100644 index 0000000..55fc9ce --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionVideoFileRepository.java @@ -0,0 +1,13 @@ +package nl.andrewlalis.gymboard_api.dao.exercise; + +import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; +import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionVideoFile; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ExerciseSubmissionVideoFileRepository extends JpaRepository { + 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 72de18e..41e71f8 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 @@ -10,6 +10,13 @@ import java.time.LocalDateTime; @Entity @Table(name = "exercise_submission") public class ExerciseSubmission { + public enum Status { + WAITING, + PROCESSING, + FAILED, + COMPLETED + } + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -23,11 +30,9 @@ public class ExerciseSubmission { @ManyToOne(optional = false, fetch = FetchType.LAZY) private Exercise exercise; + @Enumerated(EnumType.STRING) @Column(nullable = false) - private String status; - - @Column(nullable = false) - private boolean verified; + private Status status; @Column(nullable = false, updatable = false, length = 63) private String submitterName; @@ -46,7 +51,7 @@ public class ExerciseSubmission { this.submitterName = submitterName; this.weight = weight; this.reps = reps; - this.status = "PROCESSING"; + this.status = Status.WAITING; } public Long getId() { @@ -65,11 +70,11 @@ public class ExerciseSubmission { return exercise; } - public String getStatus() { + public Status getStatus() { return status; } - public void setStatus(String status) { + public void setStatus(Status status) { this.status = status; } @@ -77,10 +82,6 @@ public class ExerciseSubmission { return submitterName; } - public boolean isVerified() { - return verified; - } - public BigDecimal getWeight() { return weight; } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmissionVideoFile.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmissionVideoFile.java new file mode 100644 index 0000000..076aaaf --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmissionVideoFile.java @@ -0,0 +1,41 @@ +package nl.andrewlalis.gymboard_api.model.exercise; + +import jakarta.persistence.*; +import nl.andrewlalis.gymboard_api.model.StoredFile; + +/** + * An entity which links an {@link ExerciseSubmission} to a {@link nl.andrewlalis.gymboard_api.model.StoredFile} + * containing the video that was submitted along with the submission. + */ +@Entity +@Table(name = "exercise_submission_video_file") +public class ExerciseSubmissionVideoFile { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(optional = false, fetch = FetchType.LAZY) + private ExerciseSubmission submission; + + @OneToOne(optional = false, fetch = FetchType.LAZY, orphanRemoval = true) + private StoredFile file; + + public ExerciseSubmissionVideoFile() {} + + public ExerciseSubmissionVideoFile(ExerciseSubmission submission, StoredFile file) { + this.submission = submission; + this.file = file; + } + + public Long getId() { + return id; + } + + public ExerciseSubmission getSubmission() { + return submission; + } + + public StoredFile getFile() { + return file; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/CommandFailedException.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/CommandFailedException.java new file mode 100644 index 0000000..1443e01 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/CommandFailedException.java @@ -0,0 +1,35 @@ +package nl.andrewlalis.gymboard_api.service; + +import java.io.IOException; +import java.nio.file.Path; + +public class CommandFailedException extends IOException { + private final Path stdoutFile; + private final Path stderrFile; + private final int exitCode; + private final String[] command; + + public CommandFailedException(String[] command, int exitCode, Path stdoutFile, Path stderrFile) { + super(String.format("Command \"%s\" exited with code %d.", String.join(" ", command), exitCode)); + this.command = command; + this.exitCode = exitCode; + this.stdoutFile = stdoutFile; + this.stderrFile = stderrFile; + } + + public Path getStdoutFile() { + return stdoutFile; + } + + public Path getStderrFile() { + return stderrFile; + } + + public int getExitCode() { + return exitCode; + } + + public String[] getCommand() { + return command; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/ExerciseSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/ExerciseSubmissionService.java new file mode 100644 index 0000000..2c3c58e --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/ExerciseSubmissionService.java @@ -0,0 +1,247 @@ +package nl.andrewlalis.gymboard_api.service; + +import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload; +import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; +import nl.andrewlalis.gymboard_api.controller.dto.RawGymId; +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.dao.exercise.ExerciseSubmissionVideoFileRepository; +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 nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionVideoFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Service which handles the logic behind accepting, validating, and processing + * exercise submissions. + */ +@Service +public class ExerciseSubmissionService { + private static final Logger log = LoggerFactory.getLogger(ExerciseSubmissionService.class); + + private final GymRepository gymRepository; + private final StoredFileRepository fileRepository; + private final ExerciseRepository exerciseRepository; + private final ExerciseSubmissionRepository exerciseSubmissionRepository; + private final ExerciseSubmissionTempFileRepository tempFileRepository; + private final ExerciseSubmissionVideoFileRepository submissionVideoFileRepository; + + public ExerciseSubmissionService(GymRepository gymRepository, + StoredFileRepository fileRepository, + ExerciseRepository exerciseRepository, + ExerciseSubmissionRepository exerciseSubmissionRepository, + ExerciseSubmissionTempFileRepository tempFileRepository, + ExerciseSubmissionVideoFileRepository submissionVideoFileRepository) { + this.gymRepository = gymRepository; + this.fileRepository = fileRepository; + this.exerciseRepository = exerciseRepository; + this.exerciseSubmissionRepository = exerciseSubmissionRepository; + this.tempFileRepository = tempFileRepository; + this.submissionVideoFileRepository = submissionVideoFileRepository; + } + + + /** + * 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) { + 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, "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()), + payload.reps() + )); + // Then link it to the temporary video file so the async task can find it. + tempFile.setSubmission(submission); + tempFileRepository.save(tempFile); + // The submission will be picked up eventually to be processed. + + return new ExerciseSubmissionResponse(submission); + } + + /** + * Simple scheduled task that periodically checks for new submissions + * that are waiting to be processed, and queues tasks to do so. + */ + @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.SECONDS) + public void processWaitingSubmissions() { + List waitingSubmissions = exerciseSubmissionRepository.findAllByStatus(ExerciseSubmission.Status.WAITING); + for (var submission : waitingSubmissions) { + processSubmission(submission.getId()); + } + } + + /** + * 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. + *

+ * Note: This method is intentionally NOT transactional, since it may + * have a long duration, and we want real-time status updates. + *

+ * @param submissionId The submission's id. + */ + @Async + public void processSubmission(long submissionId) { + log.info("Starting processing of submission {}.", submissionId); + // First try and fetch the submission. + Optional optionalSubmission = exerciseSubmissionRepository.findById(submissionId); + if (optionalSubmission.isEmpty()) { + log.warn("Submission id {} is not associated with a submission.", submissionId); + return; + } + ExerciseSubmission submission = optionalSubmission.get(); + if (submission.getStatus() != ExerciseSubmission.Status.WAITING) { + log.warn("Submission {} cannot be processed because its status {} is not WAITING.", submission.getId(), submission.getStatus()); + return; + } + + // Set the status to processing. + submission.setStatus(ExerciseSubmission.Status.PROCESSING); + exerciseSubmissionRepository.save(submission); + + // Then try and fetch the temporary video file associated with it. + 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(ExerciseSubmission.Status.FAILED); + exerciseSubmissionRepository.save(submission); + return; + } + ExerciseSubmissionTempFile tempFile = optionalTempFile.get(); + Path tempFilePath = Path.of(tempFile.getPath()); + if (!Files.exists(tempFilePath) || !Files.isReadable(tempFilePath)) { + log.error("Submission {} failed because the temporary video file {} isn't readable.", submission.getId(), tempFilePath); + submission.setStatus(ExerciseSubmission.Status.FAILED); + exerciseSubmissionRepository.save(submission); + return; + } + + // Now we can try to process the video file into a compressed format that can be stored in the DB. + Path dir = tempFilePath.getParent(); + String tempFileName = tempFilePath.getFileName().toString(); + String tempFileBaseName = tempFileName.substring(0, tempFileName.length() - ".tmp".length()); + Path outFilePath = dir.resolve(tempFileBaseName + "-out.tmp"); + StoredFile file; + try { + processVideo(dir, tempFilePath, outFilePath); + file = fileRepository.save(new StoredFile( + "compressed.mp4", + "video/mp4", + Files.size(outFilePath), + Files.readAllBytes(outFilePath) + )); + } catch (Exception e) { + log.error(""" + Video processing failed for submission {}: + Input file: {} + Output file: {} + Exception message: {}""", + submission.getId(), + tempFilePath, + outFilePath, + e.getMessage() + ); + submission.setStatus(ExerciseSubmission.Status.FAILED); + exerciseSubmissionRepository.save(submission); + return; + } + + // After we've saved the processed file, we can link it to the submission, and set the submission's status. + submissionVideoFileRepository.save(new ExerciseSubmissionVideoFile( + submission, + file + )); + submission.setStatus(ExerciseSubmission.Status.COMPLETED); + exerciseSubmissionRepository.save(submission); + // And delete the temporary files. + try { + Files.delete(tempFilePath); + Files.delete(outFilePath); + tempFileRepository.delete(tempFile); + } catch (IOException e) { + log.error("Couldn't delete temporary files after submission completed.", e); + } + log.info("Processing of submission {} complete.", submission.getId()); + } + + private void processVideo(Path dir, Path inFile, Path outFile) throws IOException, InterruptedException { + Path tmpStdout = Files.createTempFile(dir, "stdout-", ".log"); + Path tmpStderr = Files.createTempFile(dir, "stderr-", ".log"); + final String[] command = { + "ffmpeg", "-i", inFile.getFileName().toString(), + "-vf", "scale=640x480:flags=lanczos", + "-vcodec", "libx264", + "-crf", "28", + outFile.getFileName().toString() + }; + + long startSize = Files.size(inFile); + Instant startTime = Instant.now(); + + Process ffmpegProcess = new ProcessBuilder() + .command(command) + .redirectOutput(tmpStdout.toFile()) + .redirectError(tmpStderr.toFile()) + .redirectInput(ProcessBuilder.Redirect.DISCARD) + .directory(dir.toFile()) + .start(); + int result = ffmpegProcess.waitFor(); + if (result != 0) throw new CommandFailedException(command, result, tmpStdout, tmpStderr); + + long endSize = Files.size(outFile); + Duration dur = Duration.between(startTime, Instant.now()); + double reductionFactor = startSize / (double) endSize; + String reductionFactorStr = String.format("%.3f%%", reductionFactor * 100); + log.info("Processed video from {} bytes to {} bytes in {} seconds, {} reduction.", startSize, endSize, dur.getSeconds(), reductionFactorStr); + + Files.deleteIfExists(tmpStdout); + Files.deleteIfExists(tmpStderr); + } +} 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 14048c9..9a494cc 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 @@ -1,53 +1,24 @@ package nl.andrewlalis.gymboard_api.service; -import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload; -import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.controller.dto.GymResponse; import nl.andrewlalis.gymboard_api.controller.dto.RawGymId; 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; -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, - ExerciseSubmissionTempFileRepository tempFileRepository) { + public GymService(GymRepository gymRepository) { this.gymRepository = gymRepository; - this.fileRepository = fileRepository; - this.exerciseRepository = exerciseRepository; - this.exerciseSubmissionRepository = exerciseSubmissionRepository; - this.tempFileRepository = tempFileRepository; } @Transactional(readOnly = true) @@ -57,69 +28,5 @@ 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, "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()), - 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 c0c742b..9472b36 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 @@ -72,29 +72,5 @@ public class UploadService { } catch (IOException e) { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "File upload failed.", e); } - -// 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()); } } diff --git a/gymboard-app/src/api/gymboard-api.ts b/gymboard-app/src/api/gymboard-api.ts index 0600573..ac5e808 100644 --- a/gymboard-app/src/api/gymboard-api.ts +++ b/gymboard-app/src/api/gymboard-api.ts @@ -10,19 +10,20 @@ const api = axios.create({ export interface Exercise { shortName: string, displayName: string -}; +} export interface GeoPoint { latitude: number, longitude: number -}; +} export interface ExerciseSubmissionPayload { name: string, exerciseShortName: string, weight: number, + reps: number, videoId: number -}; +} export interface Gym { countryCode: string, @@ -35,18 +36,27 @@ export interface Gym { websiteUrl: string | null, location: GeoPoint, streetAddress: string -}; +} +/** + * Gets the URL for uploading a video file when creating an exercise submission + * for a gym. + * @param gym The gym that the submission is for. + */ export function getUploadUrl(gym: Gym) { return BASE_URL + `/gyms/${gym.countryCode}/${gym.cityShortName}/${gym.shortName}/submissions/upload`; } +/** + * Gets the URL at which the raw file data for the given file id can be streamed. + * @param fileId The file id. + */ export function getFileUrl(fileId: number) { return BASE_URL + `/files/${fileId}`; } export async function getExercises(): Promise> { - const response = await api.get(`/exercises`); + const response = await api.get('/exercises'); return response.data; } diff --git a/gymboard-app/src/api/gymboard-search.ts b/gymboard-app/src/api/search/index.ts similarity index 55% rename from gymboard-app/src/api/gymboard-search.ts rename to gymboard-app/src/api/search/index.ts index 4df48c6..90b1105 100644 --- a/gymboard-app/src/api/gymboard-search.ts +++ b/gymboard-app/src/api/search/index.ts @@ -1,25 +1,10 @@ -/** - * Module for interacting with the Gymboard search service's API. - */ - import axios from 'axios'; +import {GymSearchResult} from 'src/api/search/models'; const api = axios.create({ baseURL: 'http://localhost:8081' }); -export interface GymSearchResult { - shortName: string, - displayName: string, - cityShortName: string, - cityName: string, - countryCode: string, - countryName: string, - streetAddress: string, - latitude: number, - longitude: number -} - /** * Searches for gyms using the given query, and eventually returns results. * @param query The query to use. diff --git a/gymboard-app/src/api/search/models.ts b/gymboard-app/src/api/search/models.ts new file mode 100644 index 0000000..e073e76 --- /dev/null +++ b/gymboard-app/src/api/search/models.ts @@ -0,0 +1,12 @@ +export interface GymSearchResult { + compoundId: string, + shortName: string, + displayName: string, + cityShortName: string, + cityName: string, + countryCode: string, + countryName: string, + streetAddress: string, + latitude: number, + longitude: number +} diff --git a/gymboard-app/src/components/SimpleGymItem.vue b/gymboard-app/src/components/GymSearchResultListItem.vue similarity index 91% rename from gymboard-app/src/components/SimpleGymItem.vue rename to gymboard-app/src/components/GymSearchResultListItem.vue index eac3b5e..406ae32 100644 --- a/gymboard-app/src/components/SimpleGymItem.vue +++ b/gymboard-app/src/components/GymSearchResultListItem.vue @@ -12,8 +12,8 @@