Improved exercise submission flow.

This commit is contained in:
Andrew Lalis 2023-01-25 08:48:49 +01:00
parent 50a6ece0d8
commit 6702fb564b
8 changed files with 251 additions and 54 deletions

View File

@ -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;
}
}

View File

@ -50,7 +50,7 @@ public class GymController {
@PathVariable String cityCode, @PathVariable String cityCode,
@PathVariable String gymName, @PathVariable String gymName,
@RequestParam MultipartFile file @RequestParam MultipartFile file
) throws IOException { ) {
return uploadService.handleUpload(new RawGymId(countryCode, cityCode, gymName), file); return uploadService.handleSubmissionUpload(new RawGymId(countryCode, cityCode, gymName), file);
} }
} }

View File

@ -4,5 +4,6 @@ public record ExerciseSubmissionPayload(
String name, String name,
String exerciseShortName, String exerciseShortName,
float weight, float weight,
int reps,
long videoId long videoId
) {} ) {}

View File

@ -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);
}

View File

@ -2,7 +2,6 @@ package nl.andrewlalis.gymboard_api.model.exercise;
import jakarta.persistence.*; import jakarta.persistence.*;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.model.Gym;
import nl.andrewlalis.gymboard_api.model.StoredFile;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal; import java.math.BigDecimal;
@ -24,26 +23,30 @@ public class ExerciseSubmission {
@ManyToOne(optional = false, fetch = FetchType.LAZY) @ManyToOne(optional = false, fetch = FetchType.LAZY)
private Exercise exercise; private Exercise exercise;
@Column(nullable = false, updatable = false, length = 63) @Column(nullable = false)
private String submitterName; private String status;
@Column(nullable = false) @Column(nullable = false)
private boolean verified; private boolean verified;
@Column(nullable = false, updatable = false, length = 63)
private String submitterName;
@Column(nullable = false, precision = 7, scale = 2) @Column(nullable = false, precision = 7, scale = 2)
private BigDecimal weight; private BigDecimal weight;
@OneToOne(optional = false, fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @Column(nullable = false)
private StoredFile videoFile; private int reps;
public ExerciseSubmission() {} 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.gym = gym;
this.exercise = exercise; this.exercise = exercise;
this.submitterName = submitterName; this.submitterName = submitterName;
this.weight = weight; this.weight = weight;
this.videoFile = videoFile; this.reps = reps;
this.status = "PROCESSING";
} }
public Long getId() { public Long getId() {
@ -62,6 +65,14 @@ public class ExerciseSubmission {
return exercise; return exercise;
} }
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getSubmitterName() { public String getSubmitterName() {
return submitterName; return submitterName;
} }
@ -74,7 +85,7 @@ public class ExerciseSubmission {
return weight; return weight;
} }
public StoredFile getVideoFile() { public int getReps() {
return videoFile; return reps;
} }
} }

View File

@ -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;
}
}

View File

