Added basic functionality for video file uploads and compression.
This commit is contained in:
parent
3d5fe43526
commit
728b611303
|
@ -35,6 +35,12 @@
|
|||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-csv</artifactId>
|
||||
<version>1.9.0</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<!-- Test dependencies -->
|
||||
<dependency>
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
nl,groningen,Groningen
|
||||
us,tampa,Tampa
|
|
|
@ -0,0 +1,3 @@
|
|||
us,United States
|
||||
nl,Netherlands
|
||||
de,Germany
|
|
|
@ -0,0 +1,5 @@
|
|||
barbell-bench-press,Barbell Bench Press
|
||||
barbell-squat,Barbell Squat
|
||||
barbell-deadlift,Barbell Deadlift
|
||||
barbell-overhead-press,Barbell Overhead Press
|
||||
incline-dumbbell-bicep-curl,Incline Dumbbell Bicep Curl
|
|
|
@ -0,0 +1,3 @@
|
|||
nl,groningen,trainmore-munnekeholm,Trainmore Munnekeholm,https://trainmore.nl/clubs/munnekeholm/,53.215939,6.561549,"Munnekeholm 1, 9711 JA Groningen"
|
||||
nl,groningen,trainmore-oude-ebbinge,Trainmore Oude Ebbinge Non-Stop,https://trainmore.nl/clubs/oude-ebbinge/,53.2209,6.565976,Oude Ebbingestraat 54-58
|
||||
us,tampa,powerhouse-gym,Powerhouse Gym Athletic Club,http://www.pgathleticclub.com/,27.997223,-82.496237,"3251-A W Hillsborough Ave, Tampa, FL 33614, United States"
|
|
|
@ -0,0 +1,30 @@
|
|||
package nl.andrewlalis.gymboard_api.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import nl.andrewlalis.gymboard_api.dao.StoredFileRepository;
|
||||
import nl.andrewlalis.gymboard_api.model.StoredFile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@RestController
|
||||
public class FileController {
|
||||
private final StoredFileRepository fileRepository;
|
||||
|
||||
public FileController(StoredFileRepository fileRepository) {
|
||||
this.fileRepository = fileRepository;
|
||||
}
|
||||
|
||||
@GetMapping(path = "/files/{fileId}")
|
||||
public void getFile(@PathVariable long fileId, HttpServletResponse response) throws IOException {
|
||||
StoredFile file = fileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
response.setContentType(file.getMimeType());
|
||||
response.setContentLengthLong(file.getSize());
|
||||
response.getOutputStream().write(file.getContent());
|
||||
}
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
package nl.andrewlalis.gymboard_api.controller;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.GymResponse;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.*;
|
||||
import nl.andrewlalis.gymboard_api.service.GymService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import nl.andrewlalis.gymboard_api.service.UploadService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Controller for accessing a particular gym.
|
||||
|
@ -14,9 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
@RequestMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}")
|
||||
public class GymController {
|
||||
private final GymService gymService;
|
||||
private final UploadService uploadService;
|
||||
|
||||
public GymController(GymService gymService) {
|
||||
public GymController(GymService gymService, UploadService uploadService) {
|
||||
this.gymService = gymService;
|
||||
this.uploadService = uploadService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
@ -25,6 +29,29 @@ public class GymController {
|
|||
@PathVariable String cityCode,
|
||||
@PathVariable String gymName
|
||||
) {
|
||||
return gymService.getGym(countryCode, cityCode, gymName);
|
||||
return gymService.getGym(new RawGymId(countryCode, cityCode, gymName));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/submissions")
|
||||
public ExerciseSubmissionResponse createSubmission(
|
||||
@PathVariable String countryCode,
|
||||
@PathVariable String cityCode,
|
||||
@PathVariable String gymName,
|
||||
@RequestBody ExerciseSubmissionPayload payload
|
||||
) throws IOException {
|
||||
return gymService.createSubmission(new RawGymId(countryCode, cityCode, gymName), payload);
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
path = "/submissions/upload",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
|
||||
)
|
||||
public UploadedFileResponse uploadVideo(
|
||||
@PathVariable String countryCode,
|
||||
@PathVariable String cityCode,
|
||||
@PathVariable String gymName,
|
||||
@RequestParam MultipartFile file
|
||||
) throws IOException {
|
||||
return uploadService.handleUpload(new RawGymId(countryCode, cityCode, gymName), file);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
|
||||
|
||||
public record ExerciseResponse(
|
||||
String shortName,
|
||||
String displayName
|
||||
) {
|
||||
public ExerciseResponse(Exercise exercise) {
|
||||
this(exercise.getShortName(), exercise.getDisplayName());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
public record ExerciseSubmissionPayload(
|
||||
String name,
|
||||
String exerciseShortName,
|
||||
float weight,
|
||||
long videoId
|
||||
) {}
|
|
@ -0,0 +1,29 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public record ExerciseSubmissionResponse(
|
||||
long id,
|
||||
String createdAt,
|
||||
GymSimpleResponse gym,
|
||||
ExerciseResponse exercise,
|
||||
String submitterName,
|
||||
boolean verified,
|
||||
double weight,
|
||||
String videoFileUrl
|
||||
) {
|
||||
public ExerciseSubmissionResponse(ExerciseSubmission submission) {
|
||||
this(
|
||||
submission.getId(),
|
||||
submission.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
new GymSimpleResponse(submission.getGym()),
|
||||
new ExerciseResponse(submission.getExercise()),
|
||||
submission.getSubmitterName(),
|
||||
submission.isVerified(),
|
||||
submission.getWeight().doubleValue(),
|
||||
"bleh"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.model.Gym;
|
||||
|
||||
public record GymSimpleResponse(
|
||||
String countryCode,
|
||||
String cityShortName,
|
||||
String shortName,
|
||||
String displayName
|
||||
) {
|
||||
public GymSimpleResponse(Gym gym) {
|
||||
this(
|
||||
gym.getCity().getCountry().getCode(),
|
||||
gym.getCity().getShortName(),
|
||||
gym.getShortName(),
|
||||
gym.getDisplayName()
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
public record UploadedFileResponse(long id) {}
|
|
@ -3,8 +3,13 @@ package nl.andrewlalis.gymboard_api.dao;
|
|||
import nl.andrewlalis.gymboard_api.model.City;
|
||||
import nl.andrewlalis.gymboard_api.model.CityId;
|
||||
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 CityRepository extends JpaRepository<City, CityId> {
|
||||
@Query("SELECT c FROM City c WHERE c.id.shortName = :shortName AND c.id.country.code = :countryCode")
|
||||
Optional<City> findByShortNameAndCountryCode(String shortName, String countryCode);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
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> {
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package nl.andrewlalis.gymboard_api.dao.exercise;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface ExerciseSubmissionRepository extends JpaRepository<ExerciseSubmission, Long> {
|
||||
}
|
|
@ -5,6 +5,8 @@ import nl.andrewlalis.gymboard_api.dao.CountryRepository;
|
|||
import nl.andrewlalis.gymboard_api.dao.GymRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
|
||||
import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
|
@ -12,11 +14,16 @@ import org.springframework.context.event.ContextRefreshedEvent;
|
|||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Simple component that loads sample data that's useful when testing the application.
|
||||
*/
|
||||
@Component
|
||||
public class SampleDataLoader implements ApplicationListener<ContextRefreshedEvent> {
|
||||
private static final Logger log = LoggerFactory.getLogger(SampleDataLoader.class);
|
||||
|
@ -43,8 +50,8 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
|||
if (Files.exists(markerFile)) return;
|
||||
|
||||
log.info("Generating sample data.");
|
||||
generateSampleData();
|
||||
try {
|
||||
generateSampleData();
|
||||
Files.writeString(markerFile, "Yes");
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
|
@ -52,40 +59,37 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
|||
}
|
||||
|
||||
@Transactional
|
||||
protected void generateSampleData() {
|
||||
Exercise benchPress = exerciseRepository.save(new Exercise("barbell-bench-press", "Barbell Bench Press"));
|
||||
Exercise squat = exerciseRepository.save(new Exercise("barbell-squat", "Barbell Squat"));
|
||||
Exercise deadlift = exerciseRepository.save(new Exercise("deadlift", "Deadlift"));
|
||||
protected void generateSampleData() throws IOException {
|
||||
loadCsv("exercises", record -> {
|
||||
exerciseRepository.save(new Exercise(record.get(0), record.get(1)));
|
||||
});
|
||||
loadCsv("countries", record -> {
|
||||
countryRepository.save(new Country(record.get(0), record.get(1)));
|
||||
});
|
||||
loadCsv("cities", record -> {
|
||||
var country = countryRepository.findById(record.get(0)).orElseThrow();
|
||||
cityRepository.save(new City(record.get(1), record.get(2), country));
|
||||
});
|
||||
loadCsv("gyms", record -> {
|
||||
var city = cityRepository.findByShortNameAndCountryCode(record.get(1), record.get(0)).orElseThrow();
|
||||
gymRepository.save(new Gym(
|
||||
city,
|
||||
record.get(2),
|
||||
record.get(3),
|
||||
record.get(4),
|
||||
new GeoPoint(
|
||||
new BigDecimal(record.get(5)),
|
||||
new BigDecimal(record.get(6))
|
||||
),
|
||||
record.get(7)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Country nl = countryRepository.save(new Country("nl", "Netherlands"));
|
||||
City groningen = cityRepository.save(new City("groningen", "Groningen", nl));
|
||||
Gym g1 = gymRepository.save(new Gym(
|
||||
groningen,
|
||||
"trainmore-munnekeholm",
|
||||
"Trainmore Munnekeholm",
|
||||
"https://trainmore.nl/clubs/munnekeholm/",
|
||||
new GeoPoint(new BigDecimal("53.215939"), new BigDecimal("6.561549")),
|
||||
"Munnekeholm 1, 9711 JA Groningen"
|
||||
));
|
||||
Gym g2 = gymRepository.save(new Gym(
|
||||
groningen,
|
||||
"trainmore-oude-ebbinge",
|
||||
"Trainmore Oude Ebbinge Non-Stop",
|
||||
"https://trainmore.nl/clubs/oude-ebbinge/",
|
||||
new GeoPoint(new BigDecimal("53.220900"), new BigDecimal("6.565976")),
|
||||
"Oude Ebbingestraat 54-58, 9712 HL Groningen"
|
||||
));
|
||||
|
||||
|
||||
Country us = countryRepository.save(new Country("us", "United States"));
|
||||
City tampa = cityRepository.save(new City("tampa", "Tampa", us));
|
||||
Gym g3 = gymRepository.save(new Gym(
|
||||
tampa,
|
||||
"powerhouse-gym",
|
||||
"Powerhouse Gym Athletic Club",
|
||||
"http://www.pgathleticclub.com/",
|
||||
new GeoPoint(new BigDecimal("27.997223"), new BigDecimal("-82.496237")),
|
||||
"3251-A W Hillsborough Ave, Tampa, FL 33614, United States"
|
||||
));
|
||||
private void loadCsv(String csvName, Consumer<CSVRecord> recordConsumer) throws IOException {
|
||||
var reader = new FileReader("sample_data/" + csvName + ".csv");
|
||||
for (var record : CSVFormat.DEFAULT.parse(reader)) {
|
||||
recordConsumer.accept(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -13,23 +13,23 @@ import jakarta.persistence.Table;
|
|||
public class Exercise {
|
||||
@Id
|
||||
@Column(nullable = false, length = 127)
|
||||
private String shortname;
|
||||
private String shortName;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String name;
|
||||
private String displayName;
|
||||
|
||||
public Exercise() {}
|
||||
|
||||
public Exercise(String shortname, String name) {
|
||||
this.shortname = shortname;
|
||||
this.name = name;
|
||||
public Exercise(String shortName, String displayName) {
|
||||
this.shortName = shortName;
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getShortname() {
|
||||
return shortname;
|
||||
public String getShortName() {
|
||||
return shortName;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package nl.andrewlalis.gymboard_api.model.exercise;
|
|||
|
||||
import jakarta.persistence.*;
|
||||
import nl.andrewlalis.gymboard_api.model.Gym;
|
||||
import nl.andrewlalis.gymboard_api.model.StoredFile;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
@ -31,4 +32,49 @@ public class ExerciseSubmission {
|
|||
|
||||
@Column(nullable = false, precision = 7, scale = 2)
|
||||
private BigDecimal weight;
|
||||
|
||||
@OneToOne(optional = false, fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private StoredFile videoFile;
|
||||
|
||||
public ExerciseSubmission() {}
|
||||
|
||||
public ExerciseSubmission(Gym gym, Exercise exercise, String submitterName, BigDecimal weight, StoredFile videoFile) {
|
||||
this.gym = gym;
|
||||
this.exercise = exercise;
|
||||
this.submitterName = submitterName;
|
||||
this.weight = weight;
|
||||
this.videoFile = videoFile;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Gym getGym() {
|
||||
return gym;
|
||||
}
|
||||
|
||||
public Exercise getExercise() {
|
||||
return exercise;
|
||||
}
|
||||
|
||||
public String getSubmitterName() {
|
||||
return submitterName;
|
||||
}
|
||||
|
||||
public boolean isVerified() {
|
||||
return verified;
|
||||
}
|
||||
|
||||
public BigDecimal getWeight() {
|
||||
return weight;
|
||||
}
|
||||
|
||||
public StoredFile getVideoFile() {
|
||||
return videoFile;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +1,69 @@
|
|||
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.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.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 org.springframework.http.HttpStatus;
|
||||
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;
|
||||
|
||||
@Service
|
||||
public class GymService {
|
||||
private final GymRepository gymRepository;
|
||||
private final StoredFileRepository fileRepository;
|
||||
private final ExerciseRepository exerciseRepository;
|
||||
private final ExerciseSubmissionRepository exerciseSubmissionRepository;
|
||||
|
||||
public GymService(GymRepository gymRepository) {
|
||||
public GymService(GymRepository gymRepository, StoredFileRepository fileRepository, ExerciseRepository exerciseRepository, ExerciseSubmissionRepository exerciseSubmissionRepository) {
|
||||
this.gymRepository = gymRepository;
|
||||
this.fileRepository = fileRepository;
|
||||
this.exerciseRepository = exerciseRepository;
|
||||
this.exerciseSubmissionRepository = exerciseSubmissionRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public GymResponse getGym(String countryCode, String city, String gymName) {
|
||||
Gym gym = gymRepository.findByRawId(gymName, city, countryCode)
|
||||
public GymResponse getGym(RawGymId id) {
|
||||
Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
return new GymResponse(gym);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ExerciseSubmissionResponse createSubmission(RawGymId id, ExerciseSubmissionPayload payload) throws IOException {
|
||||
Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
Exercise exercise = exerciseRepository.findById(payload.exerciseShortName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
|
||||
// TODO: Implement legitimate file storage.
|
||||
Path path = Path.of("sample_data", "sample_curl_14kg.MP4");
|
||||
StoredFile file = fileRepository.save(new StoredFile(
|
||||
"sample_curl_14kg.MP4",
|
||||
"video/mp4",
|
||||
Files.size(path),
|
||||
Files.readAllBytes(path)
|
||||
));
|
||||
ExerciseSubmission submission = exerciseSubmissionRepository.save(new ExerciseSubmission(
|
||||
gym,
|
||||
exercise,
|
||||
payload.name(),
|
||||
BigDecimal.valueOf(payload.weight()),
|
||||
file
|
||||
));
|
||||
return new ExerciseSubmissionResponse(submission);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
package nl.andrewlalis.gymboard_api.service;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.RawGymId;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.UploadedFileResponse;
|
||||
import nl.andrewlalis.gymboard_api.dao.GymRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.StoredFileRepository;
|
||||
import nl.andrewlalis.gymboard_api.model.Gym;
|
||||
import nl.andrewlalis.gymboard_api.model.StoredFile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
|
||||
/**
|
||||
* Service for handling large file uploads.
|
||||
* TODO: Use this instead of simple multipart form data.
|
||||
*/
|
||||
@Service
|
||||
public class UploadService {
|
||||
private final StoredFileRepository fileRepository;
|
||||
private final GymRepository gymRepository;
|
||||
|
||||
public UploadService(StoredFileRepository fileRepository, GymRepository gymRepository) {
|
||||
this.fileRepository = fileRepository;
|
||||
this.gymRepository = gymRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UploadedFileResponse handleUpload(RawGymId gymId, MultipartFile multipartFile) throws IOException {
|
||||
Gym gym = gymRepository.findByRawId(gymId.gymName(), gymId.cityCode(), gymId.countryCode())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
// TODO: Check that user is allowed to upload.
|
||||
// TODO: Robust file type check.
|
||||
if (!"video/mp4".equalsIgnoreCase(multipartFile.getContentType())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid content type.");
|
||||
}
|
||||
Path tempDir = Files.createTempDirectory("gymboard-file-upload");
|
||||
Path tempFile = tempDir.resolve("video-file");
|
||||
multipartFile.transferTo(tempFile);
|
||||
Process ffmpegProcess = new ProcessBuilder()
|
||||
.command("ffmpeg", "-i", "video-file", "-vf", "scale=640x480:flags=lanczos", "-vcodec", "libx264", "-crf", "28", "output.mp4")
|
||||
.inheritIO()
|
||||
.directory(tempDir.toFile())
|
||||
.start();
|
||||
try {
|
||||
int result = ffmpegProcess.waitFor();
|
||||
if (result != 0) throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg exited with code " + result);
|
||||
} catch (InterruptedException e) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "ffmpeg process interrupted", e);
|
||||
}
|
||||
Path 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());
|
||||
}
|
||||
}
|
|
@ -1,3 +1,6 @@
|
|||
spring.jpa.open-in-view=false
|
||||
|
||||
spring.servlet.multipart.enabled=false
|
||||
# TODO: Find a better way than dumping files into memory.
|
||||
spring.servlet.multipart.enabled=true
|
||||
spring.servlet.multipart.max-file-size=1GB
|
||||
spring.servlet.multipart.max-request-size=2GB
|
||||
|
|
|
@ -1,11 +1,30 @@
|
|||
import axios from "axios";
|
||||
import process from "process";
|
||||
import axios from 'axios';
|
||||
|
||||
export const BASE_URL = 'http://localhost:8080';
|
||||
|
||||
// TODO: Figure out how to get the base URL from environment.
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:8080'
|
||||
baseURL: BASE_URL
|
||||
});
|
||||
|
||||
export type Gym = {
|
||||
export interface Exercise {
|
||||
shortName: string,
|
||||
displayName: string
|
||||
};
|
||||
|
||||
export interface GeoPoint {
|
||||
latitude: number,
|
||||
longitude: number
|
||||
};
|
||||
|
||||
export interface ExerciseSubmissionPayload {
|
||||
name: string,
|
||||
exerciseShortName: string,
|
||||
weight: number,
|
||||
videoId: number
|
||||
};
|
||||
|
||||
export interface Gym {
|
||||
countryCode: string,
|
||||
countryName: string,
|
||||
cityShortName: string,
|
||||
|
@ -14,13 +33,18 @@ export type Gym = {
|
|||
shortName: string,
|
||||
displayName: string,
|
||||
websiteUrl: string | null,
|
||||
location: {
|
||||
latitude: number,
|
||||
longitude: number
|
||||
},
|
||||
location: GeoPoint,
|
||||
streetAddress: string
|
||||
};
|
||||
|
||||
export function getUploadUrl(gym: Gym) {
|
||||
return BASE_URL + `/gyms/${gym.countryCode}/${gym.cityShortName}/${gym.shortName}/submissions/upload`;
|
||||
}
|
||||
|
||||
export function getFileUrl(fileId: number) {
|
||||
return BASE_URL + `/files/${fileId}`;
|
||||
}
|
||||
|
||||
export async function getGym(countryCode: string, cityShortName: string, gymShortName: string): Promise<Gym> {
|
||||
const response = await api.get(`/gyms/${countryCode}/${cityShortName}/${gymShortName}`);
|
||||
const d = response.data;
|
||||
|
@ -37,4 +61,9 @@ export async function getGym(countryCode: string, cityShortName: string, gymShor
|
|||
streetAddress: d.streetAddress
|
||||
};
|
||||
return gym;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExercises(): Promise<Array<Exercise>> {
|
||||
const response = await api.get(`/exercises`);
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
@ -1,13 +1,44 @@
|
|||
<template>
|
||||
<q-page v-if="gym">
|
||||
<h3>{{ gym.displayName }}</h3>
|
||||
<q-page v-if="gym" padding>
|
||||
<h3 class="q-mt-none">{{ gym.displayName }}</h3>
|
||||
<p>Recent top lifts go here.</p>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Submit Your Lift"
|
||||
:to="route.fullPath + '/submit'"
|
||||
/>
|
||||
|
||||
<div class="q-pa-md" style="max-width: 400px">
|
||||
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
|
||||
<h5>Submit your lift!</h5>
|
||||
<q-input
|
||||
v-model="submissionModel.name"
|
||||
filled
|
||||
label="Name"
|
||||
/>
|
||||
<q-input
|
||||
v-model="submissionModel.exercise"
|
||||
filled
|
||||
label="Exercise"
|
||||
/>
|
||||
<q-input
|
||||
v-model="submissionModel.weight"
|
||||
filled
|
||||
label="Weight"
|
||||
/>
|
||||
<q-uploader
|
||||
:url="api.getUploadUrl(gym)"
|
||||
label="Upload video"
|
||||
field-name="file"
|
||||
max-file-size="1000000000"
|
||||
@uploaded="onUploadSuccess"
|
||||
>
|
||||
|
||||
</q-uploader>
|
||||
<div>
|
||||
<q-btn label="Submit" type="submit" color="primary"/>
|
||||
<q-btn label="Reset" type="reset" color="primary" flat class="q-ml-sm"/>
|
||||
</div>
|
||||
</q-form>
|
||||
</div>
|
||||
|
||||
<p>All the rest of the gym leaderboards should show up here.</p>
|
||||
<video v-if="videoRef" :src="api.getFileUrl(videoRef)"> </video>
|
||||
</q-page>
|
||||
<q-page v-if="notFound">
|
||||
<h3>Gym not found! Oh no!!!</h3>
|
||||
|
@ -17,18 +48,24 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, Ref } from 'vue';
|
||||
import { getGym, Gym } from 'src/api/gymboard-api';
|
||||
import * as api from 'src/api/gymboard-api';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const gym: Ref<Gym | undefined> = ref<Gym>();
|
||||
const gym: Ref<api.Gym | undefined> = ref<api.Gym>();
|
||||
const notFound: Ref<boolean | undefined> = ref<boolean>();
|
||||
const videoRef: Ref<number | undefined> = ref<number>();
|
||||
let submissionModel = {
|
||||
name: '',
|
||||
exercise: '',
|
||||
weight: 0
|
||||
};
|
||||
|
||||
// Once the component is mounted, load the gym that we're at.
|
||||
onMounted(async () => {
|
||||
try {
|
||||
gym.value = await getGym(
|
||||
gym.value = await api.getGym(
|
||||
route.params.countryCode as string,
|
||||
route.params.cityShortName as string,
|
||||
route.params.gymShortName as string
|
||||
|
@ -39,4 +76,20 @@ onMounted(async () => {
|
|||
notFound.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function onSubmit() {
|
||||
console.log('submitting!');
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
submissionModel.name = '';
|
||||
submissionModel.exercise = '';
|
||||
submissionModel.weight = 0;
|
||||
}
|
||||
|
||||
function onUploadSuccess(info: any) {
|
||||
console.log(info);
|
||||
const fileId: number = JSON.parse(info.xhr.response).id;
|
||||
videoRef.value = fileId;
|
||||
}
|
||||
</script>
|
Loading…
Reference in New Issue