Implemented backend submission flow.
This commit is contained in:
parent
6702fb564b
commit
e612c084da
|
@ -3,12 +3,14 @@ package nl.andrewlalis.gymboard_api.config;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
public class AsyncConfig {
|
public class AsyncConfig {
|
||||||
@Bean
|
@Bean
|
||||||
public Executor taskExecutor() {
|
public Executor taskExecutor() {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package nl.andrewlalis.gymboard_api.controller;
|
package nl.andrewlalis.gymboard_api.controller;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.controller.dto.*;
|
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.GymService;
|
||||||
import nl.andrewlalis.gymboard_api.service.UploadService;
|
import nl.andrewlalis.gymboard_api.service.UploadService;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
@ -16,10 +17,12 @@ import java.io.IOException;
|
||||||
public class GymController {
|
public class GymController {
|
||||||
private final GymService gymService;
|
private final GymService gymService;
|
||||||
private final UploadService uploadService;
|
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.gymService = gymService;
|
||||||
this.uploadService = uploadService;
|
this.uploadService = uploadService;
|
||||||
|
this.submissionService = submissionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}")
|
@GetMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}")
|
||||||
|
@ -37,8 +40,8 @@ public class GymController {
|
||||||
@PathVariable String cityCode,
|
@PathVariable String cityCode,
|
||||||
@PathVariable String gymName,
|
@PathVariable String gymName,
|
||||||
@RequestBody ExerciseSubmissionPayload payload
|
@RequestBody ExerciseSubmissionPayload payload
|
||||||
) throws IOException {
|
) {
|
||||||
return gymService.createSubmission(new RawGymId(countryCode, cityCode, gymName), payload);
|
return submissionService.createSubmission(new RawGymId(countryCode, cityCode, gymName), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(
|
@PostMapping(
|
||||||
|
|
|
@ -9,10 +9,10 @@ public record ExerciseSubmissionResponse(
|
||||||
String createdAt,
|
String createdAt,
|
||||||
GymSimpleResponse gym,
|
GymSimpleResponse gym,
|
||||||
ExerciseResponse exercise,
|
ExerciseResponse exercise,
|
||||||
|
String status,
|
||||||
String submitterName,
|
String submitterName,
|
||||||
boolean verified,
|
|
||||||
double weight,
|
double weight,
|
||||||
String videoFileUrl
|
int reps
|
||||||
) {
|
) {
|
||||||
public ExerciseSubmissionResponse(ExerciseSubmission submission) {
|
public ExerciseSubmissionResponse(ExerciseSubmission submission) {
|
||||||
this(
|
this(
|
||||||
|
@ -20,10 +20,10 @@ public record ExerciseSubmissionResponse(
|
||||||
submission.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
submission.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||||
new GymSimpleResponse(submission.getGym()),
|
new GymSimpleResponse(submission.getGym()),
|
||||||
new ExerciseResponse(submission.getExercise()),
|
new ExerciseResponse(submission.getExercise()),
|
||||||
|
submission.getStatus().name(),
|
||||||
submission.getSubmitterName(),
|
submission.getSubmitterName(),
|
||||||
submission.isVerified(),
|
|
||||||
submission.getWeight().doubleValue(),
|
submission.getWeight().doubleValue(),
|
||||||
"bleh"
|
submission.getReps()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,9 @@ import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface ExerciseSubmissionRepository extends JpaRepository<ExerciseSubmission, Long> {
|
public interface ExerciseSubmissionRepository extends JpaRepository<ExerciseSubmission, Long> {
|
||||||
|
List<ExerciseSubmission> findAllByStatus(ExerciseSubmission.Status status);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<ExerciseSubmissionVideoFile, Long> {
|
||||||
|
Optional<ExerciseSubmissionVideoFile> findBySubmission(ExerciseSubmission submission);
|
||||||
|
}
|
|
@ -10,6 +10,13 @@ import java.time.LocalDateTime;
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "exercise_submission")
|
@Table(name = "exercise_submission")
|
||||||
public class ExerciseSubmission {
|
public class ExerciseSubmission {
|
||||||
|
public enum Status {
|
||||||
|
WAITING,
|
||||||
|
PROCESSING,
|
||||||
|
FAILED,
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
@ -23,11 +30,9 @@ public class ExerciseSubmission {
|
||||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
private Exercise exercise;
|
private Exercise exercise;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String status;
|
private Status status;
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private boolean verified;
|
|
||||||
|
|
||||||
@Column(nullable = false, updatable = false, length = 63)
|
@Column(nullable = false, updatable = false, length = 63)
|
||||||
private String submitterName;
|
private String submitterName;
|
||||||
|
@ -46,7 +51,7 @@ public class ExerciseSubmission {
|
||||||
this.submitterName = submitterName;
|
this.submitterName = submitterName;
|
||||||
this.weight = weight;
|
this.weight = weight;
|
||||||
this.reps = reps;
|
this.reps = reps;
|
||||||
this.status = "PROCESSING";
|
this.status = Status.WAITING;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
|
@ -65,11 +70,11 @@ public class ExerciseSubmission {
|
||||||
return exercise;
|
return exercise;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getStatus() {
|
public Status getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setStatus(String status) {
|
public void setStatus(Status status) {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,10 +82,6 @@ public class ExerciseSubmission {
|
||||||
return submitterName;
|
return submitterName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isVerified() {
|
|
||||||
return verified;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getWeight() {
|
public BigDecimal getWeight() {
|
||||||
return weight;
|
return weight;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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:
|
||||||
|
* <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) {
|
||||||
|
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<ExerciseSubmission> 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.
|
||||||
|
* <p>
|
||||||
|
* Note: This method is intentionally NOT transactional, since it may
|
||||||
|
* have a long duration, and we want real-time status updates.
|
||||||
|
* </p>
|
||||||
|
* @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<ExerciseSubmission> 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<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(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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,53 +1,24 @@
|
||||||
package nl.andrewlalis.gymboard_api.service;
|
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.GymResponse;
|
||||||
import nl.andrewlalis.gymboard_api.controller.dto.RawGymId;
|
import nl.andrewlalis.gymboard_api.controller.dto.RawGymId;
|
||||||
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.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.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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
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 static final Logger log = LoggerFactory.getLogger(GymService.class);
|
||||||
|
|
||||||
private final GymRepository gymRepository;
|
private final GymRepository gymRepository;
|
||||||
private final StoredFileRepository fileRepository;
|
|
||||||
private final ExerciseRepository exerciseRepository;
|
|
||||||
private final ExerciseSubmissionRepository exerciseSubmissionRepository;
|
|
||||||
private final ExerciseSubmissionTempFileRepository tempFileRepository;
|
|
||||||
|
|
||||||
public GymService(GymRepository gymRepository,
|
public GymService(GymRepository gymRepository) {
|
||||||
StoredFileRepository fileRepository,
|
|
||||||
ExerciseRepository exerciseRepository,
|
|
||||||
ExerciseSubmissionRepository exerciseSubmissionRepository,
|
|
||||||
ExerciseSubmissionTempFileRepository tempFileRepository) {
|
|
||||||
this.gymRepository = gymRepository;
|
this.gymRepository = gymRepository;
|
||||||
this.fileRepository = fileRepository;
|
|
||||||
this.exerciseRepository = exerciseRepository;
|
|
||||||
this.exerciseSubmissionRepository = exerciseSubmissionRepository;
|
|
||||||
this.tempFileRepository = tempFileRepository;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
@ -57,69 +28,5 @@ 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
|
|
||||||
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<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!
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -72,29 +72,5 @@ public class UploadService {
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "File upload failed.", 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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,19 +10,20 @@ const api = axios.create({
|
||||||
export interface Exercise {
|
export interface Exercise {
|
||||||
shortName: string,
|
shortName: string,
|
||||||
displayName: string
|
displayName: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export interface GeoPoint {
|
export interface GeoPoint {
|
||||||
latitude: number,
|
latitude: number,
|
||||||
longitude: number
|
longitude: number
|
||||||
};
|
}
|
||||||
|
|
||||||
export interface ExerciseSubmissionPayload {
|
export interface ExerciseSubmissionPayload {
|
||||||
name: string,
|
name: string,
|
||||||
exerciseShortName: string,
|
exerciseShortName: string,
|
||||||
weight: number,
|
weight: number,
|
||||||
|
reps: number,
|
||||||
videoId: number
|
videoId: number
|
||||||
};
|
}
|
||||||
|
|
||||||
export interface Gym {
|
export interface Gym {
|
||||||
countryCode: string,
|
countryCode: string,
|
||||||
|
@ -35,18 +36,27 @@ export interface Gym {
|
||||||
websiteUrl: string | null,
|
websiteUrl: string | null,
|
||||||
location: GeoPoint,
|
location: GeoPoint,
|
||||||
streetAddress: string
|
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) {
|
export function getUploadUrl(gym: Gym) {
|
||||||
return BASE_URL + `/gyms/${gym.countryCode}/${gym.cityShortName}/${gym.shortName}/submissions/upload`;
|
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) {
|
export function getFileUrl(fileId: number) {
|
||||||
return BASE_URL + `/files/${fileId}`;
|
return BASE_URL + `/files/${fileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getExercises(): Promise<Array<Exercise>> {
|
export async function getExercises(): Promise<Array<Exercise>> {
|
||||||
const response = await api.get(`/exercises`);
|
const response = await api.get('/exercises');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,25 +1,10 @@
|
||||||
/**
|
|
||||||
* Module for interacting with the Gymboard search service's API.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import {GymSearchResult} from 'src/api/search/models';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: 'http://localhost:8081'
|
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.
|
* Searches for gyms using the given query, and eventually returns results.
|
||||||
* @param query The query to use.
|
* @param query The query to use.
|
|
@ -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
|
||||||
|
}
|
|
@ -12,8 +12,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {GymSearchResult} from 'src/api/gymboard-search';
|
|
||||||
import {getGymRoute} from 'src/router/gym-routing';
|
import {getGymRoute} from 'src/router/gym-routing';
|
||||||
|
import {GymSearchResult} from 'src/api/search/models';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gym: GymSearchResult
|
gym: GymSearchResult
|
|
@ -13,7 +13,7 @@
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-list>
|
<q-list>
|
||||||
<SimpleGymItem v-for="gym in searchResults" :gym="gym" :key="gym.shortName" />
|
<GymSearchResultListItem v-for="result in searchResults" :gym="result" :key="result.compoundId" />
|
||||||
</q-list>
|
</q-list>
|
||||||
</StandardCenteredPage>
|
</StandardCenteredPage>
|
||||||
</template>
|
</template>
|
||||||
|
@ -21,9 +21,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {onMounted, ref, Ref} from 'vue';
|
import {onMounted, ref, Ref} from 'vue';
|
||||||
import {useRoute, useRouter} from 'vue-router';
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
import {GymSearchResult, searchGyms} from 'src/api/gymboard-search';
|
import GymSearchResultListItem from 'components/GymSearchResultListItem.vue';
|
||||||
import SimpleGymItem from 'src/components/SimpleGymItem.vue';
|
|
||||||
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
|
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
|
||||||
|
import {GymSearchResult} from 'src/api/search/models';
|
||||||
|
import {searchGyms} from 'src/api/search';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
@ -3,6 +3,7 @@ package nl.andrewlalis.gymboardsearch.dto;
|
||||||
import org.apache.lucene.document.Document;
|
import org.apache.lucene.document.Document;
|
||||||
|
|
||||||
public record GymResponse(
|
public record GymResponse(
|
||||||
|
String compoundId,
|
||||||
String shortName,
|
String shortName,
|
||||||
String displayName,
|
String displayName,
|
||||||
String cityShortName,
|
String cityShortName,
|
||||||
|
@ -15,6 +16,7 @@ public record GymResponse(
|
||||||
) {
|
) {
|
||||||
public GymResponse(Document doc) {
|
public GymResponse(Document doc) {
|
||||||
this(
|
this(
|
||||||
|
doc.get("compound_id"),
|
||||||
doc.get("short_name"),
|
doc.get("short_name"),
|
||||||
doc.get("display_name"),
|
doc.get("display_name"),
|
||||||
doc.get("city_short_name"),
|
doc.get("city_short_name"),
|
||||||
|
|
Loading…
Reference in New Issue