@ -8,11 +8,16 @@ import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.StoredFileRepository; import nl.andrewlalis.gymboard_api.dao.StoredFileRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository; 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.Gym;
import nl.andrewlalis.gymboard_api.model.StoredFile; import nl.andrewlalis.gymboard_api.model.StoredFile;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; 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.http.HttpStatus;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@ -21,19 +26,28 @@ import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Optional;
@Service @Service
public class GymService { public class GymService {
private static final Logger log = LoggerFactory.getLogger(GymService.class);
private final GymRepository gymRepository; private final GymRepository gymRepository;
private final StoredFileRepository fileRepository; private final StoredFileRepository fileRepository;
private final ExerciseRepository exerciseRepository; private final ExerciseRepository exerciseRepository;
private final ExerciseSubmissionRepository exerciseSubmissionRepository; 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.gymRepository = gymRepository;
this.fileRepository = fileRepository; this.fileRepository = fileRepository;
this.exerciseRepository = exerciseRepository; this.exerciseRepository = exerciseRepository;
this.exerciseSubmissionRepository = exerciseSubmissionRepository; this.exerciseSubmissionRepository = exerciseSubmissionRepository;
this.tempFileRepository = tempFileRepository;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@ -43,27 +57,69 @@ public class GymService {
return new GymResponse(gym); 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 @Transactional
public ExerciseSubmissionResponse createSubmission(RawGymId id, ExerciseSubmissionPayload payload) throws IOException { public ExerciseSubmissionResponse createSubmission(RawGymId id, ExerciseSubmissionPayload payload) throws IOException {
Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode()) Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Exercise exercise = exerciseRepository.findById(payload.exerciseShortName()) Exercise exercise = exerciseRepository.findById(payload.exerciseShortName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid exercise."));
// TODO: Implement legitimate file storage. ExerciseSubmissionTempFile tempFile = tempFileRepository.findById(payload.videoId())
Path path = Path.of("sample_data", "sample_curl_14kg.MP4"); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid video id."));
StoredFile file = fileRepository.save(new StoredFile(
"sample_curl_14kg.MP4", // TODO: Validate the submission data.
"video/mp4",
Files.size(path),
Files.readAllBytes(path) // Create the submission.
));
ExerciseSubmission submission = exerciseSubmissionRepository.save(new ExerciseSubmission( ExerciseSubmission submission = exerciseSubmissionRepository.save(new ExerciseSubmission(
gym, gym,
exercise, exercise,
payload.name(), payload.name(),
BigDecimal.valueOf(payload.weight()), 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); 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!
}
} }

View File

@ -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.RawGymId;
import nl.andrewlalis.gymboard_api.controller.dto.UploadedFileResponse; import nl.andrewlalis.gymboard_api.controller.dto.UploadedFileResponse;
import nl.andrewlalis.gymboard_api.dao.GymRepository; 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.Gym;
import nl.andrewlalis.gymboard_api.model.StoredFile; import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionTempFile;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.FileSystemUtils;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@ -17,52 +16,85 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
/** /**
* Service for handling large file uploads. * Service for handling large file uploads.
* TODO: Use this instead of simple multipart form data.
*/ */
@Service @Service
public class UploadService { public class UploadService {
private final StoredFileRepository fileRepository; private static final String[] ALLOWED_VIDEO_TYPES = {
"video/mp4"
};
private final ExerciseSubmissionTempFileRepository tempFileRepository;
private final GymRepository gymRepository; private final GymRepository gymRepository;
public UploadService(StoredFileRepository fileRepository, GymRepository gymRepository) { public UploadService(ExerciseSubmissionTempFileRepository tempFileRepository, GymRepository gymRepository) {
this.fileRepository = fileRepository; this.tempFileRepository = tempFileRepository;
this.gymRepository = gymRepository; 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 @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()) Gym gym = gymRepository.findByRawId(gymId.gymName(), gymId.cityCode(), gymId.countryCode())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// TODO: Check that user is allowed to upload. // TODO: Check that user is allowed to upload.
// TODO: Robust file type check. boolean fileTypeAcceptable = false;
if (!"video/mp4".equalsIgnoreCase(multipartFile.getContentType())) { 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."); 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 { try {
int result = ffmpegProcess.waitFor(); Path tempFileDir = Path.of("exercise_submission_temp_files");
if (result != 0) throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg exited with code " + result); if (!Files.exists(tempFileDir)) {
} catch (InterruptedException e) { Files.createDirectory(tempFileDir);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg process interrupted", e); }
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( // Path tempDir = Files.createTempDirectory("gymboard-file-upload");
"compressed.mp4", // Path tempFile = tempDir.resolve("video-file");
"video/mp4", // multipartFile.transferTo(tempFile);
Files.size(compressedFile), // Process ffmpegProcess = new ProcessBuilder()
Files.readAllBytes(compressedFile) // .command("ffmpeg", "-i", "video-file", "-vf", "scale=640x480:flags=lanczos", "-vcodec", "libx264", "-crf", "28", "output.mp4")
)); // .inheritIO()
FileSystemUtils.deleteRecursively(tempDir); // .directory(tempDir.toFile())
return new UploadedFileResponse(file.getId()); // .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());
} }
} }