Added CDN client to sample data loader, and finalized base implementation of CDN.
This commit is contained in:
parent
91648a13fa
commit
d60f7142e8
|
@ -4,7 +4,7 @@ An HTTP/REST API powered by Java and Spring Boot. This API serves as the main en
|
|||
|
||||
## Development
|
||||
|
||||
To ease development, `nl.andrewlalis.gymboard_api.model.SampleDataLoader` will run on startup and populate the database with some sample entities. You can regenerate this data by manually deleting the database, and deleting the `.sample_data` marker file that's generated in the project directory.
|
||||
To ease development, `nl.andrewlalis.gymboard_api.util.SampleDataLoader` will run on startup and populate the database with some sample entities. You can regenerate this data by manually deleting the database, and deleting the `.sample_data` marker file that's generated in the project directory.
|
||||
|
||||
## ULIDs
|
||||
|
||||
|
|
|
@ -42,6 +42,7 @@
|
|||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
package nl.andrewlalis.gymboard_api.controller;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.*;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
|
||||
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.service.GymService;
|
||||
import nl.andrewlalis.gymboard_api.service.UploadService;
|
||||
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
@ -17,14 +17,10 @@ import java.util.List;
|
|||
@RequestMapping(path = "/gyms/{compoundId}")
|
||||
public class GymController {
|
||||
private final GymService gymService;
|
||||
private final UploadService uploadService;
|
||||
private final ExerciseSubmissionService submissionService;
|
||||
|
||||
public GymController(GymService gymService,
|
||||
UploadService uploadService,
|
||||
ExerciseSubmissionService submissionService) {
|
||||
public GymController(GymService gymService, ExerciseSubmissionService submissionService) {
|
||||
this.gymService = gymService;
|
||||
this.uploadService = uploadService;
|
||||
this.submissionService = submissionService;
|
||||
}
|
||||
|
||||
|
@ -45,12 +41,4 @@ public class GymController {
|
|||
) {
|
||||
return submissionService.createSubmission(CompoundGymId.parse(compoundId), payload);
|
||||
}
|
||||
|
||||
@PostMapping(path = "/submissions/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public UploadedFileResponse uploadVideo(
|
||||
@PathVariable String compoundId,
|
||||
@RequestParam MultipartFile file
|
||||
) {
|
||||
return uploadService.handleSubmissionUpload(CompoundGymId.parse(compoundId), file);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package nl.andrewlalis.gymboard_api.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
|
||||
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
@ -21,9 +20,4 @@ public class SubmissionController {
|
|||
public ExerciseSubmissionResponse getSubmission(@PathVariable String submissionId) {
|
||||
return submissionService.getSubmission(submissionId);
|
||||
}
|
||||
|
||||
@GetMapping(path = "/{submissionId}/video")
|
||||
public void getSubmissionVideo(@PathVariable String submissionId, HttpServletResponse response) {
|
||||
submissionService.streamVideo(submissionId, response);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,5 +6,5 @@ public record ExerciseSubmissionPayload(
|
|||
float weight,
|
||||
String weightUnit,
|
||||
int reps,
|
||||
long videoId
|
||||
String videoFileId
|
||||
) {}
|
||||
|
|
|
@ -9,7 +9,7 @@ public record ExerciseSubmissionResponse(
|
|||
String createdAt,
|
||||
GymSimpleResponse gym,
|
||||
ExerciseResponse exercise,
|
||||
String status,
|
||||
String videoFileId,
|
||||
String submitterName,
|
||||
double rawWeight,
|
||||
String weightUnit,
|
||||
|
@ -22,7 +22,7 @@ public record ExerciseSubmissionResponse(
|
|||
submission.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
new GymSimpleResponse(submission.getGym()),
|
||||
new ExerciseResponse(submission.getExercise()),
|
||||
submission.getStatus().name(),
|
||||
submission.getVideoFileId(),
|
||||
submission.getSubmitterName(),
|
||||
submission.getRawWeight().doubleValue(),
|
||||
submission.getWeightUnit().name(),
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
public record UploadedFileResponse(long id) {}
|
|
@ -1,9 +0,0 @@
|
|||
package nl.andrewlalis.gymboard_api.dao;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.model.StoredFile;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
}
|
|
@ -5,9 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface ExerciseSubmissionRepository extends JpaRepository<ExerciseSubmission, String>, JpaSpecificationExecutor<ExerciseSubmission> {
|
||||
List<ExerciseSubmission> findAllByStatus(ExerciseSubmission.Status status);
|
||||
}
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
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);
|
||||
boolean existsByPath(String path);
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
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.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface ExerciseSubmissionVideoFileRepository extends JpaRepository<ExerciseSubmissionVideoFile, Long> {
|
||||
Optional<ExerciseSubmissionVideoFile> findBySubmission(ExerciseSubmission submission);
|
||||
|
||||
@Query("SELECT f FROM ExerciseSubmissionVideoFile f WHERE " +
|
||||
"f.submission.id = :submissionId AND f.submission.complete = true")
|
||||
Optional<ExerciseSubmissionVideoFile> findByCompletedSubmissionId(String submissionId);
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
package nl.andrewlalis.gymboard_api.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Base class for file storage. Files (mostly gym videos) are stored in the
|
||||
* database as blobs, after they've been pre-processed with compression and/or
|
||||
* resizing.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "stored_file")
|
||||
public class StoredFile {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@CreationTimestamp
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private String filename;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private String mimeType;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private long size;
|
||||
|
||||
@Lob
|
||||
@Column(nullable = false, updatable = false)
|
||||
private byte[] content;
|
||||
|
||||
public StoredFile() {}
|
||||
|
||||
public StoredFile(String filename, String mimeType, long size, byte[] content) {
|
||||
this.filename = filename;
|
||||
this.mimeType = mimeType;
|
||||
this.size = size;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public byte[] getContent() {
|
||||
return content;
|
||||
}
|
||||
}
|
|
@ -10,24 +10,6 @@ import java.time.LocalDateTime;
|
|||
@Entity
|
||||
@Table(name = "exercise_submission")
|
||||
public class ExerciseSubmission {
|
||||
/**
|
||||
* The status of a submission.
|
||||
* <ul>
|
||||
* <li>Each submission starts as WAITING.</li>
|
||||
* <li>The status changes to PROCESSING once it's picked up for processing.</li>
|
||||
* <li>If processing fails, the status changes to FAILED.</li>
|
||||
* <li>If processing is successful, the status changes to COMPLETED.</li>
|
||||
* <li>Once a completed submission is verified either automatically or manually, it's set to VERIFIED.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum Status {
|
||||
WAITING,
|
||||
PROCESSING,
|
||||
FAILED,
|
||||
COMPLETED,
|
||||
VERIFIED
|
||||
}
|
||||
|
||||
public enum WeightUnit {
|
||||
KG,
|
||||
LBS
|
||||
|
@ -46,9 +28,13 @@ public class ExerciseSubmission {
|
|||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
private Exercise exercise;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private Status status;
|
||||
/**
|
||||
* The id of the video file that was submitted for this submission. It lives
|
||||
* on the <em>gymboard-cdn</em> service as a stored file, which can be
|
||||
* accessed via <code>GET https://CDN-HOST/files/{videoFileId}</code>.
|
||||
*/
|
||||
@Column(nullable = false, updatable = false, length = 26)
|
||||
private String videoFileId;
|
||||
|
||||
@Column(nullable = false, updatable = false, length = 63)
|
||||
private String submitterName;
|
||||
|
@ -66,27 +52,18 @@ public class ExerciseSubmission {
|
|||
@Column(nullable = false)
|
||||
private int reps;
|
||||
|
||||
/**
|
||||
* Marker that's used to simplify queries where we just want submissions
|
||||
* that are in a status that's not WAITING, PROCESSING, or FAILED, i.e.
|
||||
* a successful submission that's been processed.
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
private boolean complete;
|
||||
|
||||
public ExerciseSubmission() {}
|
||||
|
||||
public ExerciseSubmission(String id, Gym gym, Exercise exercise, String submitterName, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, int reps) {
|
||||
public ExerciseSubmission(String id, Gym gym, Exercise exercise, String videoFileId, String submitterName, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, int reps) {
|
||||
this.id = id;
|
||||
this.gym = gym;
|
||||
this.exercise = exercise;
|
||||
this.videoFileId = videoFileId;
|
||||
this.submitterName = submitterName;
|
||||
this.rawWeight = rawWeight;
|
||||
this.weightUnit = unit;
|
||||
this.metricWeight = metricWeight;
|
||||
this.reps = reps;
|
||||
this.status = Status.WAITING;
|
||||
this.complete = false;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
|
@ -105,12 +82,8 @@ public class ExerciseSubmission {
|
|||
return exercise;
|
||||
}
|
||||
|
||||
public Status getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Status status) {
|
||||
this.status = status;
|
||||
public String getVideoFileId() {
|
||||
return videoFileId;
|
||||
}
|
||||
|
||||
public String getSubmitterName() {
|
||||
|
@ -132,12 +105,4 @@ public class ExerciseSubmission {
|
|||
public int getReps() {
|
||||
return reps;
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return complete;
|
||||
}
|
||||
|
||||
public void setComplete(boolean complete) {
|
||||
this.complete = complete;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,58 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
package nl.andrewlalis.gymboard_api.service;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.UploadedFileResponse;
|
||||
import nl.andrewlalis.gymboard_api.dao.GymRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionTempFileRepository;
|
||||
import nl.andrewlalis.gymboard_api.model.Gym;
|
||||
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.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Service for handling large file uploads.
|
||||
*/
|
||||
@Service
|
||||
public class UploadService {
|
||||
public static final Path SUBMISSION_TEMP_FILE_DIR = Path.of("exercise_submission_temp_files");
|
||||
private static final String[] ALLOWED_VIDEO_TYPES = {
|
||||
"video/mp4"
|
||||
};
|
||||
|
||||
private final ExerciseSubmissionTempFileRepository tempFileRepository;
|
||||
private final GymRepository gymRepository;
|
||||
|
||||
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 handleSubmissionUpload(CompoundGymId gymId, MultipartFile multipartFile) {
|
||||
Gym gym = gymRepository.findByCompoundId(gymId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
// TODO: Check that user is allowed to upload.
|
||||
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.");
|
||||
}
|
||||
try {
|
||||
if (!Files.exists(SUBMISSION_TEMP_FILE_DIR)) {
|
||||
Files.createDirectory(SUBMISSION_TEMP_FILE_DIR);
|
||||
}
|
||||
Path tempFilePath = Files.createTempFile(SUBMISSION_TEMP_FILE_DIR, 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package nl.andrewlalis.gymboard_api.service.cdn_client;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
|
||||
public class CdnClient {
|
||||
private final HttpClient httpClient;
|
||||
private final String baseUrl;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public final UploadsClient uploads;
|
||||
|
||||
public CdnClient(String baseUrl) {
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(3))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
this.baseUrl = baseUrl;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.uploads = new UploadsClient(this);
|
||||
}
|
||||
|
||||
public <T> T get(String urlPath, Class<T> responseType) throws IOException, InterruptedException {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
return objectMapper.readValue(response.body(), responseType);
|
||||
}
|
||||
|
||||
public <T> T postFile(String urlPath, Path filePath, String contentType, Class<T> responseType) throws IOException, InterruptedException {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
||||
.POST(HttpRequest.BodyPublishers.ofFile(filePath))
|
||||
.header("Content-Type", contentType)
|
||||
.header("X-Gymboard-Filename", filePath.getFileName().toString())
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
return objectMapper.readValue(response.body(), responseType);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package nl.andrewlalis.gymboard_api.service.cdn_client;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
public record UploadsClient(CdnClient client) {
|
||||
public record FileUploadResponse(String id) {}
|
||||
public record VideoProcessingTaskStatusResponse(String status) {}
|
||||
|
||||
public FileUploadResponse uploadVideo(Path filePath, String contentType) throws Exception {
|
||||
return client.postFile("/uploads/video", filePath, contentType, FileUploadResponse.class);
|
||||
}
|
||||
|
||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) throws Exception {
|
||||
return client.get("/uploads/video/" + id + "/status", VideoProcessingTaskStatusResponse.class);
|
||||
}
|
||||
}
|
|
@ -1,19 +1,14 @@
|
|||
package nl.andrewlalis.gymboard_api.service.submission;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
|
||||
import nl.andrewlalis.gymboard_api.dao.GymRepository;
|
||||
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.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 nl.andrewlalis.gymboard_api.util.ULID;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
@ -22,7 +17,6 @@ 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;
|
||||
|
||||
/**
|
||||
|
@ -36,21 +30,15 @@ public class ExerciseSubmissionService {
|
|||
private final GymRepository gymRepository;
|
||||
private final ExerciseRepository exerciseRepository;
|
||||
private final ExerciseSubmissionRepository exerciseSubmissionRepository;
|
||||
private final ExerciseSubmissionTempFileRepository tempFileRepository;
|
||||
private final ExerciseSubmissionVideoFileRepository submissionVideoFileRepository;
|
||||
private final ULID ulid;
|
||||
|
||||
public ExerciseSubmissionService(GymRepository gymRepository,
|
||||
ExerciseRepository exerciseRepository,
|
||||
ExerciseSubmissionRepository exerciseSubmissionRepository,
|
||||
ExerciseSubmissionTempFileRepository tempFileRepository,
|
||||
ExerciseSubmissionVideoFileRepository submissionVideoFileRepository,
|
||||
ULID ulid) {
|
||||
this.gymRepository = gymRepository;
|
||||
this.exerciseRepository = exerciseRepository;
|
||||
this.exerciseSubmissionRepository = exerciseSubmissionRepository;
|
||||
this.tempFileRepository = tempFileRepository;
|
||||
this.submissionVideoFileRepository = submissionVideoFileRepository;
|
||||
this.ulid = ulid;
|
||||
}
|
||||
|
||||
|
@ -61,33 +49,11 @@ public class ExerciseSubmissionService {
|
|||
return new ExerciseSubmissionResponse(submission);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public void streamVideo(String submissionId, HttpServletResponse response) {
|
||||
ExerciseSubmissionVideoFile videoFile = submissionVideoFileRepository.findByCompletedSubmissionId(submissionId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
response.setContentType(videoFile.getFile().getMimeType());
|
||||
response.setContentLengthLong(videoFile.getFile().getSize());
|
||||
try {
|
||||
response.getOutputStream().write(videoFile.getFile().getContent());
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to write submission video file to response.", e);
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>Save the submission. (With the WAITING status initially.)</li>
|
||||
* <li>Sometime soon, {@link SubmissionProcessingService#processWaitingSubmissions()} will pick up the submission for processing.</li>
|
||||
* </ol>
|
||||
* Once the asynchronous submission processing is complete, the submission
|
||||
* status will change to COMPLETE.
|
||||
* Handles the creation of a new exercise submission.
|
||||
* @param id The gym id.
|
||||
* @param payload The submission data.
|
||||
* @return The saved submission, which will be in the PROCESSING state at first.
|
||||
* @return The saved submission.
|
||||
*/
|
||||
@Transactional
|
||||
public ExerciseSubmissionResponse createSubmission(CompoundGymId id, ExerciseSubmissionPayload payload) {
|
||||
|
@ -95,10 +61,8 @@ public class ExerciseSubmissionService {
|
|||
.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."));
|
||||
|
||||
validateSubmission(payload, exercise, tempFile);
|
||||
// TODO: Validate the submission data.
|
||||
|
||||
// Create the submission.
|
||||
BigDecimal rawWeight = BigDecimal.valueOf(payload.weight());
|
||||
|
@ -107,26 +71,17 @@ public class ExerciseSubmissionService {
|
|||
if (unit == ExerciseSubmission.WeightUnit.LBS) {
|
||||
metricWeight = metricWeight.multiply(new BigDecimal("0.45359237"));
|
||||
}
|
||||
|
||||
ExerciseSubmission submission = exerciseSubmissionRepository.saveAndFlush(new ExerciseSubmission(
|
||||
ulid.nextULID(),
|
||||
gym,
|
||||
exercise,
|
||||
payload.videoFileId(),
|
||||
payload.name(),
|
||||
rawWeight,
|
||||
unit,
|
||||
metricWeight,
|
||||
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);
|
||||
}
|
||||
|
||||
private void validateSubmission(ExerciseSubmissionPayload payload, Exercise exercise, ExerciseSubmissionTempFile tempFile) {
|
||||
// TODO: Implement this validation.
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,237 +0,0 @@
|
|||
package nl.andrewlalis.gymboard_api.service.submission;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.dao.StoredFileRepository;
|
||||
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.StoredFile;
|
||||
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 nl.andrewlalis.gymboard_api.service.CommandFailedException;
|
||||
import nl.andrewlalis.gymboard_api.service.UploadService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* This service is responsible for the logic of processing new exercise
|
||||
* submissions and tasks immediately related to that.
|
||||
*/
|
||||
@Service
|
||||
public class SubmissionProcessingService {
|
||||
private static final Logger log = LoggerFactory.getLogger(SubmissionProcessingService.class);
|
||||
|
||||
private final ExerciseSubmissionRepository exerciseSubmissionRepository;
|
||||
private final Executor taskExecutor;
|
||||
private final ExerciseSubmissionTempFileRepository tempFileRepository;
|
||||
private final ExerciseSubmissionVideoFileRepository videoFileRepository;
|
||||
private final StoredFileRepository fileRepository;
|
||||
|
||||
public SubmissionProcessingService(ExerciseSubmissionRepository exerciseSubmissionRepository,
|
||||
Executor taskExecutor,
|
||||
ExerciseSubmissionTempFileRepository tempFileRepository,
|
||||
ExerciseSubmissionVideoFileRepository videoFileRepository,
|
||||
StoredFileRepository fileRepository) {
|
||||
this.exerciseSubmissionRepository = exerciseSubmissionRepository;
|
||||
this.taskExecutor = taskExecutor;
|
||||
this.tempFileRepository = tempFileRepository;
|
||||
this.videoFileRepository = videoFileRepository;
|
||||
this.fileRepository = fileRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
taskExecutor.execute(() -> 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.
|
||||
*/
|
||||
private void processSubmission(String 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.saveAndFlush(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.saveAndFlush(submission);
|
||||
return;
|
||||
}
|
||||
|
||||
// Now we can try to process the video file into a compressed format that can be stored in the DB.
|
||||
Path dir = UploadService.SUBMISSION_TEMP_FILE_DIR;
|
||||
String tempFileName = tempFilePath.getFileName().toString();
|
||||
String tempFileBaseName = tempFileName.substring(0, tempFileName.length() - ".tmp".length());
|
||||
Path outFilePath = dir.resolve(tempFileBaseName + "-out.mp4");
|
||||
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.saveAndFlush(submission);
|
||||
return;
|
||||
}
|
||||
|
||||
// After we've saved the processed file, we can link it to the submission, and set the submission's status.
|
||||
videoFileRepository.save(new ExerciseSubmissionVideoFile(
|
||||
submission,
|
||||
file
|
||||
));
|
||||
submission.setStatus(ExerciseSubmission.Status.COMPLETED);
|
||||
submission.setComplete(true);
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the `ffmpeg` system command to process a raw input video and produce
|
||||
* a compressed, reduced-size output video that's ready for usage in the
|
||||
* application.
|
||||
* @param dir The working directory.
|
||||
* @param inFile The input file to read from.
|
||||
* @param outFile The output file to write to. MUST have a ".mp4" extension.
|
||||
* @throws IOException If a filesystem error occurs.
|
||||
* @throws CommandFailedException If the ffmpeg command fails.
|
||||
* @throws InterruptedException If the ffmpeg command is interrupted.
|
||||
*/
|
||||
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())
|
||||
.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);
|
||||
|
||||
// Delete the logs if everything was successful.
|
||||
Files.deleteIfExists(tmpStdout);
|
||||
Files.deleteIfExists(tmpStderr);
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)
|
||||
public void removeOldUploadedFiles() {
|
||||
// First remove any temp files older than 10 minutes.
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(10);
|
||||
var tempFiles = tempFileRepository.findAllByCreatedAtBefore(cutoff);
|
||||
for (var file : tempFiles) {
|
||||
try {
|
||||
Files.deleteIfExists(Path.of(file.getPath()));
|
||||
tempFileRepository.delete(file);
|
||||
log.info("Removed temporary submission file {} at {}.", file.getId(), file.getPath());
|
||||
} catch (IOException e) {
|
||||
log.error(String.format("Could not delete submission temp file %d at %s.", file.getId(), file.getPath()), e);
|
||||
}
|
||||
}
|
||||
|
||||
// Then remove any files in the directory which don't correspond to a valid file in the db.
|
||||
if (Files.notExists(UploadService.SUBMISSION_TEMP_FILE_DIR)) return;
|
||||
try (var s = Files.list(UploadService.SUBMISSION_TEMP_FILE_DIR)) {
|
||||
for (var path : s.toList()) {
|
||||
if (!tempFileRepository.existsByPath(path.toString())) {
|
||||
try {
|
||||
Files.delete(path);
|
||||
} catch (IOException e) {
|
||||
log.error("Couldn't delete orphan temp file: " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Couldn't get list of temp files.", e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package nl.andrewlalis.gymboard_api.model;
|
||||
package nl.andrewlalis.gymboard_api.util;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload;
|
||||
|
@ -9,20 +9,24 @@ import nl.andrewlalis.gymboard_api.dao.GymRepository;
|
|||
import nl.andrewlalis.gymboard_api.dao.auth.RoleRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
|
||||
import nl.andrewlalis.gymboard_api.model.City;
|
||||
import nl.andrewlalis.gymboard_api.model.Country;
|
||||
import nl.andrewlalis.gymboard_api.model.GeoPoint;
|
||||
import nl.andrewlalis.gymboard_api.model.Gym;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.Role;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.User;
|
||||
import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
|
||||
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
|
||||
import nl.andrewlalis.gymboard_api.service.UploadService;
|
||||
import nl.andrewlalis.gymboard_api.service.auth.UserService;
|
||||
import nl.andrewlalis.gymboard_api.service.cdn_client.CdnClient;
|
||||
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
@ -31,7 +35,8 @@ import java.io.IOException;
|
|||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Simple component that loads sample data that's useful when testing the application.
|
||||
|
@ -44,25 +49,25 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
|||
private final GymRepository gymRepository;
|
||||
private final ExerciseRepository exerciseRepository;
|
||||
private final ExerciseSubmissionService submissionService;
|
||||
private final UploadService uploadService;
|
||||
private final RoleRepository roleRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
|
||||
@Value("${app.cdn-origin}")
|
||||
private String cdnOrigin;
|
||||
|
||||
public SampleDataLoader(
|
||||
CountryRepository countryRepository,
|
||||
CityRepository cityRepository,
|
||||
GymRepository gymRepository,
|
||||
ExerciseRepository exerciseRepository,
|
||||
ExerciseSubmissionService submissionService,
|
||||
UploadService uploadService,
|
||||
RoleRepository roleRepository, UserRepository userRepository, UserService userService) {
|
||||
this.countryRepository = countryRepository;
|
||||
this.cityRepository = cityRepository;
|
||||
this.gymRepository = gymRepository;
|
||||
this.exerciseRepository = exerciseRepository;
|
||||
this.submissionService = submissionService;
|
||||
this.uploadService = uploadService;
|
||||
this.roleRepository = roleRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.userService = userService;
|
||||
|
@ -77,13 +82,13 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
|||
try {
|
||||
generateSampleData();
|
||||
Files.writeString(markerFile, "Yes");
|
||||
} catch (IOException e) {
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void generateSampleData() throws IOException {
|
||||
protected void generateSampleData() throws Exception {
|
||||
loadCsv("exercises", record -> {
|
||||
exerciseRepository.save(new Exercise(record.get(0), record.get(1)));
|
||||
});
|
||||
|
@ -108,6 +113,13 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
|||
record.get(7)
|
||||
));
|
||||
});
|
||||
|
||||
// Loading sample submissions involves sending content to the Gymboard CDN service.
|
||||
// We upload a video for each submission, and wait until all uploads are processed before continuing.
|
||||
|
||||
final CdnClient cdnClient = new CdnClient(cdnOrigin);
|
||||
final Set<String> videoIds = new HashSet<>();
|
||||
|
||||
loadCsv("submissions", record -> {
|
||||
var exercise = exerciseRepository.findById(record.get(0)).orElseThrow();
|
||||
BigDecimal weight = new BigDecimal(record.get(1));
|
||||
|
@ -117,26 +129,34 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
|||
CompoundGymId gymId = CompoundGymId.parse(record.get(5));
|
||||
String videoFilename = record.get(6);
|
||||
|
||||
try {
|
||||
var uploadResp = uploadService.handleSubmissionUpload(gymId, new MockMultipartFile(
|
||||
videoFilename,
|
||||
videoFilename,
|
||||
"video/mp4",
|
||||
Files.readAllBytes(Path.of("sample_data", videoFilename))
|
||||
));
|
||||
// Upload the video to the CDN, and wait until it's done processing.
|
||||
log.info("Uploading video {} to CDN...", videoFilename);
|
||||
var video = cdnClient.uploads.uploadVideo(Path.of("sample_data", videoFilename), "video/mp4");
|
||||
submissionService.createSubmission(gymId, new ExerciseSubmissionPayload(
|
||||
name,
|
||||
exercise.getShortName(),
|
||||
weight.floatValue(),
|
||||
unit.name(),
|
||||
reps,
|
||||
uploadResp.id()
|
||||
video.id()
|
||||
));
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
videoIds.add(video.id());
|
||||
});
|
||||
|
||||
int count = videoIds.size();
|
||||
while (!videoIds.isEmpty()) {
|
||||
log.info("Waiting for {} / {} videos to finish processing...", videoIds.size(), count);
|
||||
Set<String> removalSet = new HashSet<>();
|
||||
for (var videoId : videoIds) {
|
||||
String status = cdnClient.uploads.getVideoProcessingStatus(videoId).status();
|
||||
if (status.equalsIgnoreCase("COMPLETED") || status.equalsIgnoreCase("FAILED")) {
|
||||
removalSet.add(videoId);
|
||||
}
|
||||
}
|
||||
videoIds.removeAll(removalSet);
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
|
||||
loadCsv("users", record -> {
|
||||
String email = record.get(0);
|
||||
String password = record.get(1);
|
||||
|
@ -156,12 +176,21 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
|||
});
|
||||
}
|
||||
|
||||
private void loadCsv(String csvName, Consumer<CSVRecord> recordConsumer) throws IOException {
|
||||
@FunctionalInterface
|
||||
interface ThrowableConsumer<T> {
|
||||
void accept(T item) throws Exception;
|
||||
}
|
||||
|
||||
private void loadCsv(String csvName, ThrowableConsumer<CSVRecord> recordConsumer) throws IOException {
|
||||
String path = "sample_data/" + csvName + ".csv";
|
||||
log.info("Loading data from {}...", path);
|
||||
var reader = new FileReader(path);
|
||||
for (var record : CSVFormat.DEFAULT.parse(reader)) {
|
||||
try {
|
||||
recordConsumer.accept(record);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,3 +16,4 @@ spring.mail.properties.mail.smtp.timeout=10000
|
|||
|
||||
app.auth.private-key-location=./private_key.der
|
||||
app.web-origin=http://localhost:9000
|
||||
app.cdn-origin=http://localhost:8082
|
||||
|
|
|
@ -31,3 +31,5 @@ build/
|
|||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
cdn-files/
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package nl.andrewlalis.gymboardcdn;
|
||||
|
||||
import nl.andrewlalis.gymboardcdn.util.ULID;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
@ -14,6 +15,8 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
|||
public class Config {
|
||||
@Value("${app.web-origin}")
|
||||
private String webOrigin;
|
||||
@Value("${app.api-origin}")
|
||||
private String apiOrigin;
|
||||
|
||||
/**
|
||||
* Defines the CORS configuration for this API, which is to say that we
|
||||
|
@ -27,11 +30,16 @@ public class Config {
|
|||
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
final CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowCredentials(true);
|
||||
// Don't do this in production, use a proper list of allowed origins
|
||||
config.addAllowedOriginPattern(webOrigin);
|
||||
config.addAllowedOriginPattern(apiOrigin);
|
||||
config.addAllowedHeader("*");
|
||||
config.addAllowedMethod("*");
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ULID ulid() {
|
||||
return new ULID();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
package nl.andrewlalis.gymboardcdn.api;
|
||||
|
||||
public record FileMetadataResponse(
|
||||
String filename,
|
||||
String mimeType,
|
||||
long size,
|
||||
String uploadedAt,
|
||||
boolean availableForDownload
|
||||
) {}
|
|
@ -1,5 +1,5 @@
|
|||
package nl.andrewlalis.gymboardcdn.api;
|
||||
|
||||
public record FileUploadResponse(
|
||||
String identifier
|
||||
String id
|
||||
) {}
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
package nl.andrewlalis.gymboardcdn.api;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import nl.andrewlalis.gymboardcdn.service.UploadService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class UploadController {
|
||||
|
@ -13,13 +16,23 @@ public class UploadController {
|
|||
this.uploadService = uploadService;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/uploads/video", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public FileUploadResponse uploadVideo(@RequestParam MultipartFile file) {
|
||||
return uploadService.processableVideoUpload(file);
|
||||
@PostMapping(path = "/uploads/video", consumes = {"video/mp4"})
|
||||
public FileUploadResponse uploadVideo(HttpServletRequest request) {
|
||||
return uploadService.processableVideoUpload(request);
|
||||
}
|
||||
|
||||
@GetMapping(path = "/uploads/video/{identifier}/status")
|
||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String identifier) {
|
||||
return uploadService.getVideoProcessingStatus(identifier);
|
||||
@GetMapping(path = "/uploads/video/{id}/status")
|
||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String id) {
|
||||
return uploadService.getVideoProcessingStatus(id);
|
||||
}
|
||||
|
||||
@GetMapping(path = "/files/{id}")
|
||||
public void getFile(@PathVariable String id, HttpServletResponse response) {
|
||||
uploadService.streamFile(id, response);
|
||||
}
|
||||
|
||||
@GetMapping(path = "/files/{id}/metadata")
|
||||
public FileMetadataResponse getFileMetadata(@PathVariable String id) {
|
||||
return uploadService.getFileMetadata(id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
package nl.andrewlalis.gymboardcdn.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
@ -8,9 +11,12 @@ import java.time.LocalDateTime;
|
|||
@Entity
|
||||
@Table(name = "stored_file")
|
||||
public class StoredFile {
|
||||
/**
|
||||
* ULID-based unique file identifier.
|
||||
*/
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
@Column(nullable = false, updatable = false, length = 26)
|
||||
private String id;
|
||||
|
||||
@CreationTimestamp
|
||||
private LocalDateTime createdAt;
|
||||
|
@ -27,13 +33,6 @@ public class StoredFile {
|
|||
@Column(nullable = false, updatable = false)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* The internal id that's used to find this file wherever it's placed on
|
||||
* our service's storage. It is universally unique.
|
||||
*/
|
||||
@Column(nullable = false, updatable = false, unique = true)
|
||||
private String identifier;
|
||||
|
||||
/**
|
||||
* The type of the file.
|
||||
*/
|
||||
|
@ -48,15 +47,15 @@ public class StoredFile {
|
|||
|
||||
public StoredFile() {}
|
||||
|
||||
public StoredFile(String name, String identifier, String mimeType, long size, LocalDateTime uploadedAt) {
|
||||
public StoredFile(String id, String name, String mimeType, long size, LocalDateTime uploadedAt) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.identifier = identifier;
|
||||
this.mimeType = mimeType;
|
||||
this.size = size;
|
||||
this.uploadedAt = uploadedAt;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
@ -68,10 +67,6 @@ public class StoredFile {
|
|||
return name;
|
||||
}
|
||||
|
||||
public String getIdentifier() {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
|
|
@ -3,10 +3,6 @@ package nl.andrewlalis.gymboardcdn.model;
|
|||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
Optional<StoredFile> findByIdentifier(String identifier);
|
||||
boolean existsByIdentifier(String identifier);
|
||||
public interface StoredFileRepository extends JpaRepository<StoredFile, String> {
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ public class VideoProcessingTask {
|
|||
* The identifier that will be used to identify the final video, if it
|
||||
* is processed successfully.
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
@Column(nullable = false, updatable = false, length = 26)
|
||||
private String videoIdentifier;
|
||||
|
||||
public VideoProcessingTask() {}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package nl.andrewlalis.gymboardcdn.service;
|
||||
|
||||
import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
|
||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
|
||||
import nl.andrewlalis.gymboardcdn.model.StoredFile;
|
||||
import nl.andrewlalis.gymboardcdn.util.ULID;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
@ -13,7 +13,6 @@ import java.io.InputStream;
|
|||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* The service that manages storing and retrieving files from a base filesystem.
|
||||
|
@ -28,52 +27,29 @@ public class FileService {
|
|||
@Value("${app.files.temp-dir}")
|
||||
private String tempDir;
|
||||
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final VideoProcessingTaskRepository videoProcessingTaskRepository;
|
||||
private final ULID ulid;
|
||||
|
||||
public FileService(StoredFileRepository storedFileRepository, VideoProcessingTaskRepository videoProcessingTaskRepository) {
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.videoProcessingTaskRepository = videoProcessingTaskRepository;
|
||||
public FileService(ULID ulid) {
|
||||
this.ulid = ulid;
|
||||
}
|
||||
|
||||
public Path getStorageDirForTime(LocalDateTime time) throws IOException {
|
||||
Path dir = getStorageDir()
|
||||
public Path getStoragePathForFile(StoredFile file) throws IOException {
|
||||
LocalDateTime time = file.getUploadedAt();
|
||||
Path dir = Path.of(storageDir)
|
||||
.resolve(Integer.toString(time.getYear()))
|
||||
.resolve(Integer.toString(time.getMonthValue()))
|
||||
.resolve(Integer.toString(time.getDayOfMonth()));
|
||||
if (Files.notExists(dir)) Files.createDirectories(dir);
|
||||
return dir;
|
||||
return dir.resolve(file.getId());
|
||||
}
|
||||
|
||||
public String createNewFileIdentifier() {
|
||||
String ident = generateRandomIdentifier();
|
||||
int attempts = 0;
|
||||
while (storedFileRepository.existsByIdentifier(ident) || videoProcessingTaskRepository.existsByVideoIdentifier(ident)) {
|
||||
ident = generateRandomIdentifier();
|
||||
attempts++;
|
||||
if (attempts > 10) {
|
||||
log.warn("Took more than 10 attempts to generate a unique file identifier.");
|
||||
}
|
||||
if (attempts > 100) {
|
||||
log.error("Couldn't generate a unique file identifier after 100 attempts. Quitting!");
|
||||
throw new RuntimeException("Couldn't generate a unique file identifier.");
|
||||
}
|
||||
}
|
||||
return ident;
|
||||
return ulid.nextULID();
|
||||
}
|
||||
|
||||
private String generateRandomIdentifier() {
|
||||
StringBuilder sb = new StringBuilder(9);
|
||||
String alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
Random rand = new Random();
|
||||
for (int i = 0; i < 9; i++) sb.append(alphabet.charAt(rand.nextInt(alphabet.length())));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public Path saveToTempFile(MultipartFile file) throws IOException {
|
||||
public Path saveToTempFile(InputStream in, String filename) throws IOException {
|
||||
Path tempDir = getTempDir();
|
||||
String suffix = null;
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename != null) {
|
||||
int idx = filename.lastIndexOf('.');
|
||||
if (idx >= 0) {
|
||||
|
@ -81,12 +57,10 @@ public class FileService {
|
|||
}
|
||||
}
|
||||
Path tempFile = Files.createTempFile(tempDir, null, suffix);
|
||||
file.transferTo(tempFile);
|
||||
return tempFile;
|
||||
try (var out = Files.newOutputStream(tempFile)) {
|
||||
in.transferTo(out);
|
||||
}
|
||||
|
||||
public Path saveToStorage(String filename, InputStream in) throws IOException {
|
||||
throw new RuntimeException("Not implemented!");
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
private Path getStorageDir() throws IOException {
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
package nl.andrewlalis.gymboardcdn.service;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import nl.andrewlalis.gymboardcdn.api.FileMetadataResponse;
|
||||
import nl.andrewlalis.gymboardcdn.api.FileUploadResponse;
|
||||
import nl.andrewlalis.gymboardcdn.api.VideoProcessingTaskStatusResponse;
|
||||
import nl.andrewlalis.gymboardcdn.model.StoredFile;
|
||||
import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
|
||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask;
|
||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
|
||||
|
@ -14,7 +18,9 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Service
|
||||
public class UploadService {
|
||||
|
@ -32,11 +38,20 @@ public class UploadService {
|
|||
this.fileService = fileService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles uploading of a processable video file that will be processed
|
||||
* before being stored in the system.
|
||||
* @param request The request from which we can read the file.
|
||||
* @return A response that contains an identifier that can be used to check
|
||||
* the status of the video processing, and eventually fetch the video.
|
||||
*/
|
||||
@Transactional
|
||||
public FileUploadResponse processableVideoUpload(MultipartFile file) {
|
||||
public FileUploadResponse processableVideoUpload(HttpServletRequest request) {
|
||||
Path tempFile;
|
||||
String filename = request.getHeader("X-Gymboard-Filename");
|
||||
if (filename == null) filename = "unnamed.mp4";
|
||||
try {
|
||||
tempFile = fileService.saveToTempFile(file);
|
||||
tempFile = fileService.saveToTempFile(request.getInputStream(), filename);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to save video upload to temp file.", e);
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
|
@ -44,17 +59,64 @@ public class UploadService {
|
|||
String identifier = fileService.createNewFileIdentifier();
|
||||
videoTaskRepository.save(new VideoProcessingTask(
|
||||
VideoProcessingTask.Status.WAITING,
|
||||
file.getOriginalFilename(),
|
||||
filename,
|
||||
tempFile.toString(),
|
||||
identifier
|
||||
));
|
||||
return new FileUploadResponse(identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status of a video processing task.
|
||||
* @param id The video identifier.
|
||||
* @return The status of the video processing task.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String identifier) {
|
||||
VideoProcessingTask task = videoTaskRepository.findByVideoIdentifier(identifier)
|
||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) {
|
||||
VideoProcessingTask task = videoTaskRepository.findByVideoIdentifier(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
return new VideoProcessingTaskStatusResponse(task.getStatus().name());
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the contents of a stored file to a client via the Http response.
|
||||
* @param id The file's unique identifier.
|
||||
* @param response The response to stream the content to.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public void streamFile(String id, HttpServletResponse response) {
|
||||
StoredFile file = storedFileRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
response.setContentType(file.getMimeType());
|
||||
response.setContentLengthLong(file.getSize());
|
||||
try {
|
||||
Path filePath = fileService.getStoragePathForFile(file);
|
||||
try (var in = Files.newInputStream(filePath)) {
|
||||
in.transferTo(response.getOutputStream());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to write file to response.", e);
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public FileMetadataResponse getFileMetadata(String id) {
|
||||
StoredFile file = storedFileRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
try {
|
||||
Path filePath = fileService.getStoragePathForFile(file);
|
||||
boolean exists = Files.exists(filePath);
|
||||
return new FileMetadataResponse(
|
||||
file.getName(),
|
||||
file.getMimeType(),
|
||||
file.getSize(),
|
||||
file.getUploadedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
exists
|
||||
);
|
||||
} catch (IOException e) {
|
||||
log.error("Couldn't get path to stored file.", e);
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@ import java.nio.file.Files;
|
|||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
@ -77,21 +76,21 @@ public class VideoProcessingService {
|
|||
}
|
||||
|
||||
// And finally, copy the output to the final location.
|
||||
LocalDateTime uploadedAt = task.getCreatedAt();
|
||||
try {
|
||||
Path finalFilePath = fileService.getStorageDirForTime(uploadedAt)
|
||||
.resolve(task.getVideoIdentifier());
|
||||
StoredFile storedFile = new StoredFile(
|
||||
task.getVideoIdentifier(),
|
||||
task.getFilename(),
|
||||
"video/mp4",
|
||||
Files.size(ffmpegOutputFile),
|
||||
task.getCreatedAt()
|
||||
);
|
||||
Path finalFilePath = fileService.getStoragePathForFile(storedFile);
|
||||
Files.move(ffmpegOutputFile, finalFilePath);
|
||||
Files.deleteIfExists(tempFile);
|
||||
Files.deleteIfExists(ffmpegOutputFile);
|
||||
storedFileRepository.saveAndFlush(new StoredFile(
|
||||
task.getFilename(),
|
||||
task.getVideoIdentifier(),
|
||||
"video/mp4",
|
||||
Files.size(ffmpegOutputFile),
|
||||
uploadedAt
|
||||
));
|
||||
storedFileRepository.saveAndFlush(storedFile);
|
||||
updateTask(task, VideoProcessingTask.Status.COMPLETED);
|
||||
log.info("Finished processing video {}.", task.getVideoIdentifier());
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to copy processed video to final storage location.", e);
|
||||
updateTask(task, VideoProcessingTask.Status.FAILED);
|
||||
|
@ -113,7 +112,8 @@ public class VideoProcessingService {
|
|||
Path tmpStdout = Files.createTempFile(dir, "stdout-", ".log");
|
||||
Path tmpStderr = Files.createTempFile(dir, "stderr-", ".log");
|
||||
final String[] command = {
|
||||
"ffmpeg", "-i", inFile.getFileName().toString(),
|
||||
"ffmpeg",
|
||||
"-i", inFile.getFileName().toString(),
|
||||
"-vf", "scale=640x480:flags=lanczos",
|
||||
"-vcodec", "libx264",
|
||||
"-crf", "28",
|
||||
|
|
|
@ -0,0 +1,456 @@
|
|||
package nl.andrewlalis.gymboardcdn.util;
|
||||
|
||||
/*
|
||||
* sulky-modules - several general-purpose modules.
|
||||
* Copyright (C) 2007-2019 Joern Huxhorn
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright 2007-2019 Joern Huxhorn
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
|
||||
/*
|
||||
* https://github.com/ulid/spec
|
||||
*/
|
||||
@SuppressWarnings("PMD.ShortClassName")
|
||||
public class ULID
|
||||
{
|
||||
private static final char[] ENCODING_CHARS = {
|
||||
'0','1','2','3','4','5','6','7','8','9',
|
||||
'A','B','C','D','E','F','G','H','J','K',
|
||||
'M','N','P','Q','R','S','T','V','W','X',
|
||||
'Y','Z',
|
||||
};
|
||||
|
||||
private static final byte[] DECODING_CHARS = {
|
||||
// 0
|
||||
-1, -1, -1, -1, -1, -1, -1, -1,
|
||||
// 8
|
||||
-1, -1, -1, -1, -1, -1, -1, -1,
|
||||
// 16
|
||||
-1, -1, -1, -1, -1, -1, -1, -1,
|
||||
// 24
|
||||
-1, -1, -1, -1, -1, -1, -1, -1,
|
||||
// 32
|
||||
-1, -1, -1, -1, -1, -1, -1, -1,
|
||||
// 40
|
||||
-1, -1, -1, -1, -1, -1, -1, -1,
|
||||
// 48
|
||||
0, 1, 2, 3, 4, 5, 6, 7,
|
||||
// 56
|
||||
8, 9, -1, -1, -1, -1, -1, -1,
|
||||
// 64
|
||||
-1, 10, 11, 12, 13, 14, 15, 16,
|
||||
// 72
|
||||
17, 1, 18, 19, 1, 20, 21, 0,
|
||||
// 80
|
||||
22, 23, 24, 25, 26, -1, 27, 28,
|
||||
// 88
|
||||
29, 30, 31, -1, -1, -1, -1, -1,
|
||||
// 96
|
||||
-1, 10, 11, 12, 13, 14, 15, 16,
|
||||
// 104
|
||||
17, 1, 18, 19, 1, 20, 21, 0,
|
||||
// 112
|
||||
22, 23, 24, 25, 26, -1, 27, 28,
|
||||
// 120
|
||||
29, 30, 31,
|
||||
};
|
||||
|
||||
private static final int MASK = 0x1F;
|
||||
private static final int MASK_BITS = 5;
|
||||
private static final long TIMESTAMP_OVERFLOW_MASK = 0xFFFF_0000_0000_0000L;
|
||||
private static final long TIMESTAMP_MSB_MASK = 0xFFFF_FFFF_FFFF_0000L;
|
||||
private static final long RANDOM_MSB_MASK = 0xFFFFL;
|
||||
|
||||
private final Random random;
|
||||
|
||||
public ULID()
|
||||
{
|
||||
this(new SecureRandom());
|
||||
}
|
||||
|
||||
public ULID(Random random)
|
||||
{
|
||||
Objects.requireNonNull(random, "random must not be null!");
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
public void appendULID(StringBuilder stringBuilder)
|
||||
{
|
||||
Objects.requireNonNull(stringBuilder, "stringBuilder must not be null!");
|
||||
internalAppendULID(stringBuilder, System.currentTimeMillis(), random);
|
||||
}
|
||||
|
||||
public String nextULID()
|
||||
{
|
||||
return nextULID(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public String nextULID(long timestamp)
|
||||
{
|
||||
return internalUIDString(timestamp, random);
|
||||
}
|
||||
|
||||
public Value nextValue()
|
||||
{
|
||||
return nextValue(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public Value nextValue(long timestamp)
|
||||
{
|
||||
return internalNextValue(timestamp, random);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next monotonic value. If an overflow happened while incrementing
|
||||
* the random part of the given previous ULID value then the returned value will
|
||||
* have a zero random part.
|
||||
*
|
||||
* @param previousUlid the previous ULID value.
|
||||
* @return the next monotonic value.
|
||||
*/
|
||||
public Value nextMonotonicValue(Value previousUlid)
|
||||
{
|
||||
return nextMonotonicValue(previousUlid, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next monotonic value. If an overflow happened while incrementing
|
||||
* the random part of the given previous ULID value then the returned value will
|
||||
* have a zero random part.
|
||||
*
|
||||
* @param previousUlid the previous ULID value.
|
||||
* @param timestamp the timestamp of the next ULID value.
|
||||
* @return the next monotonic value.
|
||||
*/
|
||||
public Value nextMonotonicValue(Value previousUlid, long timestamp)
|
||||
{
|
||||
Objects.requireNonNull(previousUlid, "previousUlid must not be null!");
|
||||
if(previousUlid.timestamp() == timestamp)
|
||||
{
|
||||
return previousUlid.increment();
|
||||
}
|
||||
return nextValue(timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next monotonic value or empty if an overflow happened while incrementing
|
||||
* the random part of the given previous ULID value.
|
||||
*
|
||||
* @param previousUlid the previous ULID value.
|
||||
* @return the next monotonic value or empty if an overflow happened.
|
||||
*/
|
||||
public Optional<Value> nextStrictlyMonotonicValue(Value previousUlid)
|
||||
{
|
||||
return nextStrictlyMonotonicValue(previousUlid, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next monotonic value or empty if an overflow happened while incrementing
|
||||
* the random part of the given previous ULID value.
|
||||
*
|
||||
* @param previousUlid the previous ULID value.
|
||||
* @param timestamp the timestamp of the next ULID value.
|
||||
* @return the next monotonic value or empty if an overflow happened.
|
||||
*/
|
||||
public Optional<Value> nextStrictlyMonotonicValue(Value previousUlid, long timestamp)
|
||||
{
|
||||
Value result = nextMonotonicValue(previousUlid, timestamp);
|
||||
if(result.compareTo(previousUlid) < 1)
|
||||
{
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(result);
|
||||
}
|
||||
|
||||
public static Value parseULID(String ulidString)
|
||||
{
|
||||
Objects.requireNonNull(ulidString, "ulidString must not be null!");
|
||||
if(ulidString.length() != 26)
|
||||
{
|
||||
throw new IllegalArgumentException("ulidString must be exactly 26 chars long.");
|
||||
}
|
||||
|
||||
String timeString = ulidString.substring(0, 10);
|
||||
long time = internalParseCrockford(timeString);
|
||||
if ((time & TIMESTAMP_OVERFLOW_MASK) != 0)
|
||||
{
|
||||
throw new IllegalArgumentException("ulidString must not exceed '7ZZZZZZZZZZZZZZZZZZZZZZZZZ'!");
|
||||
}
|
||||
String part1String = ulidString.substring(10, 18);
|
||||
String part2String = ulidString.substring(18);
|
||||
long part1 = internalParseCrockford(part1String);
|
||||
long part2 = internalParseCrockford(part2String);
|
||||
|
||||
long most = (time << 16) | (part1 >>> 24);
|
||||
long least = part2 | (part1 << 40);
|
||||
return new Value(most, least);
|
||||
}
|
||||
|
||||
public static Value fromBytes(byte[] data)
|
||||
{
|
||||
Objects.requireNonNull(data, "data must not be null!");
|
||||
if(data.length != 16)
|
||||
{
|
||||
throw new IllegalArgumentException("data must be 16 bytes in length!");
|
||||
}
|
||||
long mostSignificantBits = 0;
|
||||
long leastSignificantBits = 0;
|
||||
for (int i=0; i<8; i++)
|
||||
{
|
||||
mostSignificantBits = (mostSignificantBits << 8) | (data[i] & 0xff);
|
||||
}
|
||||
for (int i=8; i<16; i++)
|
||||
{
|
||||
leastSignificantBits = (leastSignificantBits << 8) | (data[i] & 0xff);
|
||||
}
|
||||
return new Value(mostSignificantBits, leastSignificantBits);
|
||||
}
|
||||
|
||||
public static class Value
|
||||
implements Comparable<Value>, Serializable
|
||||
{
|
||||
private static final long serialVersionUID = -3563159514112487717L;
|
||||
|
||||
/*
|
||||
* The most significant 64 bits of this ULID.
|
||||
*/
|
||||
private final long mostSignificantBits;
|
||||
|
||||
/*
|
||||
* The least significant 64 bits of this ULID.
|
||||
*/
|
||||
private final long leastSignificantBits;
|
||||
|
||||
public Value(long mostSignificantBits, long leastSignificantBits)
|
||||
{
|
||||
this.mostSignificantBits = mostSignificantBits;
|
||||
this.leastSignificantBits = leastSignificantBits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most significant 64 bits of this ULID's 128 bit value.
|
||||
*
|
||||
* @return The most significant 64 bits of this ULID's 128 bit value
|
||||
*/
|
||||
public long getMostSignificantBits() {
|
||||
return mostSignificantBits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the least significant 64 bits of this ULID's 128 bit value.
|
||||
*
|
||||
* @return The least significant 64 bits of this ULID's 128 bit value
|
||||
*/
|
||||
public long getLeastSignificantBits() {
|
||||
return leastSignificantBits;
|
||||
}
|
||||
|
||||
|
||||
public long timestamp()
|
||||
{
|
||||
return mostSignificantBits >>> 16;
|
||||
}
|
||||
|
||||
public byte[] toBytes()
|
||||
{
|
||||
byte[] result=new byte[16];
|
||||
for (int i=0; i<8; i++)
|
||||
{
|
||||
result[i] = (byte)((mostSignificantBits >> ((7-i)*8)) & 0xFF);
|
||||
}
|
||||
for (int i=8; i<16; i++)
|
||||
{
|
||||
result[i] = (byte)((leastSignificantBits >> ((15-i)*8)) & 0xFF);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Value increment()
|
||||
{
|
||||
long lsb = leastSignificantBits;
|
||||
if(lsb != 0xFFFF_FFFF_FFFF_FFFFL)
|
||||
{
|
||||
return new Value(mostSignificantBits, lsb+1);
|
||||
}
|
||||
long msb = mostSignificantBits;
|
||||
if((msb & RANDOM_MSB_MASK) != RANDOM_MSB_MASK)
|
||||
{
|
||||
return new Value(msb + 1, 0);
|
||||
}
|
||||
return new Value(msb & TIMESTAMP_MSB_MASK, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
long hilo = mostSignificantBits ^ leastSignificantBits;
|
||||
return ((int)(hilo >> 32)) ^ (int) hilo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o)
|
||||
{
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
Value value = (Value) o;
|
||||
|
||||
return mostSignificantBits == value.mostSignificantBits
|
||||
&& leastSignificantBits == value.leastSignificantBits;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(Value val)
|
||||
{
|
||||
// The ordering is intentionally set up so that the ULIDs
|
||||
// can simply be numerically compared as two numbers
|
||||
return (this.mostSignificantBits < val.mostSignificantBits ? -1 :
|
||||
(this.mostSignificantBits > val.mostSignificantBits ? 1 :
|
||||
(this.leastSignificantBits < val.leastSignificantBits ? -1 :
|
||||
(this.leastSignificantBits > val.leastSignificantBits ? 1 :
|
||||
0))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
char[] buffer = new char[26];
|
||||
|
||||
internalWriteCrockford(buffer, timestamp(), 10, 0);
|
||||
long value = ((mostSignificantBits & 0xFFFFL) << 24);
|
||||
long interim = (leastSignificantBits >>> 40);
|
||||
value = value | interim;
|
||||
internalWriteCrockford(buffer, value, 8, 10);
|
||||
internalWriteCrockford(buffer, leastSignificantBits, 8, 18);
|
||||
|
||||
return new String(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* http://crockford.com/wrmg/base32.html
|
||||
*/
|
||||
static void internalAppendCrockford(StringBuilder builder, long value, int count)
|
||||
{
|
||||
for(int i = count-1; i >= 0; i--)
|
||||
{
|
||||
int index = (int)((value >>> (i * MASK_BITS)) & MASK);
|
||||
builder.append(ENCODING_CHARS[index]);
|
||||
}
|
||||
}
|
||||
|
||||
static long internalParseCrockford(String input)
|
||||
{
|
||||
Objects.requireNonNull(input, "input must not be null!");
|
||||
int length = input.length();
|
||||
if(length > 12)
|
||||
{
|
||||
throw new IllegalArgumentException("input length must not exceed 12 but was "+length+"!");
|
||||
}
|
||||
|
||||
long result = 0;
|
||||
for(int i=0;i<length;i++)
|
||||
{
|
||||
char current = input.charAt(i);
|
||||
byte value = -1;
|
||||
if(current < DECODING_CHARS.length)
|
||||
{
|
||||
value = DECODING_CHARS[current];
|
||||
}
|
||||
if(value < 0)
|
||||
{
|
||||
throw new IllegalArgumentException("Illegal character '"+current+"'!");
|
||||
}
|
||||
result |= ((long)value) << ((length - 1 - i)*MASK_BITS);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* http://crockford.com/wrmg/base32.html
|
||||
*/
|
||||
static void internalWriteCrockford(char[] buffer, long value, int count, int offset)
|
||||
{
|
||||
for(int i = 0; i < count; i++)
|
||||
{
|
||||
int index = (int)((value >>> ((count - i - 1) * MASK_BITS)) & MASK);
|
||||
buffer[offset+i] = ENCODING_CHARS[index];
|
||||
}
|
||||
}
|
||||
|
||||
static String internalUIDString(long timestamp, Random random)
|
||||
{
|
||||
checkTimestamp(timestamp);
|
||||
|
||||
char[] buffer = new char[26];
|
||||
|
||||
internalWriteCrockford(buffer, timestamp, 10, 0);
|
||||
// could use nextBytes(byte[] bytes) instead
|
||||
internalWriteCrockford(buffer, random.nextLong(), 8, 10);
|
||||
internalWriteCrockford(buffer, random.nextLong(), 8, 18);
|
||||
|
||||
return new String(buffer);
|
||||
}
|
||||
|
||||
static void internalAppendULID(StringBuilder builder, long timestamp, Random random)
|
||||
{
|
||||
checkTimestamp(timestamp);
|
||||
|
||||
internalAppendCrockford(builder, timestamp, 10);
|
||||
// could use nextBytes(byte[] bytes) instead
|
||||
internalAppendCrockford(builder, random.nextLong(), 8);
|
||||
internalAppendCrockford(builder, random.nextLong(), 8);
|
||||
}
|
||||
|
||||
static Value internalNextValue(long timestamp, Random random)
|
||||
{
|
||||
checkTimestamp(timestamp);
|
||||
// could use nextBytes(byte[] bytes) instead
|
||||
long mostSignificantBits = random.nextLong();
|
||||
long leastSignificantBits = random.nextLong();
|
||||
mostSignificantBits &= 0xFFFF;
|
||||
mostSignificantBits |= (timestamp << 16);
|
||||
return new Value(mostSignificantBits, leastSignificantBits);
|
||||
}
|
||||
|
||||
private static void checkTimestamp(long timestamp)
|
||||
{
|
||||
if((timestamp & TIMESTAMP_OVERFLOW_MASK) != 0)
|
||||
{
|
||||
throw new IllegalArgumentException("ULID does not support timestamps after +10889-08-02T05:31:50.655Z!");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,5 +7,6 @@ spring.jpa.hibernate.ddl-auto=update
|
|||
server.port=8082
|
||||
|
||||
app.web-origin=http://localhost:9000
|
||||
app.api-origin=http://localhost:8080
|
||||
app.files.storage-dir=./cdn-files/
|
||||
app.files.temp-dir=./cdn-files/tmp/
|
||||
|
|
|
@ -12,5 +12,6 @@ spring.jpa.hibernate.ddl-auto=update
|
|||
server.port=8082
|
||||
|
||||
app.web-origin=http://localhost:9000
|
||||
app.api-origin=http://localhost:8080
|
||||
app.files.storage-dir=./test-cdn-files/
|
||||
app.files.temp-dir=./test-cdn-files/tmp/
|
||||
|
|
Loading…
Reference in New Issue