Added basic functionality for video file uploads and compression.

This commit is contained in:
Andrew Lalis 2023-01-22 19:23:56 +01:00
parent 3d5fe43526
commit 728b611303
24 changed files with 559 additions and 74 deletions

View File

@ -35,6 +35,12 @@
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.9.0</version>
</dependency>
<!-- Test dependencies --> <!-- Test dependencies -->
<dependency> <dependency>

View File

@ -0,0 +1,2 @@
nl,groningen,Groningen
us,tampa,Tampa
1 nl groningen Groningen
2 us tampa Tampa

View File

@ -0,0 +1,3 @@
us,United States
nl,Netherlands
de,Germany
1 us United States
2 nl Netherlands
3 de Germany

View File

@ -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
1 barbell-bench-press Barbell Bench Press
2 barbell-squat Barbell Squat
3 barbell-deadlift Barbell Deadlift
4 barbell-overhead-press Barbell Overhead Press
5 incline-dumbbell-bicep-curl Incline Dumbbell Bicep Curl

View File

@ -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"
1 nl groningen trainmore-munnekeholm Trainmore Munnekeholm https://trainmore.nl/clubs/munnekeholm/ 53.215939 6.561549 Munnekeholm 1, 9711 JA Groningen
2 nl groningen trainmore-oude-ebbinge Trainmore Oude Ebbinge Non-Stop https://trainmore.nl/clubs/oude-ebbinge/ 53.2209 6.565976 Oude Ebbingestraat 54-58
3 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

View File

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

View File

@ -1,11 +1,13 @@
package nl.andrewlalis.gymboard_api.controller; 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 nl.andrewlalis.gymboard_api.service.GymService;
import org.springframework.web.bind.annotation.GetMapping; import nl.andrewlalis.gymboard_api.service.UploadService;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
/** /**
* Controller for accessing a particular gym. * Controller for accessing a particular gym.
@ -14,9 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}") @RequestMapping(path = "/gyms/{countryCode}/{cityCode}/{gymName}")
public class GymController { public class GymController {
private final GymService gymService; private final GymService gymService;
private final UploadService uploadService;
public GymController(GymService gymService) { public GymController(GymService gymService, UploadService uploadService) {
this.gymService = gymService; this.gymService = gymService;
this.uploadService = uploadService;
} }
@GetMapping @GetMapping
@ -25,6 +29,29 @@ public class GymController {
@PathVariable String cityCode, @PathVariable String cityCode,
@PathVariable String gymName @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);
} }
} }

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

View File

@ -0,0 +1,8 @@
package nl.andrewlalis.gymboard_api.controller.dto;
public record ExerciseSubmissionPayload(
String name,
String exerciseShortName,
float weight,
long videoId
) {}

View File

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

View File

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

View File

@ -0,0 +1,3 @@
package nl.andrewlalis.gymboard_api.controller.dto;
public record UploadedFileResponse(long id) {}

View File

@ -3,8 +3,13 @@ package nl.andrewlalis.gymboard_api.dao;
import nl.andrewlalis.gymboard_api.model.City; import nl.andrewlalis.gymboard_api.model.City;
import nl.andrewlalis.gymboard_api.model.CityId; import nl.andrewlalis.gymboard_api.model.CityId;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository @Repository
public interface CityRepository extends JpaRepository<City, CityId> { 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);
} }

View File

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

View File

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

View File

@ -5,6 +5,8 @@ import nl.andrewlalis.gymboard_api.dao.CountryRepository;
import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationListener; import org.springframework.context.ApplicationListener;
@ -12,11 +14,16 @@ import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.io.FileReader;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.function.Consumer;
/**
* Simple component that loads sample data that's useful when testing the application.
*/
@Component @Component
public class SampleDataLoader implements ApplicationListener<ContextRefreshedEvent> { public class SampleDataLoader implements ApplicationListener<ContextRefreshedEvent> {
private static final Logger log = LoggerFactory.getLogger(SampleDataLoader.class); private static final Logger log = LoggerFactory.getLogger(SampleDataLoader.class);
@ -43,8 +50,8 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
if (Files.exists(markerFile)) return; if (Files.exists(markerFile)) return;
log.info("Generating sample data."); log.info("Generating sample data.");
generateSampleData();
try { try {
generateSampleData();
Files.writeString(markerFile, "Yes"); Files.writeString(markerFile, "Yes");
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); e.printStackTrace();
@ -52,40 +59,37 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
} }
@Transactional @Transactional
protected void generateSampleData() { protected void generateSampleData() throws IOException {
Exercise benchPress = exerciseRepository.save(new Exercise("barbell-bench-press", "Barbell Bench Press")); loadCsv("exercises", record -> {
Exercise squat = exerciseRepository.save(new Exercise("barbell-squat", "Barbell Squat")); exerciseRepository.save(new Exercise(record.get(0), record.get(1)));
Exercise deadlift = exerciseRepository.save(new Exercise("deadlift", "Deadlift")); });
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")); private void loadCsv(String csvName, Consumer<CSVRecord> recordConsumer) throws IOException {
City groningen = cityRepository.save(new City("groningen", "Groningen", nl)); var reader = new FileReader("sample_data/" + csvName + ".csv");
Gym g1 = gymRepository.save(new Gym( for (var record : CSVFormat.DEFAULT.parse(reader)) {
groningen, recordConsumer.accept(record);
"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"
));
} }
} }

View File

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

View File

@ -13,23 +13,23 @@ import jakarta.persistence.Table;
public class Exercise { public class Exercise {
@Id @Id
@Column(nullable = false, length = 127) @Column(nullable = false, length = 127)
private String shortname; private String shortName;
@Column(nullable = false, unique = true) @Column(nullable = false, unique = true)
private String name; private String displayName;
public Exercise() {} public Exercise() {}
public Exercise(String shortname, String name) { public Exercise(String shortName, String displayName) {
this.shortname = shortname; this.shortName = shortName;
this.name = name; this.displayName = displayName;
} }
public String getShortname() { public String getShortName() {
return shortname; return shortName;
} }
public String getName() { public String getDisplayName() {
return name; return displayName;
} }
} }

View File

@ -2,6 +2,7 @@ package nl.andrewlalis.gymboard_api.model.exercise;
import jakarta.persistence.*; import jakarta.persistence.*;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.model.Gym;
import nl.andrewlalis.gymboard_api.model.StoredFile;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal; import java.math.BigDecimal;
@ -31,4 +32,49 @@ public class ExerciseSubmission {
@Column(nullable = false, precision = 7, scale = 2) @Column(nullable = false, precision = 7, scale = 2)
private BigDecimal weight; private BigDecimal weight;
@OneToOne(optional = false, fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
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;
}
} }

View File

@ -1,25 +1,69 @@
package nl.andrewlalis.gymboard_api.service; package nl.andrewlalis.gymboard_api.service;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.controller.dto.GymResponse; import nl.andrewlalis.gymboard_api.controller.dto.GymResponse;
import nl.andrewlalis.gymboard_api.controller.dto.RawGymId;
import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.StoredFileRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.model.Gym;
import nl.andrewlalis.gymboard_api.model.StoredFile;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
@Service @Service
public class GymService { public class GymService {
private final GymRepository gymRepository; 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.gymRepository = gymRepository;
this.fileRepository = fileRepository;
this.exerciseRepository = exerciseRepository;
this.exerciseSubmissionRepository = exerciseSubmissionRepository;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public GymResponse getGym(String countryCode, String city, String gymName) { public GymResponse getGym(RawGymId id) {
Gym gym = gymRepository.findByRawId(gymName, city, countryCode) Gym gym = gymRepository.findByRawId(id.gymName(), id.cityCode(), id.countryCode())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new GymResponse(gym); 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);
}
} }

View File

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

View File

@ -1,3 +1,6 @@
spring.jpa.open-in-view=false 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

View File

@ -1,11 +1,30 @@
import axios from "axios"; import axios from 'axios';
import process from "process";
export const BASE_URL = 'http://localhost:8080';
// TODO: Figure out how to get the base URL from environment.
const api = axios.create({ 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, countryCode: string,
countryName: string, countryName: string,
cityShortName: string, cityShortName: string,
@ -14,13 +33,18 @@ export type Gym = {
shortName: string, shortName: string,
displayName: string, displayName: string,
websiteUrl: string | null, websiteUrl: string | null,
location: { location: GeoPoint,
latitude: number,
longitude: number
},
streetAddress: string 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> { export async function getGym(countryCode: string, cityShortName: string, gymShortName: string): Promise<Gym> {
const response = await api.get(`/gyms/${countryCode}/${cityShortName}/${gymShortName}`); const response = await api.get(`/gyms/${countryCode}/${cityShortName}/${gymShortName}`);
const d = response.data; const d = response.data;
@ -37,4 +61,9 @@ export async function getGym(countryCode: string, cityShortName: string, gymShor
streetAddress: d.streetAddress streetAddress: d.streetAddress
}; };
return gym; return gym;
} }
export async function getExercises(): Promise<Array<Exercise>> {
const response = await api.get(`/exercises`);
return response.data;
}

View File

@ -1,13 +1,44 @@
<template> <template>
<q-page v-if="gym"> <q-page v-if="gym" padding>
<h3>{{ gym.displayName }}</h3> <h3 class="q-mt-none">{{ gym.displayName }}</h3>
<p>Recent top lifts go here.</p> <p>Recent top lifts go here.</p>
<q-btn
color="primary" <div class="q-pa-md" style="max-width: 400px">
label="Submit Your Lift" <q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
:to="route.fullPath + '/submit'" <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> <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>
<q-page v-if="notFound"> <q-page v-if="notFound">
<h3>Gym not found! Oh no!!!</h3> <h3>Gym not found! Oh no!!!</h3>
@ -17,18 +48,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, Ref } from 'vue'; 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'; import { useRoute } from 'vue-router';
const route = useRoute(); 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 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. // Once the component is mounted, load the gym that we're at.
onMounted(async () => { onMounted(async () => {
try { try {
gym.value = await getGym( gym.value = await api.getGym(
route.params.countryCode as string, route.params.countryCode as string,
route.params.cityShortName as string, route.params.cityShortName as string,
route.params.gymShortName as string route.params.gymShortName as string
@ -39,4 +76,20 @@ onMounted(async () => {
notFound.value = true; 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> </script>