Implemented backend submission flow.

This commit is contained in:
Andrew Lalis 2023-01-25 11:55:14 +01:00
parent 6702fb564b
commit e612c084da
17 changed files with 399 additions and 161 deletions

View File

@ -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() {

View File

@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

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

View File

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

View File

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

View File

@ -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"),