Improved exercise submission flow.
This commit is contained in:
parent
50a6ece0d8
commit
6702fb564b
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,5 +4,6 @@ public record ExerciseSubmissionPayload(
|
|||
String name,
|
||||
String exerciseShortName,
|
||||
float weight,
|
||||
int reps,
|
||||
long videoId
|
||||
) {}
|
||||
|
|
|
@ -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<ExerciseSubmissionTempFile, Long> {
|
||||
List<ExerciseSubmissionTempFile> findAllByCreatedAtBefore(LocalDateTime timestamp);
|
||||
Optional<ExerciseSubmissionTempFile> findBySubmission(ExerciseSubmission submission);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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:
|
||||
* <ol>
|
||||
* <li>Pre-fetch all of the referenced data, like exercise and video file.</li>
|
||||
* <li>Check that the submission is legitimate.</li>
|
||||
* <li>Begin video processing.</li>
|
||||
* <li>Save the submission with the PROCESSING status.</li>
|
||||
* </ol>
|
||||
* 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<ExerciseSubmission> optionalSubmission = exerciseSubmissionRepository.findById(submissionId);
|
||||
if (optionalSubmission.isEmpty()) {
|
||||
log.warn("Submission id {} is not associated with a submission.", submissionId);
|
||||
return;
|
||||
}
|
||||
ExerciseSubmission submission = optionalSubmission.get();
|
||||
Optional<ExerciseSubmissionTempFile> 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!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue