Compare commits
13 Commits
Author | SHA1 | Date |
---|---|---|
Andrew Lalis | 4a6d33ffa4 | |
Andrew Lalis | 2881dc5376 | |
Andrew Lalis | ae8595db07 | |
Andrew Lalis | 52be976286 | |
Andrew Lalis | 1ade7ffe66 | |
Andrew Lalis | af3435834f | |
Andrew Lalis | 55eb95e08a | |
Andrew Lalis | ffe1d9bd40 | |
Andrew Lalis | ab3cf591c6 | |
Andrew Lalis | 63550c880d | |
Andrew Lalis | d66fa71ae2 | |
Andrew Lalis | c00697b3d1 | |
Andrew Lalis | 91d4624489 |
|
@ -1,2 +1 @@
|
||||||
build/
|
build/
|
||||||
cli*
|
|
52
README.md
52
README.md
|
@ -1,53 +1,7 @@
|
||||||
# Gymboard
|
# Gymboard
|
||||||
Leaderboards and social lifting for your local community gym.
|
Leaderboards for your local community gym.
|
||||||
|
|
||||||
Gymboard is a platform for sharing videos of your gym lifts with the world,
|
|
||||||
from your local gym to the world's stage.
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
Gymboard is designed as a sort of hybrid architecture combining the best of
|
|
||||||
microservices and monoliths. Here's a short list of each project that makes
|
|
||||||
up the Gymboard ecosystem, and what they do:
|
|
||||||
|
|
||||||
| Project | Type | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 💻 **gymboard-app** | TS, VueJS, Quasar | A front-end web application for users accessing Gymboard. |
|
|
||||||
| 🧬 **gymboard-api** | Java, Spring | The *main* backend service that the app talks to. Includes auth and most business logic. |
|
|
||||||
| 🔍 **gymboard-search** | Java, Lucene | An indexing and searching service that scrapes data from *gymboard-api* to build Lucene search indexes. |
|
|
||||||
| 🗂 **gymboard-cdn** | Java, Spring | A minimal content-delivery service that manages general file storage, including video processing. |
|
|
||||||
| 🛠 **gymboard-cli** | D | **WIP** command-line-interface for managing all services for development and deployment. |
|
|
||||||
| 📸 **gymboard-uploads** | D, Handy-Httpd | **WIP** dedicated service for video upload processing, to extract functionality from *gymboard-cdn*. |
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
Gymboard is comprised of a variety of components, each in its own directory, and with its own project format. Follow the instructions in the README of the respective project to set that one up.
|
||||||
|
|
||||||
Gymboard is comprised of a variety of components, each in its own directory,
|
A `docker-compose.yml` file is defined in this directory, and it defines a set of services that may be used by one or more services. Install docker on your system if you haven't already, and run `docker-compose up -d` to start the services.
|
||||||
and with its own project format. Follow the instructions in the README of the
|
|
||||||
respective project to set that one up.
|
|
||||||
|
|
||||||
A `docker-compose.yml` file is defined in this directory, and it defines a set
|
|
||||||
of services that may be used by one or more services. Install docker on your
|
|
||||||
system if you haven't already, and run `docker-compose up -d` to start the
|
|
||||||
services.
|
|
||||||
|
|
||||||
Run `./build-cli.d` to build and prepare a `cli` executable that you can use to
|
|
||||||
run the Gymboard CLI.
|
|
||||||
|
|
||||||
**WIP:**
|
|
||||||
A `build-apps.d` script is available to try and build all projects and collect
|
|
||||||
their artifacts in a `build/` directory for deployment.
|
|
||||||
> Eventually, this functionality will be merged into *gymboard-cli*.
|
|
||||||
|
|
||||||
### Local Environment
|
|
||||||
|
|
||||||
The requirements to develop each project depend of course on the type of
|
|
||||||
project. But in general, the following software recommendations should hold:
|
|
||||||
|
|
||||||
| Type | Requirements |
|
|
||||||
| --- | --- |
|
|
||||||
| **Java** | [Latest LTS version](https://adoptium.net/en-GB/temurin/releases/), latest Maven version |
|
|
||||||
| **VueJS** | Vue 3, with a recent version of NodeJS and NPM or similar. |
|
|
||||||
| **D** | [D toolchain](https://dlang.org/download.html) (compiler + dub) for D version >= 2.103, DMD recommended compiler |
|
|
||||||
|
|
||||||
[Docker](https://www.docker.com/) is recommended for running local dependencies
|
|
||||||
like DB, mail server, message queues, etc.
|
|
||||||
|
|
42
build-cli.d
42
build-cli.d
|
@ -1,42 +0,0 @@
|
||||||
#!/usr/bin/env rdmd
|
|
||||||
/**
|
|
||||||
* Run this script with `./build.d` to prepare the latest version of the CLI
|
|
||||||
* for use. It compiles the CLI application, and copies a "cli" executable to
|
|
||||||
* the project's root directory for use.
|
|
||||||
*/
|
|
||||||
module build_cli;
|
|
||||||
|
|
||||||
import std.stdio;
|
|
||||||
import std.process;
|
|
||||||
import std.file;
|
|
||||||
import std.path;
|
|
||||||
|
|
||||||
int main() {
|
|
||||||
writeln("Building...");
|
|
||||||
const mainDir = getcwd();
|
|
||||||
chdir("gymboard-cli");
|
|
||||||
auto result = executeShell("dub build --build=release");
|
|
||||||
if (result.status != 0) {
|
|
||||||
stderr.writefln!"Build failed: %d"(result.status);
|
|
||||||
stderr.writeln(result.output);
|
|
||||||
return result.status;
|
|
||||||
}
|
|
||||||
string finalPath = buildPath("..", "cli");
|
|
||||||
if (exists(finalPath)) std.file.remove(finalPath);
|
|
||||||
version (Posix) {
|
|
||||||
string sourceExecutable = "gymboard-cli";
|
|
||||||
}
|
|
||||||
version (Windows) {
|
|
||||||
string sourceExecutable = "gymboard-cli.exe";
|
|
||||||
}
|
|
||||||
std.file.copy(sourceExecutable, finalPath);
|
|
||||||
version (Posix) {
|
|
||||||
result = executeShell("chmod +x " ~ finalPath);
|
|
||||||
if (result.status != 0) {
|
|
||||||
stderr.writefln!"Failed to enable executable permission: %d"(result.status);
|
|
||||||
return result.status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeln("Done! Run ./cli to start using Gymboard CLI.");
|
|
||||||
return 0;
|
|
||||||
}
|
|
|
@ -6,8 +6,6 @@
|
||||||
* thread.
|
* thread.
|
||||||
*
|
*
|
||||||
* Supply the "clean" command to remove all build artifacts instead of building.
|
* Supply the "clean" command to remove all build artifacts instead of building.
|
||||||
*
|
|
||||||
* Eventually, this will be migrated to gymboard-cli.
|
|
||||||
*/
|
*/
|
||||||
module build_apps;
|
module build_apps;
|
||||||
|
|
||||||
|
@ -22,8 +20,7 @@ enum BUILDS_DIR = "build";
|
||||||
|
|
||||||
int main(string[] args) {
|
int main(string[] args) {
|
||||||
if (args.length > 1 && args[1] == "clean") {
|
if (args.length > 1 && args[1] == "clean") {
|
||||||
writeln("Cleaning builds");
|
rmdirRecurse(BUILDS_DIR);
|
||||||
if (exists(BUILDS_DIR)) rmdirRecurse(BUILDS_DIR);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
Thread[] buildThreads = [
|
Thread[] buildThreads = [
|
||||||
|
@ -94,10 +91,8 @@ int runBuild(const BuildSpec spec) {
|
||||||
string buildDir = buildPath(BUILDS_DIR, spec.name);
|
string buildDir = buildPath(BUILDS_DIR, spec.name);
|
||||||
if (exists(buildDir)) rmdirRecurse(buildDir);
|
if (exists(buildDir)) rmdirRecurse(buildDir);
|
||||||
mkdirRecurse(buildDir);
|
mkdirRecurse(buildDir);
|
||||||
const logFilePath = buildPath(buildDir, "build.log");
|
File buildLogFile = File(buildPath(buildDir, "build.log"), "w");
|
||||||
const errorLogFilePath = buildPath(buildDir, "build-error.log");
|
File buildErrorLogFile = File(buildPath(buildDir, "build-error.log"), "w");
|
||||||
File buildLogFile = File(logFilePath, "w");
|
|
||||||
File buildErrorLogFile = File(errorLogFilePath, "w");
|
|
||||||
Pid pid = spawnShell(
|
Pid pid = spawnShell(
|
||||||
spec.buildCommand,
|
spec.buildCommand,
|
||||||
std.stdio.stdin,
|
std.stdio.stdin,
|
||||||
|
@ -109,14 +104,7 @@ int runBuild(const BuildSpec spec) {
|
||||||
nativeShell()
|
nativeShell()
|
||||||
);
|
);
|
||||||
int result = wait(pid);
|
int result = wait(pid);
|
||||||
if (result != 0) {
|
if (result != 0) return result;
|
||||||
writefln!"Build command failed for build \"%s\". Check %s for more info."(spec.name, errorLogFilePath);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up unused log files.
|
|
||||||
if (getSize(logFilePath) == 0) std.file.remove(logFilePath);
|
|
||||||
if (getSize(errorLogFilePath) == 0) std.file.remove(errorLogFilePath);
|
|
||||||
|
|
||||||
// Find and extract artifacts.
|
// Find and extract artifacts.
|
||||||
bool artifactFound = false;
|
bool artifactFound = false;
|
|
@ -62,7 +62,8 @@ public class SecurityConfig {
|
||||||
"/auth/token",
|
"/auth/token",
|
||||||
"/auth/register",
|
"/auth/register",
|
||||||
"/auth/activate",
|
"/auth/activate",
|
||||||
"/auth/reset-password"
|
"/auth/reset-password",
|
||||||
|
"/submissions/video-processing-complete"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
// Everything else must be authenticated, just to be safe.
|
// Everything else must be authenticated, just to be safe.
|
||||||
.anyRequest().authenticated();
|
.anyRequest().authenticated();
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.config;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.method.HandlerMethod;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interceptor that checks that requests to endpoints annotated with
|
||||||
|
* {@link ServiceOnly} have a valid service secret header value.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ServiceAccessInterceptor implements HandlerInterceptor {
|
||||||
|
public static final String HEADER_NAME = "X-Gymboard-Service-Secret";
|
||||||
|
|
||||||
|
@Value("${app.service-secret}")
|
||||||
|
private String serviceSecret;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
Method handlerMethod = ((HandlerMethod) handler).getMethod();
|
||||||
|
Class<?> handlerClass = handlerMethod.getDeclaringClass();
|
||||||
|
|
||||||
|
ServiceOnly methodAnnotation = handlerMethod.getAnnotation(ServiceOnly.class);
|
||||||
|
ServiceOnly classAnnotation = handlerClass.getAnnotation(ServiceOnly.class);
|
||||||
|
if (methodAnnotation != null || classAnnotation != null) {
|
||||||
|
String secret = request.getHeader(HEADER_NAME);
|
||||||
|
if (secret == null || !secret.trim().equals(serviceSecret)) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.config;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation that can be applied to a controller or controller method to
|
||||||
|
* restrict access to only requests from another service that provide a
|
||||||
|
* legitimate service secret.
|
||||||
|
* @see ServiceAccessInterceptor
|
||||||
|
*/
|
||||||
|
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface ServiceOnly {}
|
|
@ -22,9 +22,11 @@ public class WebComponents {
|
||||||
|
|
||||||
@Value("${app.cdn-origin}")
|
@Value("${app.cdn-origin}")
|
||||||
private String cdnOrigin;
|
private String cdnOrigin;
|
||||||
|
@Value("${app.cdn-secret}")
|
||||||
|
private String cdnSecret;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CdnClient cdnClient() {
|
public CdnClient cdnClient() {
|
||||||
return new CdnClient(cdnOrigin);
|
return new CdnClient(cdnOrigin, cdnSecret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.controller;
|
package nl.andrewlalis.gymboard_api.domains.api.controller;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
|
import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionPayload;
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionPayload;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse;
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.GymResponse;
|
import nl.andrewlalis.gymboard_api.domains.api.dto.GymResponse;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.GymService;
|
import nl.andrewlalis.gymboard_api.domains.api.service.GymService;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
|
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.controller;
|
package nl.andrewlalis.gymboard_api.domains.api.controller;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse;
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.LeaderboardService;
|
import nl.andrewlalis.gymboard_api.domains.api.service.LeaderboardService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.controller;
|
package nl.andrewlalis.gymboard_api.domains.api.controller;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse;
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.submission.UserSubmissionService;
|
import nl.andrewlalis.gymboard_api.domains.api.service.submission.UserSubmissionService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.dto;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
public record SubmissionPayload(
|
|
||||||
String exerciseShortName,
|
|
||||||
LocalDateTime performedAt,
|
|
||||||
float weight,
|
|
||||||
String weightUnit,
|
|
||||||
int reps,
|
|
||||||
String videoFileId
|
|
||||||
) {}
|
|
|
@ -1,37 +0,0 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.dto;
|
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.submission.Submission;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dto.UserResponse;
|
|
||||||
import nl.andrewlalis.gymboard_api.util.StandardDateFormatter;
|
|
||||||
|
|
||||||
public record SubmissionResponse(
|
|
||||||
String id,
|
|
||||||
String createdAt,
|
|
||||||
GymSimpleResponse gym,
|
|
||||||
ExerciseResponse exercise,
|
|
||||||
UserResponse user,
|
|
||||||
String performedAt,
|
|
||||||
String videoFileId,
|
|
||||||
double rawWeight,
|
|
||||||
String weightUnit,
|
|
||||||
double metricWeight,
|
|
||||||
int reps,
|
|
||||||
boolean verified
|
|
||||||
) {
|
|
||||||
public SubmissionResponse(Submission submission) {
|
|
||||||
this(
|
|
||||||
submission.getId(),
|
|
||||||
StandardDateFormatter.format(submission.getCreatedAt()),
|
|
||||||
new GymSimpleResponse(submission.getGym()),
|
|
||||||
new ExerciseResponse(submission.getExercise()),
|
|
||||||
new UserResponse(submission.getUser()),
|
|
||||||
StandardDateFormatter.format(submission.getPerformedAt()),
|
|
||||||
submission.getVideoFileId(),
|
|
||||||
submission.getRawWeight().doubleValue(),
|
|
||||||
submission.getWeightUnit().name(),
|
|
||||||
submission.getMetricWeight().doubleValue(),
|
|
||||||
submission.getReps(),
|
|
||||||
submission.isVerified()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.api.dto;
|
||||||
|
|
||||||
|
public record VideoProcessingCompletePayload(
|
||||||
|
long taskId,
|
||||||
|
String status,
|
||||||
|
String videoFileId,
|
||||||
|
String thumbnailFileId
|
||||||
|
) {}
|
|
@ -1,137 +0,0 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.model.submission;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.Exercise;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "submission")
|
|
||||||
public class Submission {
|
|
||||||
@Id
|
|
||||||
@Column(nullable = false, updatable = false, length = 26)
|
|
||||||
private String id;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
|
||||||
private Gym gym;
|
|
||||||
|
|
||||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
|
||||||
private Exercise exercise;
|
|
||||||
|
|
||||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
|
||||||
private User user;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private LocalDateTime performedAt;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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, precision = 7, scale = 2)
|
|
||||||
private BigDecimal rawWeight;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false)
|
|
||||||
private WeightUnit weightUnit;
|
|
||||||
|
|
||||||
@Column(nullable = false, precision = 7, scale = 2)
|
|
||||||
private BigDecimal metricWeight;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private int reps;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private boolean verified;
|
|
||||||
|
|
||||||
public Submission() {}
|
|
||||||
|
|
||||||
public Submission(
|
|
||||||
String id,
|
|
||||||
Gym gym,
|
|
||||||
Exercise exercise,
|
|
||||||
User user,
|
|
||||||
LocalDateTime performedAt,
|
|
||||||
String videoFileId,
|
|
||||||
BigDecimal rawWeight,
|
|
||||||
WeightUnit unit,
|
|
||||||
BigDecimal metricWeight,
|
|
||||||
int reps
|
|
||||||
) {
|
|
||||||
this.id = id;
|
|
||||||
this.gym = gym;
|
|
||||||
this.exercise = exercise;
|
|
||||||
this.videoFileId = videoFileId;
|
|
||||||
this.user = user;
|
|
||||||
this.performedAt = performedAt;
|
|
||||||
this.rawWeight = rawWeight;
|
|
||||||
this.weightUnit = unit;
|
|
||||||
this.metricWeight = metricWeight;
|
|
||||||
this.reps = reps;
|
|
||||||
this.verified = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Gym getGym() {
|
|
||||||
return gym;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Exercise getExercise() {
|
|
||||||
return exercise;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getVideoFileId() {
|
|
||||||
return videoFileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public User getUser() {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getPerformedAt() {
|
|
||||||
return performedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getRawWeight() {
|
|
||||||
return rawWeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
public WeightUnit getWeightUnit() {
|
|
||||||
return weightUnit;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getMetricWeight() {
|
|
||||||
return metricWeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getReps() {
|
|
||||||
return reps;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isVerified() {
|
|
||||||
return verified;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setVerified(boolean verified) {
|
|
||||||
this.verified = verified;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +1,10 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.service;
|
package nl.andrewlalis.gymboard_api.domains.api.service;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
|
import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse;
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.GymResponse;
|
import nl.andrewlalis.gymboard_api.domains.api.dto.GymResponse;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
|
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionRepository;
|
import nl.andrewlalis.gymboard_api.domains.submission.dao.SubmissionRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
||||||
import nl.andrewlalis.gymboard_api.util.PredicateBuilder;
|
import nl.andrewlalis.gymboard_api.util.PredicateBuilder;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
@ -42,13 +42,14 @@ public class GymService {
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||||
return submissionRepository.findAll((root, query, criteriaBuilder) -> {
|
return submissionRepository.findAll((root, query, criteriaBuilder) -> {
|
||||||
query.orderBy(
|
query.orderBy(
|
||||||
criteriaBuilder.desc(root.get("performedAt")),
|
criteriaBuilder.desc(root.get("properties").get("performedAt")),
|
||||||
criteriaBuilder.desc(root.get("createdAt"))
|
criteriaBuilder.desc(root.get("createdAt"))
|
||||||
);
|
);
|
||||||
query.distinct(true);
|
query.distinct(true);
|
||||||
return PredicateBuilder.and(criteriaBuilder)
|
return PredicateBuilder.and(criteriaBuilder)
|
||||||
.with(criteriaBuilder.equal(root.get("gym"), gym))
|
.with(criteriaBuilder.equal(root.get("gym"), gym))
|
||||||
.with(criteriaBuilder.isTrue(root.get("verified")))
|
.with(criteriaBuilder.isTrue(root.get("verified")))
|
||||||
|
.with(criteriaBuilder.isFalse(root.get("processing")))
|
||||||
.build();
|
.build();
|
||||||
}, PageRequest.of(0, 5))
|
}, PageRequest.of(0, 5))
|
||||||
.map(SubmissionResponse::new)
|
.map(SubmissionResponse::new)
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.service;
|
package nl.andrewlalis.gymboard_api.domains.api.service;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
|
import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse;
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
|
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.ExerciseRepository;
|
import nl.andrewlalis.gymboard_api.domains.api.dao.ExerciseRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionRepository;
|
import nl.andrewlalis.gymboard_api.domains.submission.dao.SubmissionRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.LeaderboardTimeframe;
|
import nl.andrewlalis.gymboard_api.domains.api.model.LeaderboardTimeframe;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.Exercise;
|
import nl.andrewlalis.gymboard_api.domains.api.model.Exercise;
|
||||||
|
@ -55,8 +55,10 @@ public class LeaderboardService {
|
||||||
query.distinct(true);
|
query.distinct(true);
|
||||||
query.orderBy(criteriaBuilder.desc(root.get("metricWeight")));
|
query.orderBy(criteriaBuilder.desc(root.get("metricWeight")));
|
||||||
|
|
||||||
|
// Basic predicates that should always hold.
|
||||||
PredicateBuilder pb = PredicateBuilder.and(criteriaBuilder)
|
PredicateBuilder pb = PredicateBuilder.and(criteriaBuilder)
|
||||||
.with(criteriaBuilder.isTrue(root.get("verified")));
|
.with(criteriaBuilder.isTrue(root.get("verified")))
|
||||||
|
.with(criteriaBuilder.isFalse(root.get("processing")));
|
||||||
|
|
||||||
cutoffTime.ifPresent(time -> pb.with(criteriaBuilder.greaterThan(root.get("performedAt"), time)));
|
cutoffTime.ifPresent(time -> pb.with(criteriaBuilder.greaterThan(root.get("performedAt"), time)));
|
||||||
optionalExercise.ifPresent(exercise -> pb.with(criteriaBuilder.equal(root.get("exercise"), exercise)));
|
optionalExercise.ifPresent(exercise -> pb.with(criteriaBuilder.equal(root.get("exercise"), exercise)));
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client;
|
package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import nl.andrewlalis.gymboard_api.config.ServiceAccessInterceptor;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
@ -13,23 +14,28 @@ import java.time.Duration;
|
||||||
public class CdnClient {
|
public class CdnClient {
|
||||||
private final HttpClient httpClient;
|
private final HttpClient httpClient;
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
|
private final String cdnSecret;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public final UploadsClient uploads;
|
public final UploadsClient uploads;
|
||||||
|
public final FilesClient files;
|
||||||
|
|
||||||
public CdnClient(String baseUrl) {
|
public CdnClient(String baseUrl, String cdnSecret) {
|
||||||
this.httpClient = HttpClient.newBuilder()
|
this.httpClient = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(3))
|
.connectTimeout(Duration.ofSeconds(3))
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
.build();
|
.build();
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
this.cdnSecret = cdnSecret;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = new ObjectMapper();
|
||||||
this.uploads = new UploadsClient(this);
|
this.uploads = new UploadsClient(this);
|
||||||
|
this.files = new FilesClient(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T> T get(String urlPath, Class<T> responseType) throws IOException, InterruptedException {
|
public <T> T get(String urlPath, Class<T> responseType) throws IOException, InterruptedException {
|
||||||
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
||||||
.GET()
|
.GET()
|
||||||
|
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
if (response.statusCode() == 200) {
|
if (response.statusCode() == 200) {
|
||||||
|
@ -46,8 +52,31 @@ public class CdnClient {
|
||||||
.POST(HttpRequest.BodyPublishers.ofFile(filePath))
|
.POST(HttpRequest.BodyPublishers.ofFile(filePath))
|
||||||
.header("Content-Type", contentType)
|
.header("Content-Type", contentType)
|
||||||
.header("X-Gymboard-Filename", filePath.getFileName().toString())
|
.header("X-Gymboard-Filename", filePath.getFileName().toString())
|
||||||
|
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
return objectMapper.readValue(response.body(), responseType);
|
return objectMapper.readValue(response.body(), responseType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void post(String urlPath) throws IOException, InterruptedException {
|
||||||
|
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
||||||
|
.POST(HttpRequest.BodyPublishers.noBody())
|
||||||
|
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
|
||||||
|
.build();
|
||||||
|
HttpResponse<Void> response = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new IOException("Request failed with code " + response.statusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String urlPath) throws IOException, InterruptedException {
|
||||||
|
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
|
||||||
|
.DELETE()
|
||||||
|
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
|
||||||
|
.build();
|
||||||
|
HttpResponse<Void> response = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
throw new IOException("Request failed with code " + response.statusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client;
|
||||||
|
|
||||||
|
public record FilesClient(CdnClient client) {
|
||||||
|
public record FileMetadataResponse(
|
||||||
|
String filename,
|
||||||
|
String mimeType,
|
||||||
|
long size,
|
||||||
|
String createdAt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public FileMetadataResponse getFileMetadata(String id) throws Exception {
|
||||||
|
return client.get("/files/" + id + "/metadata", FileMetadataResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteFile(String id) throws Exception {
|
||||||
|
client.delete("/files/" + id);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,26 +3,22 @@ package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
public record UploadsClient(CdnClient client) {
|
public record UploadsClient(CdnClient client) {
|
||||||
public record FileUploadResponse(String id) {}
|
public record FileUploadResponse(long taskId) {}
|
||||||
public record VideoProcessingTaskStatusResponse(String status) {}
|
public record VideoProcessingTaskStatusResponse(
|
||||||
|
String status,
|
||||||
public record FileMetadataResponse(
|
String videoFileId,
|
||||||
String filename,
|
String thumbnailFileId
|
||||||
String mimeType,
|
|
||||||
long size,
|
|
||||||
String uploadedAt,
|
|
||||||
boolean availableForDownload
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public FileUploadResponse uploadVideo(Path filePath, String contentType) throws Exception {
|
public long uploadVideo(Path filePath, String contentType) throws Exception {
|
||||||
return client.postFile("/uploads/video", filePath, contentType, FileUploadResponse.class);
|
return client.postFile("/uploads/video", filePath, contentType, FileUploadResponse.class).taskId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) throws Exception {
|
public VideoProcessingTaskStatusResponse getVideoProcessingTaskStatus(long id) throws Exception {
|
||||||
return client.get("/uploads/video/" + id + "/status", VideoProcessingTaskStatusResponse.class);
|
return client.get("/uploads/video/" + id + "/status", VideoProcessingTaskStatusResponse.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FileMetadataResponse getFileMetadata(String id) throws Exception {
|
public void startTask(long taskId) throws Exception {
|
||||||
return client.get("/files/" + id + "/metadata", FileMetadataResponse.class);
|
client.post("/uploads/video/" + taskId + "/start");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,26 +2,33 @@ package nl.andrewlalis.gymboard_api.domains.api.service.submission;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
|
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.ExerciseRepository;
|
import nl.andrewlalis.gymboard_api.domains.api.dao.ExerciseRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionRepository;
|
import nl.andrewlalis.gymboard_api.domains.submission.dao.SubmissionRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.*;
|
import nl.andrewlalis.gymboard_api.domains.api.dto.*;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
|
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.Exercise;
|
import nl.andrewlalis.gymboard_api.domains.api.model.Exercise;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.submission.Submission;
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionPayload;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.model.Submission;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient;
|
import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.UploadsClient;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.model.SubmissionProperties;
|
||||||
import nl.andrewlalis.gymboard_api.util.ULID;
|
import nl.andrewlalis.gymboard_api.util.ULID;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
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.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service which handles the rather mundane tasks associated with exercise
|
* Service which handles the rather mundane tasks associated with exercise
|
||||||
|
@ -80,32 +87,53 @@ public class ExerciseSubmissionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the submission.
|
// Create the submission.
|
||||||
LocalDateTime performedAt = payload.performedAt();
|
LocalDateTime performedAt = LocalDateTime.now();
|
||||||
if (performedAt == null) performedAt = LocalDateTime.now();
|
if (payload.performedAt() != null) {
|
||||||
|
performedAt = LocalDate.parse(payload.performedAt()).atTime(performedAt.toLocalTime());
|
||||||
|
}
|
||||||
BigDecimal rawWeight = BigDecimal.valueOf(payload.weight());
|
BigDecimal rawWeight = BigDecimal.valueOf(payload.weight());
|
||||||
WeightUnit weightUnit = WeightUnit.parse(payload.weightUnit());
|
WeightUnit weightUnit = WeightUnit.parse(payload.weightUnit());
|
||||||
BigDecimal metricWeight = BigDecimal.valueOf(payload.weight());
|
BigDecimal metricWeight = BigDecimal.valueOf(payload.weight());
|
||||||
if (weightUnit == WeightUnit.POUNDS) {
|
if (weightUnit == WeightUnit.POUNDS) {
|
||||||
metricWeight = WeightUnit.toKilograms(rawWeight);
|
metricWeight = WeightUnit.toKilograms(rawWeight);
|
||||||
}
|
}
|
||||||
Submission submission = submissionRepository.saveAndFlush(new Submission(
|
SubmissionProperties properties = new SubmissionProperties(
|
||||||
ulid.nextULID(), gym, exercise, user,
|
exercise,
|
||||||
performedAt,
|
performedAt,
|
||||||
payload.videoFileId(),
|
rawWeight,
|
||||||
rawWeight, weightUnit, metricWeight, payload.reps()
|
weightUnit,
|
||||||
));
|
payload.reps()
|
||||||
|
);
|
||||||
|
|
||||||
|
Submission submission = new Submission(ulid.nextULID(), gym, user, payload.taskId(), properties);
|
||||||
|
try {
|
||||||
|
cdnClient.uploads.startTask(submission.getVideoProcessingTaskId());
|
||||||
|
submission.setProcessing(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to start video processing task for submission.", e);
|
||||||
|
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to start video processing.");
|
||||||
|
}
|
||||||
|
submission = submissionRepository.saveAndFlush(submission);
|
||||||
return new SubmissionResponse(submission);
|
return new SubmissionResponse(submission);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ValidationResponse validateSubmissionData(Gym gym, User user, Exercise exercise, SubmissionPayload data) {
|
private ValidationResponse validateSubmissionData(Gym gym, User user, Exercise exercise, SubmissionPayload data) {
|
||||||
ValidationResponse response = new ValidationResponse();
|
ValidationResponse response = new ValidationResponse();
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
LocalDateTime cutoff = LocalDateTime.now().minusDays(3);
|
LocalDateTime cutoff = LocalDateTime.now().minusDays(3);
|
||||||
if (data.performedAt() != null && data.performedAt().isAfter(LocalDateTime.now())) {
|
if (data.performedAt() != null) {
|
||||||
|
try {
|
||||||
|
LocalDateTime performedAt = LocalDate.parse(data.performedAt()).atTime(now.toLocalTime());
|
||||||
|
if (performedAt.isAfter(now)) {
|
||||||
response.addMessage("Cannot submit an exercise from the future.");
|
response.addMessage("Cannot submit an exercise from the future.");
|
||||||
}
|
}
|
||||||
if (data.performedAt() != null && data.performedAt().isBefore(cutoff)) {
|
if (performedAt.isBefore(cutoff)) {
|
||||||
response.addMessage("Cannot submit an exercise too far in the past.");
|
response.addMessage("Cannot submit an exercise too far in the past.");
|
||||||
}
|
}
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
response.addMessage("Invalid performedAt format.");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (data.reps() < 1 || data.reps() > 500) {
|
if (data.reps() < 1 || data.reps() > 500) {
|
||||||
response.addMessage("Invalid rep count.");
|
response.addMessage("Invalid rep count.");
|
||||||
}
|
}
|
||||||
|
@ -118,17 +146,13 @@ public class ExerciseSubmissionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
UploadsClient.FileMetadataResponse metadata = cdnClient.uploads.getFileMetadata(data.videoFileId());
|
var status = cdnClient.uploads.getVideoProcessingTaskStatus(data.taskId());
|
||||||
if (metadata == null) {
|
if (status == null || !status.status().equalsIgnoreCase("NOT_STARTED")) {
|
||||||
response.addMessage("Missing video file.");
|
response.addMessage("Invalid video processing task.");
|
||||||
} else if (!metadata.availableForDownload()) {
|
|
||||||
response.addMessage("File not yet available for download.");
|
|
||||||
} else if (!"video/mp4".equals(metadata.mimeType())) {
|
|
||||||
response.addMessage("Invalid video file format.");
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error fetching file metadata.", e);
|
log.error("Error fetching task status.", e);
|
||||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error fetching uploaded video file metadata.");
|
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error fetching uploaded video task status.");
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -140,7 +164,133 @@ public class ExerciseSubmissionService {
|
||||||
if (!submission.getUser().getId().equals(user.getId())) {
|
if (!submission.getUser().getId().equals(user.getId())) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete other user's submission.");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete other user's submission.");
|
||||||
}
|
}
|
||||||
// TODO: Find a secure way to delete the associated video.
|
try {
|
||||||
|
|
||||||
|
if (submission.getVideoFileId() != null) {
|
||||||
|
cdnClient.files.deleteFile(submission.getVideoFileId());
|
||||||
|
}
|
||||||
|
if (submission.getThumbnailFileId() != null) {
|
||||||
|
cdnClient.files.deleteFile(submission.getThumbnailFileId());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Couldn't delete CDN content for submission " + submissionId, e);
|
||||||
|
}
|
||||||
submissionRepository.delete(submission);
|
submissionRepository.delete(submission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is invoked when the CDN calls this API's endpoint to notify
|
||||||
|
* us that a video processing task has completed. If the task completed
|
||||||
|
* successfully, we can set any related submissions' video and thumbnail
|
||||||
|
* file ids and remove its "processing" flag. Otherwise, we should delete
|
||||||
|
* the failed submission.
|
||||||
|
* @param payload The information about the task.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void handleVideoProcessingComplete(VideoProcessingCompletePayload payload) {
|
||||||
|
var submissionsToUpdate = submissionRepository.findUnprocessedByTaskId(payload.taskId());
|
||||||
|
log.info("Received video processing complete message from CDN: {}, affecting {} submissions.", payload, submissionsToUpdate.size());
|
||||||
|
for (var submission : submissionsToUpdate) {
|
||||||
|
if (payload.status().equalsIgnoreCase("COMPLETED")) {
|
||||||
|
submission.setVideoFileId(payload.videoFileId());
|
||||||
|
submission.setThumbnailFileId(payload.thumbnailFileId());
|
||||||
|
submission.setProcessing(false);
|
||||||
|
submissionRepository.save(submission);
|
||||||
|
// TODO: Send notification of successful processing to the user!
|
||||||
|
} else if (payload.status().equalsIgnoreCase("FAILED")) {
|
||||||
|
submissionRepository.delete(submission);
|
||||||
|
// TODO: Send notification of failed video processing to the user!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scheduled task that checks and resolves issues with any submission that
|
||||||
|
* stays in the "processing" state for too long.
|
||||||
|
* TODO: Find some way to clean up this mess of logic!
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES)
|
||||||
|
public void checkProcessingSubmissions() {
|
||||||
|
var processingSubmissions = submissionRepository.findAllByProcessingTrue();
|
||||||
|
LocalDateTime actionCutoff = LocalDateTime.now().minus(Duration.ofMinutes(3));
|
||||||
|
LocalDateTime deleteCutoff = LocalDateTime.now().minus(Duration.ofMinutes(30));
|
||||||
|
for (var submission : processingSubmissions) {
|
||||||
|
if (submission.getCreatedAt().isBefore(actionCutoff)) {
|
||||||
|
// Sanity check to remove any inconsistent submission that doesn't have a task id for whatever reason.
|
||||||
|
if (submission.getVideoProcessingTaskId() == null) {
|
||||||
|
log.warn(
|
||||||
|
"Removing long-processing submission {} for user {} because it doesn't have a task id.",
|
||||||
|
submission.getId(), submission.getUser().getEmail()
|
||||||
|
);
|
||||||
|
submissionRepository.delete(submission);
|
||||||
|
// TODO: Send notification to user.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var status = cdnClient.uploads.getVideoProcessingTaskStatus(submission.getVideoProcessingTaskId());
|
||||||
|
if (status == null) {
|
||||||
|
// The task no longer exists on the CDN, so remove the submission.
|
||||||
|
log.warn(
|
||||||
|
"Removing long-processing submission {} for user {} because its task no longer exists on the CDN.",
|
||||||
|
submission.getId(), submission.getUser().getEmail()
|
||||||
|
);
|
||||||
|
submissionRepository.delete(submission);
|
||||||
|
// TODO: Send notification to user.
|
||||||
|
} else if (status.status().equalsIgnoreCase("FAILED")) {
|
||||||
|
// The task failed, so we should remove the submission.
|
||||||
|
log.warn(
|
||||||
|
"Removing long-processing submission {} for user {} because its task failed.",
|
||||||
|
submission.getId(), submission.getUser().getEmail()
|
||||||
|
);
|
||||||
|
submissionRepository.delete(submission);
|
||||||
|
// TODO: Send notification to user.
|
||||||
|
} else if (status.status().equalsIgnoreCase("COMPLETED")) {
|
||||||
|
// The submission should be marked as complete.
|
||||||
|
submission.setVideoFileId(status.videoFileId());
|
||||||
|
submission.setThumbnailFileId(status.thumbnailFileId());
|
||||||
|
submission.setProcessing(false);
|
||||||
|
submissionRepository.save(submission);
|
||||||
|
// TODO: Send notification to user.
|
||||||
|
} else if (status.status().equalsIgnoreCase("NOT_STARTED")) {
|
||||||
|
// If for whatever reason the submission's video processing never started, start now.
|
||||||
|
try {
|
||||||
|
cdnClient.uploads.startTask(submission.getVideoProcessingTaskId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to start processing task " + submission.getVideoProcessingTaskId(), e);
|
||||||
|
if (submission.getCreatedAt().isBefore(deleteCutoff)) {
|
||||||
|
log.warn(
|
||||||
|
"Removing long-processing submission {} for user {} because it is waiting or processing for too long.",
|
||||||
|
submission.getId(), submission.getUser().getEmail()
|
||||||
|
);
|
||||||
|
submissionRepository.delete(submission);
|
||||||
|
// TODO: Send notification to user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The task is waiting or processing, so delete the submission if it's been in that state for an unreasonably long time.
|
||||||
|
if (submission.getCreatedAt().isBefore(deleteCutoff)) {
|
||||||
|
log.warn(
|
||||||
|
"Removing long-processing submission {} for user {} because it is waiting or processing for too long.",
|
||||||
|
submission.getId(), submission.getUser().getEmail()
|
||||||
|
);
|
||||||
|
submissionRepository.delete(submission);
|
||||||
|
// TODO: Send notification to user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Couldn't fetch status of long-processing submission " + submission.getId() + " for user " + submission.getUser().getEmail(), e);
|
||||||
|
// We can't reliably remove this submission yet, so we'll try again on the next pass.
|
||||||
|
if (submission.getCreatedAt().isBefore(deleteCutoff)) {
|
||||||
|
log.warn(
|
||||||
|
"Removing long-processing submission {} for user {} because it is waiting or processing for too long.",
|
||||||
|
submission.getId(), submission.getUser().getEmail()
|
||||||
|
);
|
||||||
|
submissionRepository.delete(submission);
|
||||||
|
// TODO: Send notification to user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.service.submission;
|
package nl.andrewlalis.gymboard_api.domains.api.service.submission;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionRepository;
|
import nl.andrewlalis.gymboard_api.domains.submission.dao.SubmissionRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse;
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
|
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
|
||||||
|
@ -9,10 +9,8 @@ import nl.andrewlalis.gymboard_api.util.PredicateBuilder;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
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 java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserAccountDataRequest;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserAccountDataRequest;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface UserAccountDataRequestRepository extends JpaRepository<UserAccountDataRequest, Long> {
|
public interface UserAccountDataRequestRepository extends JpaRepository<UserAccountDataRequest, Long> {
|
||||||
boolean existsByUserIdAndFulfilledFalse(String userId);
|
boolean existsByUserIdAndFulfilledFalse(String userId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByUser(User user);
|
||||||
}
|
}
|
||||||
|
|
|
@ -116,10 +116,15 @@ public class TokenService {
|
||||||
|
|
||||||
public Jws<Claims> getToken(String token) {
|
public Jws<Claims> getToken(String token) {
|
||||||
if (token == null) return null;
|
if (token == null) return null;
|
||||||
|
try {
|
||||||
var builder = Jwts.parserBuilder()
|
var builder = Jwts.parserBuilder()
|
||||||
.setSigningKey(this.getPrivateKey())
|
.setSigningKey(this.getPrivateKey())
|
||||||
.requireIssuer(ISSUER);
|
.requireIssuer(ISSUER);
|
||||||
return builder.build().parseClaimsJws(token);
|
return builder.build().parseClaimsJws(token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Error parsing JWT.", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private PrivateKey getPrivateKey() {
|
private PrivateKey getPrivateKey() {
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionRepository;
|
import nl.andrewlalis.gymboard_api.domains.submission.dao.SubmissionReportRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.dao.SubmissionRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.*;
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.*;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.dao.SubmissionVoteRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
@ -19,6 +21,9 @@ public class UserAccountDeletionService {
|
||||||
private final EmailResetCodeRepository emailResetCodeRepository;
|
private final EmailResetCodeRepository emailResetCodeRepository;
|
||||||
private final PasswordResetCodeRepository passwordResetCodeRepository;
|
private final PasswordResetCodeRepository passwordResetCodeRepository;
|
||||||
private final SubmissionRepository submissionRepository;
|
private final SubmissionRepository submissionRepository;
|
||||||
|
private final SubmissionReportRepository submissionReportRepository;
|
||||||
|
private final SubmissionVoteRepository submissionVoteRepository;
|
||||||
|
private final UserAccountDataRequestRepository accountDataRequestRepository;
|
||||||
|
|
||||||
public UserAccountDeletionService(UserRepository userRepository,
|
public UserAccountDeletionService(UserRepository userRepository,
|
||||||
UserReportRepository userReportRepository,
|
UserReportRepository userReportRepository,
|
||||||
|
@ -26,7 +31,10 @@ public class UserAccountDeletionService {
|
||||||
UserActivationCodeRepository userActivationCodeRepository,
|
UserActivationCodeRepository userActivationCodeRepository,
|
||||||
EmailResetCodeRepository emailResetCodeRepository,
|
EmailResetCodeRepository emailResetCodeRepository,
|
||||||
PasswordResetCodeRepository passwordResetCodeRepository,
|
PasswordResetCodeRepository passwordResetCodeRepository,
|
||||||
SubmissionRepository submissionRepository) {
|
SubmissionRepository submissionRepository,
|
||||||
|
SubmissionReportRepository submissionReportRepository,
|
||||||
|
SubmissionVoteRepository submissionVoteRepository,
|
||||||
|
UserAccountDataRequestRepository accountDataRequestRepository) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userReportRepository = userReportRepository;
|
this.userReportRepository = userReportRepository;
|
||||||
this.userFollowingRepository = userFollowingRepository;
|
this.userFollowingRepository = userFollowingRepository;
|
||||||
|
@ -34,6 +42,9 @@ public class UserAccountDeletionService {
|
||||||
this.emailResetCodeRepository = emailResetCodeRepository;
|
this.emailResetCodeRepository = emailResetCodeRepository;
|
||||||
this.passwordResetCodeRepository = passwordResetCodeRepository;
|
this.passwordResetCodeRepository = passwordResetCodeRepository;
|
||||||
this.submissionRepository = submissionRepository;
|
this.submissionRepository = submissionRepository;
|
||||||
|
this.submissionReportRepository = submissionReportRepository;
|
||||||
|
this.submissionVoteRepository = submissionVoteRepository;
|
||||||
|
this.accountDataRequestRepository = accountDataRequestRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
@ -46,6 +57,9 @@ public class UserAccountDeletionService {
|
||||||
userReportRepository.deleteAllByUserOrReportedBy(user, user);
|
userReportRepository.deleteAllByUserOrReportedBy(user, user);
|
||||||
userFollowingRepository.deleteAllByFollowedUserOrFollowingUser(user, user);
|
userFollowingRepository.deleteAllByFollowedUserOrFollowingUser(user, user);
|
||||||
submissionRepository.deleteAllByUser(user);
|
submissionRepository.deleteAllByUser(user);
|
||||||
|
submissionReportRepository.deleteAllByUser(user);
|
||||||
|
submissionVoteRepository.deleteAllByUser(user);
|
||||||
|
accountDataRequestRepository.deleteAllByUser(user);
|
||||||
userRepository.deleteById(user.getId());
|
userRepository.deleteById(user.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.controller;
|
package nl.andrewlalis.gymboard_api.domains.submission.controller;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dto.SubmissionResponse;
|
import nl.andrewlalis.gymboard_api.config.ServiceOnly;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.api.dto.VideoProcessingCompletePayload;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
|
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
@ -26,4 +28,10 @@ public class SubmissionController {
|
||||||
submissionService.deleteSubmission(submissionId, user);
|
submissionService.deleteSubmission(submissionId, user);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "/video-processing-complete") @ServiceOnly
|
||||||
|
public ResponseEntity<Void> handleVideoProcessingComplete(@RequestBody VideoProcessingCompletePayload taskStatus) {
|
||||||
|
submissionService.handleVideoProcessingComplete(taskStatus);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.submission.dao;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.model.SubmissionReport;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface SubmissionReportRepository extends JpaRepository<SubmissionReport, Long> {
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByUser(User user);
|
||||||
|
}
|
|
@ -1,14 +1,24 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.dao.submission;
|
package nl.andrewlalis.gymboard_api.domains.submission.dao;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.submission.Submission;
|
import nl.andrewlalis.gymboard_api.domains.submission.model.Submission;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface SubmissionRepository extends JpaRepository<Submission, String>, JpaSpecificationExecutor<Submission> {
|
public interface SubmissionRepository extends JpaRepository<Submission, String>, JpaSpecificationExecutor<Submission> {
|
||||||
@Modifying
|
@Modifying
|
||||||
void deleteAllByUser(User user);
|
void deleteAllByUser(User user);
|
||||||
|
|
||||||
|
@Query("SELECT s FROM Submission s " +
|
||||||
|
"WHERE s.videoProcessingTaskId = :taskId AND " +
|
||||||
|
"s.processing = TRUE")
|
||||||
|
List<Submission> findUnprocessedByTaskId(long taskId);
|
||||||
|
|
||||||
|
List<Submission> findAllByProcessingTrue();
|
||||||
}
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.submission.dao;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.model.SubmissionVote;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface SubmissionVoteRepository extends JpaRepository<SubmissionVote, Long> {
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByUser(User user);
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.submission.dto;
|
||||||
|
|
||||||
|
public record SubmissionPayload(
|
||||||
|
String exerciseShortName,
|
||||||
|
String performedAt,
|
||||||
|
float weight,
|
||||||
|
String weightUnit,
|
||||||
|
int reps,
|
||||||
|
long taskId
|
||||||
|
) {}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.submission.dto;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseResponse;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.api.dto.GymSimpleResponse;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.model.Submission;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.dto.UserResponse;
|
||||||
|
import nl.andrewlalis.gymboard_api.util.StandardDateFormatter;
|
||||||
|
|
||||||
|
public record SubmissionResponse(
|
||||||
|
String id,
|
||||||
|
String createdAt,
|
||||||
|
GymSimpleResponse gym,
|
||||||
|
UserResponse user,
|
||||||
|
long videoProcessingTaskId,
|
||||||
|
String videoFileId,
|
||||||
|
String thumbnailFileId,
|
||||||
|
boolean processing,
|
||||||
|
boolean verified,
|
||||||
|
|
||||||
|
// From SubmissionProperties
|
||||||
|
ExerciseResponse exercise,
|
||||||
|
String performedAt,
|
||||||
|
double rawWeight,
|
||||||
|
String weightUnit,
|
||||||
|
double metricWeight,
|
||||||
|
int reps
|
||||||
|
) {
|
||||||
|
public SubmissionResponse(Submission submission) {
|
||||||
|
this(
|
||||||
|
submission.getId(),
|
||||||
|
StandardDateFormatter.format(submission.getCreatedAt()),
|
||||||
|
new GymSimpleResponse(submission.getGym()),
|
||||||
|
new UserResponse(submission.getUser()),
|
||||||
|
submission.getVideoProcessingTaskId(),
|
||||||
|
submission.getVideoFileId(),
|
||||||
|
submission.getThumbnailFileId(),
|
||||||
|
submission.isProcessing(),
|
||||||
|
submission.isVerified(),
|
||||||
|
|
||||||
|
new ExerciseResponse(submission.getProperties().getExercise()),
|
||||||
|
StandardDateFormatter.format(submission.getCreatedAt()),
|
||||||
|
submission.getProperties().getRawWeight().doubleValue(),
|
||||||
|
submission.getProperties().getWeightUnit().name(),
|
||||||
|
submission.getProperties().getMetricWeight().doubleValue(),
|
||||||
|
submission.getProperties().getReps()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,159 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.submission.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Submission entity represents a user's posted video of a lift they did at
|
||||||
|
* a gym.
|
||||||
|
* <p>
|
||||||
|
* A submission is created in the front-end using the following flow:
|
||||||
|
* </p>
|
||||||
|
* <ol>
|
||||||
|
* <li>User uploads a raw video of their lift.</li>
|
||||||
|
* <li>User enters some basic information about the lift.</li>
|
||||||
|
* <li>User submits the lift.</li>
|
||||||
|
* <li>API validates the information.</li>
|
||||||
|
* <li>API creates a new Submission, and tells the CDN service to process
|
||||||
|
* the uploaded video.</li>
|
||||||
|
* <li>Once processing completes successfully, the CDN sends the final video
|
||||||
|
* and thumbnail file ids to the API and the Submission's "processing" flag
|
||||||
|
* is removed.</li>
|
||||||
|
* <li>If for whatever reason the CDN's video processing fails or never
|
||||||
|
* completes, the Submission is deleted and the user is notified of the
|
||||||
|
* issue.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "submission")
|
||||||
|
public class Submission {
|
||||||
|
@Id
|
||||||
|
@Column(nullable = false, updatable = false, length = 26)
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
|
private Gym gym;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id of the video processing task that a user gives to us when they
|
||||||
|
* create the submission, so that when the task finishes processing, we can
|
||||||
|
* route its data to the right submission.
|
||||||
|
*/
|
||||||
|
@Column
|
||||||
|
private Long videoProcessingTaskId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(length = 26)
|
||||||
|
private String videoFileId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id of the thumbnail file that was generated for this submission.
|
||||||
|
* Similarly to the video file id, it refers to a file managed by the CDN.
|
||||||
|
*/
|
||||||
|
@Column(length = 26)
|
||||||
|
private String thumbnailFileId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user-specified properties of the submission.
|
||||||
|
*/
|
||||||
|
@Embedded
|
||||||
|
private SubmissionProperties properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flag that indicates whether this submission is currently processing.
|
||||||
|
* A submission is processing until its associated processing task completes
|
||||||
|
* either successfully or unsuccessfully.
|
||||||
|
*/
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean processing;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean verified;
|
||||||
|
|
||||||
|
public Submission() {}
|
||||||
|
|
||||||
|
public Submission(
|
||||||
|
String id,
|
||||||
|
Gym gym,
|
||||||
|
User user,
|
||||||
|
long videoProcessingTaskId,
|
||||||
|
SubmissionProperties properties
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.gym = gym;
|
||||||
|
this.user = user;
|
||||||
|
this.videoProcessingTaskId = videoProcessingTaskId;
|
||||||
|
this.properties = properties;
|
||||||
|
this.verified = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Gym getGym() {
|
||||||
|
return gym;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getVideoProcessingTaskId() {
|
||||||
|
return videoProcessingTaskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVideoFileId() {
|
||||||
|
return videoFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getThumbnailFileId() {
|
||||||
|
return thumbnailFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVideoFileId(String videoFileId) {
|
||||||
|
this.videoFileId = videoFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setThumbnailFileId(String thumbnailFileId) {
|
||||||
|
this.thumbnailFileId = thumbnailFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubmissionProperties getProperties() {
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isProcessing() {
|
||||||
|
return processing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProcessing(boolean processing) {
|
||||||
|
this.processing = processing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isVerified() {
|
||||||
|
return verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerified(boolean verified) {
|
||||||
|
this.verified = verified;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.submission.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.api.model.Exercise;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic user-specified properties about a Submission.
|
||||||
|
*/
|
||||||
|
@Embeddable
|
||||||
|
public class SubmissionProperties {
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
|
private Exercise exercise;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime performedAt;
|
||||||
|
|
||||||
|
@Column(nullable = false, precision = 7, scale = 2)
|
||||||
|
private BigDecimal rawWeight;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private WeightUnit weightUnit;
|
||||||
|
|
||||||
|
@Column(nullable = false, precision = 7, scale = 2)
|
||||||
|
private BigDecimal metricWeight;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int reps;
|
||||||
|
|
||||||
|
public SubmissionProperties() {}
|
||||||
|
|
||||||
|
public SubmissionProperties(
|
||||||
|
Exercise exercise,
|
||||||
|
LocalDateTime performedAt,
|
||||||
|
BigDecimal rawWeight,
|
||||||
|
WeightUnit weightUnit,
|
||||||
|
int reps
|
||||||
|
) {
|
||||||
|
this.exercise = exercise;
|
||||||
|
this.performedAt = performedAt;
|
||||||
|
this.rawWeight = rawWeight;
|
||||||
|
this.weightUnit = weightUnit;
|
||||||
|
this.metricWeight = WeightUnit.toKilograms(rawWeight, weightUnit);
|
||||||
|
this.reps = reps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Exercise getExercise() {
|
||||||
|
return exercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getPerformedAt() {
|
||||||
|
return performedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getRawWeight() {
|
||||||
|
return rawWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WeightUnit getWeightUnit() {
|
||||||
|
return weightUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMetricWeight() {
|
||||||
|
return metricWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getReps() {
|
||||||
|
return reps;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.model.submission;
|
package nl.andrewlalis.gymboard_api.domains.submission.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.model.Submission;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
|
@ -1,7 +1,8 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.model.submission;
|
package nl.andrewlalis.gymboard_api.domains.submission.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.model.Submission;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(
|
@Table(
|
|
@ -56,7 +56,7 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
||||||
gen.generate();
|
gen.generate();
|
||||||
completed.add(gen);
|
completed.add(gen);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException("Generator failed: " + gen.getClass().getSimpleName());
|
throw new RuntimeException("Generator failed: " + gen.getClass().getSimpleName(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,25 @@
|
||||||
package nl.andrewlalis.gymboard_api.util.sample_data;
|
package nl.andrewlalis.gymboard_api.util.sample_data;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.ExerciseRepository;
|
import nl.andrewlalis.gymboard_api.domains.api.dao.ExerciseRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionRepository;
|
import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.dao.SubmissionRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.api.model.Exercise;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
|
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.Exercise;
|
import nl.andrewlalis.gymboard_api.domains.submission.model.Submission;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.submission.Submission;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient;
|
import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient;
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
|
import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.UploadsClient;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.submission.model.SubmissionProperties;
|
||||||
import nl.andrewlalis.gymboard_api.util.ULID;
|
import nl.andrewlalis.gymboard_api.util.ULID;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.data.util.Pair;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
@ -25,35 +29,31 @@ import java.util.*;
|
||||||
@Component
|
@Component
|
||||||
@Profile("development")
|
@Profile("development")
|
||||||
public class SampleSubmissionGenerator implements SampleDataGenerator {
|
public class SampleSubmissionGenerator implements SampleDataGenerator {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SampleSubmissionGenerator.class);
|
||||||
|
|
||||||
private final GymRepository gymRepository;
|
private final GymRepository gymRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final ExerciseRepository exerciseRepository;
|
private final ExerciseRepository exerciseRepository;
|
||||||
private final ExerciseSubmissionService submissionService;
|
|
||||||
private final SubmissionRepository submissionRepository;
|
private final SubmissionRepository submissionRepository;
|
||||||
private final ULID ulid;
|
private final ULID ulid;
|
||||||
|
private final CdnClient cdnClient;
|
||||||
|
|
||||||
@Value("${app.cdn-origin}")
|
public SampleSubmissionGenerator(GymRepository gymRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, SubmissionRepository submissionRepository, ULID ulid, CdnClient cdnClient) {
|
||||||
private String cdnOrigin;
|
|
||||||
|
|
||||||
public SampleSubmissionGenerator(GymRepository gymRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, ExerciseSubmissionService submissionService, SubmissionRepository submissionRepository, ULID ulid) {
|
|
||||||
this.gymRepository = gymRepository;
|
this.gymRepository = gymRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.exerciseRepository = exerciseRepository;
|
this.exerciseRepository = exerciseRepository;
|
||||||
this.submissionService = submissionService;
|
|
||||||
this.submissionRepository = submissionRepository;
|
this.submissionRepository = submissionRepository;
|
||||||
this.ulid = ulid;
|
this.ulid = ulid;
|
||||||
|
this.cdnClient = cdnClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void generate() throws Exception {
|
public void generate() throws Exception {
|
||||||
final CdnClient cdnClient = new CdnClient(cdnOrigin);
|
// First we generate a small set of uploaded files that all the
|
||||||
|
// submissions can link to, instead of having them all upload new content.
|
||||||
List<String> videoIds = new ArrayList<>();
|
var uploads = generateUploads();
|
||||||
var video1 = cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_curl.mp4"), "video/mp4");
|
|
||||||
var video2 = cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_ohp.mp4"), "video/mp4");
|
|
||||||
videoIds.add(video1.id());
|
|
||||||
videoIds.add(video2.id());
|
|
||||||
|
|
||||||
|
// Now that uploads are complete, we can proceed with generating the submissions.
|
||||||
List<Gym> gyms = gymRepository.findAll();
|
List<Gym> gyms = gymRepository.findAll();
|
||||||
List<User> users = userRepository.findAll();
|
List<User> users = userRepository.findAll();
|
||||||
List<Exercise> exercises = exerciseRepository.findAll();
|
List<Exercise> exercises = exerciseRepository.findAll();
|
||||||
|
@ -65,15 +65,16 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
|
||||||
Random random = new Random(1);
|
Random random = new Random(1);
|
||||||
List<Submission> submissions = new ArrayList<>(count);
|
List<Submission> submissions = new ArrayList<>(count);
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
submissions.add(generateRandomSubmission(
|
Submission submission = generateRandomSubmission(
|
||||||
gyms,
|
gyms,
|
||||||
users,
|
users,
|
||||||
exercises,
|
exercises,
|
||||||
videoIds,
|
uploads,
|
||||||
earliestSubmission,
|
earliestSubmission,
|
||||||
latestSubmission,
|
latestSubmission,
|
||||||
random
|
random
|
||||||
));
|
);
|
||||||
|
submissions.add(submission);
|
||||||
}
|
}
|
||||||
submissionRepository.saveAll(submissions);
|
submissionRepository.saveAll(submissions);
|
||||||
}
|
}
|
||||||
|
@ -82,7 +83,7 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
|
||||||
List<Gym> gyms,
|
List<Gym> gyms,
|
||||||
List<User> users,
|
List<User> users,
|
||||||
List<Exercise> exercises,
|
List<Exercise> exercises,
|
||||||
List<String> videoIds,
|
Map<Long, Pair<String, String>> uploads,
|
||||||
LocalDateTime earliestSubmission,
|
LocalDateTime earliestSubmission,
|
||||||
LocalDateTime latestSubmission,
|
LocalDateTime latestSubmission,
|
||||||
Random random
|
Random random
|
||||||
|
@ -95,20 +96,26 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
|
||||||
weightUnit = WeightUnit.POUNDS;
|
weightUnit = WeightUnit.POUNDS;
|
||||||
rawWeight = metricWeight.multiply(new BigDecimal("2.2046226218"));
|
rawWeight = metricWeight.multiply(new BigDecimal("2.2046226218"));
|
||||||
}
|
}
|
||||||
|
SubmissionProperties properties = new SubmissionProperties(
|
||||||
|
randomChoice(exercises, random),
|
||||||
|
time,
|
||||||
|
rawWeight,
|
||||||
|
weightUnit,
|
||||||
|
random.nextInt(13) + 1
|
||||||
|
);
|
||||||
|
|
||||||
var submission = new Submission(
|
var submission = new Submission(
|
||||||
ulid.nextULID(),
|
ulid.nextULID(),
|
||||||
randomChoice(gyms, random),
|
randomChoice(gyms, random),
|
||||||
randomChoice(exercises, random),
|
|
||||||
randomChoice(users, random),
|
randomChoice(users, random),
|
||||||
time,
|
randomChoice(new ArrayList<>(uploads.keySet()), random),
|
||||||
randomChoice(videoIds, random),
|
properties
|
||||||
rawWeight,
|
|
||||||
weightUnit,
|
|
||||||
metricWeight,
|
|
||||||
random.nextInt(13) + 1
|
|
||||||
);
|
);
|
||||||
submission.setVerified(true);
|
submission.setVerified(true);
|
||||||
|
submission.setProcessing(false);
|
||||||
|
var uploadData = uploads.get(submission.getVideoProcessingTaskId());
|
||||||
|
submission.setVideoFileId(uploadData.getFirst());
|
||||||
|
submission.setThumbnailFileId(uploadData.getSecond());
|
||||||
return submission;
|
return submission;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,4 +132,46 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
|
||||||
Duration dur = Duration.between(start, end);
|
Duration dur = Duration.between(start, end);
|
||||||
return start.plusSeconds(rand.nextLong(dur.toSeconds() + 1));
|
return start.plusSeconds(rand.nextLong(dur.toSeconds() + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a set of sample video uploads to use for all the sample
|
||||||
|
* submissions.
|
||||||
|
* @return A map containing keys representing video processing task ids, and
|
||||||
|
* values being a pair of video and thumbnail file ids.
|
||||||
|
* @throws Exception If an error occurs.
|
||||||
|
*/
|
||||||
|
private Map<Long, Pair<String, String>> generateUploads() throws Exception {
|
||||||
|
List<Long> taskIds = new ArrayList<>();
|
||||||
|
taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_curl.mp4"), "video/mp4"));
|
||||||
|
taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_ohp.mp4"), "video/mp4"));
|
||||||
|
|
||||||
|
Map<Long, UploadsClient.VideoProcessingTaskStatusResponse> taskStatus = new HashMap<>();
|
||||||
|
for (long taskId : taskIds) {
|
||||||
|
cdnClient.uploads.startTask(taskId);
|
||||||
|
taskStatus.put(taskId, cdnClient.uploads.getVideoProcessingTaskStatus(taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all video uploads to complete.
|
||||||
|
while (
|
||||||
|
taskStatus.values().stream()
|
||||||
|
.map(UploadsClient.VideoProcessingTaskStatusResponse::status)
|
||||||
|
.anyMatch(status -> !List.of("COMPLETED", "FAILED").contains(status.toUpperCase()))
|
||||||
|
) {
|
||||||
|
log.info("Waiting for sample video upload tasks to finish...");
|
||||||
|
Thread.sleep(1000);
|
||||||
|
for (long taskId : taskIds) taskStatus.put(taskId, cdnClient.uploads.getVideoProcessingTaskStatus(taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any upload failed, throw an exception and cancel this generator.
|
||||||
|
if (taskStatus.values().stream().anyMatch(r -> r.status().equalsIgnoreCase("FAILED"))) {
|
||||||
|
throw new IOException("Video upload task processing failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the final data structure.
|
||||||
|
Map<Long, Pair<String, String>> finalResults = new HashMap<>();
|
||||||
|
for (var entry : taskStatus.entrySet()) {
|
||||||
|
finalResults.put(entry.getKey(), Pair.of(entry.getValue().videoFileId(), entry.getValue().thumbnailFileId()));
|
||||||
|
}
|
||||||
|
return finalResults;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,9 @@ spring.mail.protocol=smtp
|
||||||
spring.mail.properties.mail.smtp.timeout=10000
|
spring.mail.properties.mail.smtp.timeout=10000
|
||||||
|
|
||||||
app.auth.private-key-location=./private_key.der
|
app.auth.private-key-location=./private_key.der
|
||||||
|
app.service-secret=testing
|
||||||
app.web-origin=http://localhost:9000
|
app.web-origin=http://localhost:9000
|
||||||
app.cdn-origin=http://localhost:8082
|
app.cdn-origin=http://localhost:8082
|
||||||
|
app.cdn-secret=testing
|
||||||
|
|
||||||
#logging.level.root=DEBUG
|
#logging.level.root=DEBUG
|
||||||
|
|
|
@ -8,6 +8,7 @@ const api = axios.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
export enum VideoProcessingStatus {
|
export enum VideoProcessingStatus {
|
||||||
|
NOT_STARTED = 'NOT_STARTED',
|
||||||
WAITING = 'WAITING',
|
WAITING = 'WAITING',
|
||||||
IN_PROGRESS = 'IN_PROGRESS',
|
IN_PROGRESS = 'IN_PROGRESS',
|
||||||
COMPLETED = 'COMPLETED',
|
COMPLETED = 'COMPLETED',
|
||||||
|
@ -18,24 +19,21 @@ export interface FileMetadata {
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
uploadedAt: string;
|
createdAt: string;
|
||||||
availableForDownload: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadVideoToCDN(file: File): Promise<string> {
|
export async function uploadVideoToCDN(file: File): Promise<number> {
|
||||||
const response = await api.post('/uploads/video', file, {
|
const response = await api.post('/uploads/video', file, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': file.type,
|
'Content-Type': file.type,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data.id;
|
return response.data.taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVideoProcessingStatus(
|
export async function getVideoProcessingStatus(taskId: number): Promise<VideoProcessingStatus | null> {
|
||||||
id: string
|
|
||||||
): Promise<VideoProcessingStatus | null> {
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/uploads/video/${id}/status`);
|
const response = await api.get(`/uploads/video/${taskId}/status`);
|
||||||
return response.data.status;
|
return response.data.status;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.response && error.response.status === 404) {
|
if (error.response && error.response.status === 404) {
|
||||||
|
@ -45,16 +43,14 @@ export async function getVideoProcessingStatus(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitUntilVideoProcessingComplete(
|
export async function waitUntilVideoProcessingComplete(taskId: number): Promise<VideoProcessingStatus> {
|
||||||
id: string
|
|
||||||
): Promise<VideoProcessingStatus> {
|
|
||||||
let failureCount = 0;
|
let failureCount = 0;
|
||||||
let attemptCount = 0;
|
let attemptCount = 0;
|
||||||
while (failureCount < 5 && attemptCount < 60) {
|
while (failureCount < 5 && attemptCount < 60) {
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
attemptCount++;
|
attemptCount++;
|
||||||
try {
|
try {
|
||||||
const status = await getVideoProcessingStatus(id);
|
const status = await getVideoProcessingStatus(taskId);
|
||||||
failureCount = 0;
|
failureCount = 0;
|
||||||
if (
|
if (
|
||||||
status === VideoProcessingStatus.COMPLETED ||
|
status === VideoProcessingStatus.COMPLETED ||
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { GeoPoint } from 'src/api/main/models';
|
import { GeoPoint } from 'src/api/main/models';
|
||||||
import SubmissionsModule, { ExerciseSubmission, parseSubmission } from 'src/api/main/submission';
|
import SubmissionsModule, { Submission, parseSubmission } from 'src/api/main/submission';
|
||||||
import { api } from 'src/api/main/index';
|
import { api } from 'src/api/main/index';
|
||||||
import { GymRoutable } from 'src/router/gym-routing';
|
import { GymRoutable } from 'src/router/gym-routing';
|
||||||
|
|
||||||
|
@ -51,7 +51,7 @@ class GymsModule {
|
||||||
|
|
||||||
public async getRecentSubmissions(
|
public async getRecentSubmissions(
|
||||||
gym: GymRoutable
|
gym: GymRoutable
|
||||||
): Promise<Array<ExerciseSubmission>> {
|
): Promise<Array<Submission>> {
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/recent-submissions`
|
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/recent-submissions`
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ExerciseSubmission, parseSubmission } from 'src/api/main/submission';
|
import { Submission, parseSubmission } from 'src/api/main/submission';
|
||||||
import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
|
import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
|
||||||
import { api } from 'src/api/main/index';
|
import { api } from 'src/api/main/index';
|
||||||
|
|
||||||
|
@ -29,7 +29,7 @@ interface RequestParams {
|
||||||
class LeaderboardsModule {
|
class LeaderboardsModule {
|
||||||
public async getLeaderboard(
|
public async getLeaderboard(
|
||||||
params: LeaderboardParams
|
params: LeaderboardParams
|
||||||
): Promise<Array<ExerciseSubmission>> {
|
): Promise<Array<Submission>> {
|
||||||
const requestParams: RequestParams = {};
|
const requestParams: RequestParams = {};
|
||||||
if (params.exerciseShortName) {
|
if (params.exerciseShortName) {
|
||||||
requestParams.exercise = params.exerciseShortName;
|
requestParams.exercise = params.exerciseShortName;
|
||||||
|
|
|
@ -14,7 +14,7 @@ export interface ExerciseSubmissionPayload {
|
||||||
weight: number;
|
weight: number;
|
||||||
weightUnit: string;
|
weightUnit: string;
|
||||||
reps: number;
|
reps: number;
|
||||||
videoFileId: string;
|
taskId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WeightUnit {
|
export enum WeightUnit {
|
||||||
|
@ -29,30 +29,35 @@ export class WeightUnitUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExerciseSubmission {
|
export interface Submission {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: DateTime;
|
createdAt: DateTime;
|
||||||
gym: SimpleGym;
|
gym: SimpleGym;
|
||||||
exercise: Exercise;
|
|
||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
|
videoFileId: string | null;
|
||||||
|
thumbnailFileId: string | null;
|
||||||
|
processing: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
|
||||||
|
exercise: Exercise;
|
||||||
performedAt: DateTime;
|
performedAt: DateTime;
|
||||||
videoFileId: string;
|
|
||||||
rawWeight: number;
|
rawWeight: number;
|
||||||
weightUnit: WeightUnit;
|
weightUnit: WeightUnit;
|
||||||
metricWeight: number;
|
metricWeight: number;
|
||||||
reps: number;
|
reps: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSubmission(data: any): ExerciseSubmission {
|
export function parseSubmission(data: any): Submission {
|
||||||
data.createdAt = DateTime.fromISO(data.createdAt);
|
data.createdAt = DateTime.fromISO(data.createdAt);
|
||||||
data.performedAt = DateTime.fromISO(data.performedAt);
|
data.performedAt = DateTime.fromISO(data.performedAt);
|
||||||
return data as ExerciseSubmission;
|
return data as Submission;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SubmissionsModule {
|
class SubmissionsModule {
|
||||||
public async getSubmission(
|
public async getSubmission(
|
||||||
submissionId: string
|
submissionId: string
|
||||||
): Promise<ExerciseSubmission> {
|
): Promise<Submission> {
|
||||||
const response = await api.get(`/submissions/${submissionId}`);
|
const response = await api.get(`/submissions/${submissionId}`);
|
||||||
return parseSubmission(response.data);
|
return parseSubmission(response.data);
|
||||||
}
|
}
|
||||||
|
@ -61,7 +66,7 @@ class SubmissionsModule {
|
||||||
gym: GymRoutable,
|
gym: GymRoutable,
|
||||||
payload: ExerciseSubmissionPayload,
|
payload: ExerciseSubmissionPayload,
|
||||||
authStore: AuthStoreType
|
authStore: AuthStoreType
|
||||||
): Promise<ExerciseSubmission> {
|
): Promise<Submission> {
|
||||||
const gymId = getGymCompoundId(gym);
|
const gymId = getGymCompoundId(gym);
|
||||||
const response = await api.post(`/gyms/${gymId}/submissions`, payload, authStore.axiosConfig);
|
const response = await api.post(`/gyms/${gymId}/submissions`, payload, authStore.axiosConfig);
|
||||||
return parseSubmission(response.data);
|
return parseSubmission(response.data);
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import {api} from 'src/api/main';
|
import {api} from 'src/api/main';
|
||||||
import {AuthStoreType} from 'stores/auth-store';
|
import {AuthStoreType} from 'stores/auth-store';
|
||||||
import {ExerciseSubmission, parseSubmission} from 'src/api/main/submission';
|
import {Submission, parseSubmission} from 'src/api/main/submission';
|
||||||
import {defaultPaginationOptions, Page, PaginationOptions, toQueryParams} from 'src/api/main/models';
|
import {defaultPaginationOptions, Page, PaginationOptions, toQueryParams} from 'src/api/main/models';
|
||||||
|
|
||||||
class UsersModule {
|
class UsersModule {
|
||||||
public async getRecentSubmissions(userId: string, authStore: AuthStoreType): Promise<Array<ExerciseSubmission>> {
|
public async getRecentSubmissions(userId: string, authStore: AuthStoreType): Promise<Array<Submission>> {
|
||||||
const response = await api.get(`/users/${userId}/recent-submissions`, authStore.axiosConfig);
|
const response = await api.get(`/users/${userId}/recent-submissions`, authStore.axiosConfig);
|
||||||
return response.data.map(parseSubmission);
|
return response.data.map(parseSubmission);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSubmissions(userId: string, authStore: AuthStoreType, paginationOptions: PaginationOptions = defaultPaginationOptions()): Promise<Page<ExerciseSubmission>> {
|
public async getSubmissions(userId: string, authStore: AuthStoreType, paginationOptions: PaginationOptions = defaultPaginationOptions()): Promise<Page<Submission>> {
|
||||||
const config = structuredClone(authStore.axiosConfig);
|
const config = structuredClone(authStore.axiosConfig);
|
||||||
config.params = toQueryParams(paginationOptions);
|
config.params = toQueryParams(paginationOptions);
|
||||||
const response = await api.get(`/users/${userId}/submissions`, config);
|
const response = await api.get(`/users/${userId}/submissions`, config);
|
||||||
|
|
|
@ -19,11 +19,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ExerciseSubmission, WeightUnitUtil } from 'src/api/main/submission';
|
import { Submission, WeightUnitUtil } from 'src/api/main/submission';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
submission: ExerciseSubmission;
|
submission: Submission;
|
||||||
showName?: boolean;
|
showName?: boolean;
|
||||||
showGym?: boolean;
|
showGym?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,8 +51,8 @@ export default {
|
||||||
upload: 'Video File to Upload',
|
upload: 'Video File to Upload',
|
||||||
submit: 'Submit',
|
submit: 'Submit',
|
||||||
submitUploading: 'Uploading video...',
|
submitUploading: 'Uploading video...',
|
||||||
|
submitUploadFailed: 'Video upload failed.',
|
||||||
submitCreatingSubmission: 'Creating submission...',
|
submitCreatingSubmission: 'Creating submission...',
|
||||||
submitVideoProcessing: 'Processing...',
|
|
||||||
submitComplete: 'Submission complete!',
|
submitComplete: 'Submission complete!',
|
||||||
submitFailed: 'Submission processing failed. Please try again later.',
|
submitFailed: 'Submission processing failed. Please try again later.',
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
<q-page>
|
<q-page>
|
||||||
<StandardCenteredPage v-if="submission">
|
<StandardCenteredPage v-if="submission">
|
||||||
<video
|
<video
|
||||||
|
v-if="!submission.processing"
|
||||||
class="submission-video"
|
class="submission-video"
|
||||||
:src="getFileUrl(submission.videoFileId)"
|
:src="getFileUrl(submission.videoFileId)"
|
||||||
loop
|
loop
|
||||||
|
@ -10,6 +11,9 @@
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
autoplay
|
autoplay
|
||||||
/>
|
/>
|
||||||
|
<div v-if="submission.processing">
|
||||||
|
<p>This submission is still processing.</p>
|
||||||
|
</div>
|
||||||
<h3>
|
<h3>
|
||||||
{{ submission.rawWeight }} {{ WeightUnitUtil.toAbbreviation(submission.weightUnit) }}
|
{{ submission.rawWeight }} {{ WeightUnitUtil.toAbbreviation(submission.weightUnit) }}
|
||||||
{{ submission.exercise.displayName }}
|
{{ submission.exercise.displayName }}
|
||||||
|
@ -23,7 +27,7 @@
|
||||||
|
|
||||||
<!-- Deletion button is only visible if the user who submitted it is viewing it. -->
|
<!-- Deletion button is only visible if the user who submitted it is viewing it. -->
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="authStore.user && authStore.user.id === submission.user.id"
|
v-if="authStore.user && authStore.user.id === submission.user.id && !submission.processing"
|
||||||
label="Delete"
|
label="Delete"
|
||||||
@click="deleteSubmission"
|
@click="deleteSubmission"
|
||||||
/>
|
/>
|
||||||
|
@ -34,18 +38,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
|
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
|
||||||
import { ExerciseSubmission, WeightUnitUtil } from 'src/api/main/submission';
|
import { Submission, WeightUnitUtil } from 'src/api/main/submission';
|
||||||
import { onMounted, ref, Ref } from 'vue';
|
import { onMounted, ref, Ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { getFileUrl } from 'src/api/cdn';
|
import { getFileUrl } from 'src/api/cdn';
|
||||||
import { getGymRoute } from 'src/router/gym-routing';
|
import { getGymRoute } from 'src/router/gym-routing';
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import {confirm, showApiErrorToast} from 'src/utils';
|
import {confirm, showApiErrorToast, showInfoToast} from 'src/utils';
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import {useQuasar} from 'quasar';
|
import {useQuasar} from 'quasar';
|
||||||
|
|
||||||
const submission: Ref<ExerciseSubmission | undefined> = ref();
|
const submission: Ref<Submission | undefined> = ref();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -54,14 +58,24 @@ const i18n = useI18n();
|
||||||
const quasar = useQuasar();
|
const quasar = useQuasar();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadSubmission();
|
||||||
|
if (submission.value?.processing) {
|
||||||
|
showInfoToast('This submission is still processing.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSubmission() {
|
||||||
const submissionId = route.params.submissionId as string;
|
const submissionId = route.params.submissionId as string;
|
||||||
try {
|
try {
|
||||||
submission.value = await api.gyms.submissions.getSubmission(submissionId);
|
submission.value = await api.gyms.submissions.getSubmission(submissionId);
|
||||||
|
if (submission.value.processing) {
|
||||||
|
setTimeout(loadSubmission, 3000);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
showApiErrorToast(error);
|
||||||
await router.push('/');
|
await router.push('/');
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a confirmation dialog asking the user if they really want to delete
|
* Shows a confirmation dialog asking the user if they really want to delete
|
||||||
|
|
|
@ -43,7 +43,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onMounted, ref, Ref } from 'vue';
|
import { nextTick, onMounted, ref, Ref } from 'vue';
|
||||||
import { ExerciseSubmission } from 'src/api/main/submission';
|
import { Submission } from 'src/api/main/submission';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
import { getGymFromRoute } from 'src/router/gym-routing';
|
import { getGymFromRoute } from 'src/router/gym-routing';
|
||||||
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
|
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
|
||||||
|
@ -51,7 +51,7 @@ import { Gym } from 'src/api/main/gyms';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { Map, Marker, TileLayer } from 'leaflet';
|
import { Map, Marker, TileLayer } from 'leaflet';
|
||||||
|
|
||||||
const recentSubmissions: Ref<Array<ExerciseSubmission>> = ref([]);
|
const recentSubmissions: Ref<Array<Submission>> = ref([]);
|
||||||
const gym: Ref<Gym | undefined> = ref();
|
const gym: Ref<Gym | undefined> = ref();
|
||||||
|
|
||||||
const TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
const TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||||
|
|
|
@ -33,13 +33,13 @@ import api from 'src/api/main';
|
||||||
import { Exercise } from 'src/api/main/exercises';
|
import { Exercise } from 'src/api/main/exercises';
|
||||||
import { Gym } from 'src/api/main/gyms';
|
import { Gym } from 'src/api/main/gyms';
|
||||||
import { LeaderboardTimeframe } from 'src/api/main/leaderboards';
|
import { LeaderboardTimeframe } from 'src/api/main/leaderboards';
|
||||||
import { ExerciseSubmission } from 'src/api/main/submission';
|
import { Submission } from 'src/api/main/submission';
|
||||||
import ExerciseSubmissionListItem from 'src/components/ExerciseSubmissionListItem.vue';
|
import ExerciseSubmissionListItem from 'src/components/ExerciseSubmissionListItem.vue';
|
||||||
import { getGymFromRoute } from 'src/router/gym-routing';
|
import { getGymFromRoute } from 'src/router/gym-routing';
|
||||||
import { sleep } from 'src/utils';
|
import { sleep } from 'src/utils';
|
||||||
import { onMounted, ref, Ref, watch, computed } from 'vue';
|
import { onMounted, ref, Ref, watch, computed } from 'vue';
|
||||||
|
|
||||||
const submissions: Ref<Array<ExerciseSubmission>> = ref([]);
|
const submissions: Ref<Array<Submission>> = ref([]);
|
||||||
const gym: Ref<Gym | undefined> = ref();
|
const gym: Ref<Gym | undefined> = ref();
|
||||||
const exercises: Ref<Array<Exercise>> = ref([]);
|
const exercises: Ref<Array<Exercise>> = ref([]);
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@ A high-level overview of the submission process is as follows:
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="submissionModel.date"
|
v-model="submissionModel.performedAt"
|
||||||
type="date"
|
type="date"
|
||||||
:label="$t('gymPage.submitPage.date')"
|
:label="$t('gymPage.submitPage.date')"
|
||||||
class="col-12"
|
class="col-12"
|
||||||
|
@ -91,28 +91,22 @@ A high-level overview of the submission process is as follows:
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, Ref } from 'vue';
|
import {onMounted, ref, Ref} from 'vue';
|
||||||
import { getGymFromRoute } from 'src/router/gym-routing';
|
import {getGymFromRoute} from 'src/router/gym-routing';
|
||||||
import SlimForm from 'components/SlimForm.vue';
|
import SlimForm from 'components/SlimForm.vue';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
import { Gym } from 'src/api/main/gyms';
|
import {Gym} from 'src/api/main/gyms';
|
||||||
import { Exercise } from 'src/api/main/exercises';
|
import {Exercise} from 'src/api/main/exercises';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
import { showApiErrorToast, sleep } from 'src/utils';
|
import {showApiErrorToast, showWarningToast, sleep} from 'src/utils';
|
||||||
import {
|
import {uploadVideoToCDN,} from 'src/api/cdn';
|
||||||
uploadVideoToCDN,
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
VideoProcessingStatus,
|
import {useI18n} from 'vue-i18n';
|
||||||
waitUntilVideoProcessingComplete,
|
|
||||||
} from 'src/api/cdn';
|
|
||||||
import { useAuthStore } from 'stores/auth-store';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useQuasar } from 'quasar';
|
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const quasar = useQuasar();
|
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -127,8 +121,8 @@ let submissionModel = ref({
|
||||||
weight: 100,
|
weight: 100,
|
||||||
weightUnit: 'Kg',
|
weightUnit: 'Kg',
|
||||||
reps: 1,
|
reps: 1,
|
||||||
videoFileId: '',
|
performedAt: new Date().toLocaleDateString('en-CA'),
|
||||||
date: new Date().toLocaleDateString('en-CA'),
|
taskId: -1
|
||||||
});
|
});
|
||||||
const selectedVideoFile: Ref<File | undefined> = ref<File>();
|
const selectedVideoFile: Ref<File | undefined> = ref<File>();
|
||||||
const weightUnits = ['KG', 'LBS'];
|
const weightUnits = ['KG', 'LBS'];
|
||||||
|
@ -169,59 +163,53 @@ async function onSubmitted() {
|
||||||
if (!selectedVideoFile.value || !gym.value) return;
|
if (!selectedVideoFile.value || !gym.value) return;
|
||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
|
if (await uploadVideo()) {
|
||||||
|
await createSubmission();
|
||||||
|
}
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads the selected video and returns true if successful.
|
||||||
|
*/
|
||||||
|
async function uploadVideo(): Promise<boolean> {
|
||||||
|
if (!selectedVideoFile.value) return false;
|
||||||
try {
|
try {
|
||||||
// 1. Upload the video to the CDN.
|
// 1. Upload the video to the CDN.
|
||||||
submitButtonLabel.value = i18n.t('gymPage.submitPage.submitUploading');
|
submitButtonLabel.value = i18n.t('gymPage.submitPage.submitUploading');
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
submissionModel.value.videoFileId = await uploadVideoToCDN(
|
submissionModel.value.taskId = await uploadVideoToCDN(selectedVideoFile.value);
|
||||||
selectedVideoFile.value
|
return true;
|
||||||
);
|
} catch (error) {
|
||||||
|
showApiErrorToast(error);
|
||||||
// 2. Wait for the video to be processed.
|
submitButtonLabel.value = i18n.t('gymPage.submitPage.submitUploadFailed');
|
||||||
submitButtonLabel.value = i18n.t(
|
|
||||||
'gymPage.submitPage.submitVideoProcessing'
|
|
||||||
);
|
|
||||||
const processingStatus = await waitUntilVideoProcessingComplete(
|
|
||||||
submissionModel.value.videoFileId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. If successful upload, create the submission.
|
|
||||||
if (processingStatus === VideoProcessingStatus.COMPLETED) {
|
|
||||||
try {
|
|
||||||
submitButtonLabel.value = i18n.t(
|
|
||||||
'gymPage.submitPage.submitCreatingSubmission'
|
|
||||||
);
|
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
const submission = await api.gyms.submissions.createSubmission(
|
selectedVideoFile.value = undefined;
|
||||||
gym.value,
|
submitButtonLabel.value = i18n.t('gymPage.submitPage.submit');
|
||||||
submissionModel.value,
|
return false;
|
||||||
authStore
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to create a new submission, and if successful, redirects the user to it.
|
||||||
|
*/
|
||||||
|
async function createSubmission() {
|
||||||
|
if (!gym.value) return;
|
||||||
|
try {
|
||||||
|
submitButtonLabel.value = i18n.t('gymPage.submitPage.submitCreatingSubmission');
|
||||||
|
await sleep(1000);
|
||||||
|
const submission = await api.gyms.submissions.createSubmission(gym.value, submissionModel.value, authStore);
|
||||||
submitButtonLabel.value = i18n.t('gymPage.submitPage.submitComplete');
|
submitButtonLabel.value = i18n.t('gymPage.submitPage.submitComplete');
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
await router.push(`/submissions/${submission.id}`);
|
await router.push(`/submissions/${submission.id}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.response && error.response.status === 400) {
|
if (error.response && error.response.status === 400) {
|
||||||
quasar.notify({
|
showWarningToast(error.response.data.message);
|
||||||
message: error.response.data.message,
|
|
||||||
type: 'warning',
|
|
||||||
position: 'top',
|
|
||||||
});
|
|
||||||
submitButtonLabel.value = i18n.t('gymPage.submitPage.submitFailed');
|
submitButtonLabel.value = i18n.t('gymPage.submitPage.submitFailed');
|
||||||
await sleep(3000);
|
|
||||||
} else {
|
} else {
|
||||||
showApiErrorToast(error);
|
showApiErrorToast(error);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Otherwise, report the failed submission and give up.
|
|
||||||
} else {
|
|
||||||
submitButtonLabel.value = i18n.t('gymPage.submitPage.submitFailed');
|
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
showApiErrorToast(error);
|
|
||||||
} finally {
|
|
||||||
submitting.value = false;
|
|
||||||
submitButtonLabel.value = i18n.t('gymPage.submitPage.submit');
|
submitButtonLabel.value = i18n.t('gymPage.submitPage.submit');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vu
|
||||||
import {showApiErrorToast} from 'src/utils';
|
import {showApiErrorToast} from 'src/utils';
|
||||||
import {PaginationHelpers} from 'src/api/main/models';
|
import {PaginationHelpers} from 'src/api/main/models';
|
||||||
import InfinitePageLoader from 'src/api/infinite-page-loader';
|
import InfinitePageLoader from 'src/api/infinite-page-loader';
|
||||||
import {ExerciseSubmission} from 'src/api/main/submission';
|
import {Submission} from 'src/api/main/submission';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
@ -30,7 +30,7 @@ const props = defineProps<Props>();
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const submissions: Ref<ExerciseSubmission[]> = ref([]);
|
const submissions: Ref<Submission[]> = ref([]);
|
||||||
const loader = new InfinitePageLoader(submissions, async paginationOptions => {
|
const loader = new InfinitePageLoader(submissions, async paginationOptions => {
|
||||||
try {
|
try {
|
||||||
return await api.users.getSubmissions(props.userId, authStore, paginationOptions);
|
return await api.users.getSubmissions(props.userId, authStore, paginationOptions);
|
||||||
|
@ -41,7 +41,7 @@ const loader = new InfinitePageLoader(submissions, async paginationOptions => {
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loader.registerWindowScrollListener();
|
loader.registerWindowScrollListener();
|
||||||
await loader.setPagination(PaginationHelpers.sortedDescBy('performedAt'));
|
await loader.setPagination(PaginationHelpers.sortedDescBy('properties.performedAt'));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
package nl.andrewlalis.gymboardcdn;
|
package nl.andrewlalis.gymboardcdn;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboardcdn.util.ULID;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
|
||||||
|
import nl.andrewlalis.gymboardcdn.files.util.ULID;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.service.process.FfmpegThumbnailGenerator;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.service.process.FfmpegVideoProcessor;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.service.process.ThumbnailGenerator;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.service.process.VideoProcessor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
@ -8,17 +14,32 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
import org.springframework.web.filter.CorsFilter;
|
import org.springframework.web.filter.CorsFilter;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class Config {
|
public class Config implements WebMvcConfigurer {
|
||||||
@Value("${app.web-origin}")
|
@Value("${app.web-origin}")
|
||||||
private String webOrigin;
|
private String webOrigin;
|
||||||
@Value("${app.api-origin}")
|
@Value("${app.api-origin}")
|
||||||
private String apiOrigin;
|
private String apiOrigin;
|
||||||
|
|
||||||
|
private final ServiceAccessInterceptor serviceAccessInterceptor;
|
||||||
|
|
||||||
|
public Config(ServiceAccessInterceptor serviceAccessInterceptor) {
|
||||||
|
this.serviceAccessInterceptor = serviceAccessInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(serviceAccessInterceptor);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsFilter corsFilter() {
|
public CorsFilter corsFilter() {
|
||||||
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
@ -36,4 +57,29 @@ public class Config {
|
||||||
public ULID ulid() {
|
public ULID ulid() {
|
||||||
return new ULID();
|
return new ULID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ObjectMapper objectMapper() {
|
||||||
|
return new ObjectMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public FileStorageService fileStorageService() {
|
||||||
|
return new FileStorageService(ulid(), objectMapper(), "cdn-files");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public VideoProcessor videoProcessor() {
|
||||||
|
return new FfmpegVideoProcessor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ThumbnailGenerator thumbnailGenerator() {
|
||||||
|
return new FfmpegThumbnailGenerator();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Executor videoProcessingExecutor() {
|
||||||
|
return Executors.newFixedThreadPool(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.method.HandlerMethod;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interceptor that checks that requests to endpoints annotated with
|
||||||
|
* {@link ServiceOnly} have a valid service secret header value.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ServiceAccessInterceptor implements HandlerInterceptor {
|
||||||
|
private static final String HEADER_NAME = "X-Gymboard-Service-Secret";
|
||||||
|
|
||||||
|
@Value("${app.service-secret}")
|
||||||
|
private String serviceSecret;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
Method handlerMethod = ((HandlerMethod) handler).getMethod();
|
||||||
|
Class<?> handlerClass = handlerMethod.getDeclaringClass();
|
||||||
|
|
||||||
|
ServiceOnly methodAnnotation = handlerMethod.getAnnotation(ServiceOnly.class);
|
||||||
|
ServiceOnly classAnnotation = handlerClass.getAnnotation(ServiceOnly.class);
|
||||||
|
if (methodAnnotation != null || classAnnotation != null) {
|
||||||
|
String secret = request.getHeader(HEADER_NAME);
|
||||||
|
if (secret == null || !secret.trim().equals(serviceSecret)) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation that can be applied to a controller or controller method to
|
||||||
|
* restrict access to only requests from another service that provide a
|
||||||
|
* legitimate service secret.
|
||||||
|
* @see ServiceAccessInterceptor
|
||||||
|
*/
|
||||||
|
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface ServiceOnly {}
|
|
@ -1,4 +1,4 @@
|
||||||
package nl.andrewlalis.gymboardcdn.api;
|
package nl.andrewlalis.gymboardcdn;
|
||||||
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
|
@ -1,5 +0,0 @@
|
||||||
package nl.andrewlalis.gymboardcdn.api;
|
|
||||||
|
|
||||||
public record FileUploadResponse(
|
|
||||||
String id
|
|
||||||
) {}
|
|
|
@ -1,5 +0,0 @@
|
||||||
package nl.andrewlalis.gymboardcdn.api;
|
|
||||||
|
|
||||||
public record VideoProcessingTaskStatusResponse(
|
|
||||||
String status
|
|
||||||
) {}
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.files;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import nl.andrewlalis.gymboardcdn.ServiceOnly;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for general-purpose file access.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class FileController {
|
||||||
|
private final FileStorageService fileStorageService;
|
||||||
|
|
||||||
|
public FileController(FileStorageService fileStorageService) {
|
||||||
|
this.fileStorageService = fileStorageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(path = "/files/{id}")
|
||||||
|
public void getFile(@PathVariable String id, HttpServletResponse response) {
|
||||||
|
fileStorageService.streamToHttpResponse(id, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(path = "/files/{id}/metadata")
|
||||||
|
public FullFileMetadata getFileMetadata(@PathVariable String id) {
|
||||||
|
try {
|
||||||
|
var data = fileStorageService.getMetadata(id);
|
||||||
|
if (data == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
|
return data;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Couldn't read file metadata.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping(path = "/files/{id}") @ServiceOnly
|
||||||
|
public void deleteFile(@PathVariable String id) {
|
||||||
|
try {
|
||||||
|
fileStorageService.delete(id);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete file.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.files;
|
||||||
|
|
||||||
|
public record FileMetadata (
|
||||||
|
String filename,
|
||||||
|
String mimeType,
|
||||||
|
boolean accessible
|
||||||
|
) {}
|
|
@ -1,4 +1,4 @@
|
||||||
package nl.andrewlalis.gymboardcdn.api;
|
package nl.andrewlalis.gymboardcdn.files;
|
||||||
|
|
||||||
public record FileMetadataResponse(
|
public record FileMetadataResponse(
|
||||||
String filename,
|
String filename,
|
|
@ -0,0 +1,223 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.files;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import nl.andrewlalis.gymboardcdn.files.util.ULID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service acts as a low-level driver for interacting with the storage
|
||||||
|
* system. This includes reading and writing files and their metadata.
|
||||||
|
* <p>
|
||||||
|
* Files are stored in a top-level directory, then in 3 sub-directories
|
||||||
|
* according to their creation date. So if a file is created on 2023-04-02,
|
||||||
|
* then it will be stored in BASE_DIR/2023/04/02/. All files are uniquely
|
||||||
|
* identified by a ULID; a monotonic, time-sorted id.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* Each file has a 1Kb (1024 bytes) metadata block baked into it when it's
|
||||||
|
* first saved. This metadata block stores a JSON-serialized set of metadata
|
||||||
|
* properties about the file.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public class FileStorageService {
|
||||||
|
private static final int HEADER_SIZE = 1024;
|
||||||
|
|
||||||
|
private final ULID ulid;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final String baseStorageDir;
|
||||||
|
|
||||||
|
public FileStorageService(ULID ulid, ObjectMapper objectMapper, String baseStorageDir) {
|
||||||
|
this.ulid = ulid;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.baseStorageDir = baseStorageDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateFileId() {
|
||||||
|
return ulid.nextULID();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String save(Path inputFile, FileMetadata metadata) throws IOException {
|
||||||
|
try (var in = Files.newInputStream(inputFile)) {
|
||||||
|
return save(in, metadata, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a new file to the storage.
|
||||||
|
* @param in The input stream to the file contents.
|
||||||
|
* @param metadata The file's metadata.
|
||||||
|
* @param maxSize The maximum allowable filesize to download. If the given
|
||||||
|
* input stream has more content than this size, an exception
|
||||||
|
* is thrown.
|
||||||
|
* @return The file's id.
|
||||||
|
* @throws IOException If an error occurs.
|
||||||
|
*/
|
||||||
|
public String save(InputStream in, FileMetadata metadata, long maxSize) throws IOException {
|
||||||
|
ULID.Value id = ulid.nextValue();
|
||||||
|
return save(id, in, metadata, maxSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a new file to the storage using a specific file id.
|
||||||
|
* @param id The file id to save to.
|
||||||
|
* @param in The input stream to the file contents.
|
||||||
|
* @param metadata The file's metadata.
|
||||||
|
* @param maxSize The maximum allowable filesize to download. If the given
|
||||||
|
* input stream has more content than this size, an exception
|
||||||
|
* is thrown.
|
||||||
|
* @return The file's id.
|
||||||
|
* @throws IOException If an error occurs.
|
||||||
|
*/
|
||||||
|
public String save(ULID.Value id, InputStream in, FileMetadata metadata, long maxSize) throws IOException {
|
||||||
|
Path filePath = getStoragePathForFile(id.toString());
|
||||||
|
Files.createDirectories(filePath.getParent());
|
||||||
|
try (var out = Files.newOutputStream(filePath)) {
|
||||||
|
writeMetadata(out, metadata);
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int bytesRead;
|
||||||
|
long totalBytesWritten = 0;
|
||||||
|
while ((bytesRead = in.read(buffer)) > 0) {
|
||||||
|
out.write(buffer, 0, bytesRead);
|
||||||
|
totalBytesWritten += bytesRead;
|
||||||
|
if (maxSize > 0 && totalBytesWritten > maxSize) {
|
||||||
|
out.close();
|
||||||
|
Files.delete(filePath);
|
||||||
|
throw new IOException("File too large.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets metadata for a file identified by the given id.
|
||||||
|
* @param rawId The file's id.
|
||||||
|
* @return The metadata for the file, or null if no file is found.
|
||||||
|
* @throws IOException If an error occurs while reading metadata.
|
||||||
|
*/
|
||||||
|
public FullFileMetadata getMetadata(String rawId) throws IOException {
|
||||||
|
Path filePath = getStoragePathForFile(rawId);
|
||||||
|
if (Files.notExists(filePath)) return null;
|
||||||
|
try (var in = Files.newInputStream(filePath)) {
|
||||||
|
FileMetadata metadata = readMetadata(in);
|
||||||
|
LocalDateTime date = dateFromULID(ULID.parseULID(rawId));
|
||||||
|
return new FullFileMetadata(
|
||||||
|
metadata.filename(),
|
||||||
|
metadata.mimeType(),
|
||||||
|
Files.size(filePath) - HEADER_SIZE,
|
||||||
|
date.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams a stored file to an HTTP response. A NOT_FOUND response is sent
|
||||||
|
* if the file doesn't exist. Responses include a cache-control header to
|
||||||
|
* allow clients to cache the response for a long time, as stored files are
|
||||||
|
* considered to be immutable, unless rarely deleted.
|
||||||
|
* @param rawId The file's id.
|
||||||
|
* @param response The HTTP response to write to.
|
||||||
|
*/
|
||||||
|
public void streamToHttpResponse(String rawId, HttpServletResponse response) {
|
||||||
|
Path filePath = getStoragePathForFile(rawId);
|
||||||
|
if (Files.notExists(filePath)) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (var in = Files.newInputStream(filePath)) {
|
||||||
|
FileMetadata metadata = readMetadata(in);
|
||||||
|
response.setContentType(metadata.mimeType());
|
||||||
|
response.setContentLengthLong(Files.size(filePath) - HEADER_SIZE);
|
||||||
|
response.addHeader("Cache-Control", "max-age=604800, immutable");
|
||||||
|
var out = response.getOutputStream();
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int bytesRead;
|
||||||
|
while ((bytesRead = in.read(buffer)) > 0) {
|
||||||
|
out.write(buffer, 0, bytesRead);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "IO error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the path to the physical location of the file with the given id.
|
||||||
|
* Note that this method makes no guarantee as to whether the file exists.
|
||||||
|
* @param rawId The id of the file.
|
||||||
|
* @return The path to the location where the file is stored.
|
||||||
|
*/
|
||||||
|
public Path getStoragePathForFile(String rawId) {
|
||||||
|
ULID.Value id = ULID.parseULID(rawId);
|
||||||
|
LocalDateTime time = dateFromULID(id);
|
||||||
|
Path dir = Path.of(baseStorageDir)
|
||||||
|
.resolve(String.format("%04d", time.getYear()))
|
||||||
|
.resolve(String.format("%02d", time.getMonthValue()))
|
||||||
|
.resolve(String.format("%02d", time.getDayOfMonth()));
|
||||||
|
return dir.resolve(rawId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the file with a given id, if it exists.
|
||||||
|
* @param rawId The file's id.
|
||||||
|
* @throws IOException If an error occurs.
|
||||||
|
*/
|
||||||
|
public void delete(String rawId) throws IOException {
|
||||||
|
Path filePath = getStoragePathForFile(rawId);
|
||||||
|
Files.deleteIfExists(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void copyTo(String fileId, Path filePath) throws IOException {
|
||||||
|
Path inputFilePath = getStoragePathForFile(fileId);
|
||||||
|
if (Files.notExists(inputFilePath)) {
|
||||||
|
throw new IOException("File " + fileId + " not found.");
|
||||||
|
}
|
||||||
|
try (
|
||||||
|
var in = Files.newInputStream(inputFilePath);
|
||||||
|
var out = Files.newOutputStream(filePath)
|
||||||
|
) {
|
||||||
|
readMetadata(in);
|
||||||
|
in.transferTo(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDateTime dateFromULID(ULID.Value value) {
|
||||||
|
return Instant.ofEpochMilli(value.timestamp())
|
||||||
|
.atOffset(ZoneOffset.UTC)
|
||||||
|
.toLocalDateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileMetadata readMetadata(InputStream in) throws IOException {
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE);
|
||||||
|
int readCount = in.read(buffer.array(), 0, HEADER_SIZE);
|
||||||
|
if (readCount != HEADER_SIZE) throw new IOException("Invalid header. Read " + readCount + " bytes instead of " + HEADER_SIZE);
|
||||||
|
short metadataBytesLength = buffer.getShort();
|
||||||
|
byte[] metadataBytes = new byte[metadataBytesLength];
|
||||||
|
buffer.get(metadataBytes);
|
||||||
|
return objectMapper.readValue(metadataBytes, FileMetadata.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeMetadata(OutputStream out, FileMetadata metadata) throws IOException {
|
||||||
|
ByteBuffer headerBuffer = ByteBuffer.allocate(HEADER_SIZE);
|
||||||
|
byte[] metadataBytes = objectMapper.writeValueAsBytes(metadata);
|
||||||
|
if (metadataBytes.length > HEADER_SIZE - 2) {
|
||||||
|
throw new IOException("Metadata is too large.");
|
||||||
|
}
|
||||||
|
headerBuffer.putShort((short) metadataBytes.length);
|
||||||
|
headerBuffer.put(metadataBytes);
|
||||||
|
out.write(headerBuffer.array());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.files;
|
||||||
|
|
||||||
|
public record FullFileMetadata(
|
||||||
|
String filename,
|
||||||
|
String mimeType,
|
||||||
|
long size,
|
||||||
|
String createdAt
|
||||||
|
) {}
|
|
@ -1,4 +1,4 @@
|
||||||
package nl.andrewlalis.gymboardcdn.util;
|
package nl.andrewlalis.gymboardcdn.files.util;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* sulky-modules - several general-purpose modules.
|
* sulky-modules - several general-purpose modules.
|
|
@ -1,81 +0,0 @@
|
||||||
package nl.andrewlalis.gymboardcdn.model;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "stored_file")
|
|
||||||
public class StoredFile {
|
|
||||||
/**
|
|
||||||
* ULID-based unique file identifier.
|
|
||||||
*/
|
|
||||||
@Id
|
|
||||||
@Column(nullable = false, updatable = false, length = 26)
|
|
||||||
private String id;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The timestamp at which the file was originally uploaded.
|
|
||||||
*/
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private LocalDateTime uploadedAt;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The original filename.
|
|
||||||
*/
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of the file.
|
|
||||||
*/
|
|
||||||
@Column(updatable = false)
|
|
||||||
private String mimeType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The file's size on the disk.
|
|
||||||
*/
|
|
||||||
@Column(nullable = false, updatable = false)
|
|
||||||
private long size;
|
|
||||||
|
|
||||||
public StoredFile() {}
|
|
||||||
|
|
||||||
public StoredFile(String id, String name, String mimeType, long size, LocalDateTime uploadedAt) {
|
|
||||||
this.id = id;
|
|
||||||
this.name = name;
|
|
||||||
this.mimeType = mimeType;
|
|
||||||
this.size = size;
|
|
||||||
this.uploadedAt = uploadedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getName() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getMimeType() {
|
|
||||||
return mimeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getSize() {
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getUploadedAt() {
|
|
||||||
return uploadedAt;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
package nl.andrewlalis.gymboardcdn.model;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public interface StoredFileRepository extends JpaRepository<StoredFile, String> {
|
|
||||||
}
|
|
|
@ -1,88 +0,0 @@
|
||||||
package nl.andrewlalis.gymboardcdn.model;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An entity to keep track of a task for processing a raw video into a better
|
|
||||||
* format for Gymboard to serve.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "task_video_processing")
|
|
||||||
public class VideoProcessingTask {
|
|
||||||
public enum Status {
|
|
||||||
WAITING,
|
|
||||||
IN_PROGRESS,
|
|
||||||
COMPLETED,
|
|
||||||
FAILED
|
|
||||||
}
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false)
|
|
||||||
private Status status;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The original filename.
|
|
||||||
*/
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String filename;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The path to the temporary file that we'll use as input.
|
|
||||||
*/
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String tempFilePath;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The identifier that will be used to identify the final video, if it
|
|
||||||
* is processed successfully.
|
|
||||||
*/
|
|
||||||
@Column(nullable = false, updatable = false, length = 26)
|
|
||||||
private String videoIdentifier;
|
|
||||||
|
|
||||||
public VideoProcessingTask() {}
|
|
||||||
|
|
||||||
public VideoProcessingTask(Status status, String filename, String tempFilePath, String videoIdentifier) {
|
|
||||||
this.status = status;
|
|
||||||
this.filename = filename;
|
|
||||||
this.tempFilePath = tempFilePath;
|
|
||||||
this.videoIdentifier = videoIdentifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getFilename() {
|
|
||||||
return filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Status getStatus() {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStatus(Status status) {
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getTempFilePath() {
|
|
||||||
return tempFilePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getVideoIdentifier() {
|
|
||||||
return videoIdentifier;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,116 +0,0 @@
|
||||||
package nl.andrewlalis.gymboardcdn.service;
|
|
||||||
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.StoredFile;
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
|
|
||||||
import nl.andrewlalis.gymboardcdn.util.ULID;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The service that manages storing and retrieving files from a base filesystem.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class FileService {
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(FileService.class);
|
|
||||||
|
|
||||||
@Value("${app.files.storage-dir}")
|
|
||||||
private String storageDir;
|
|
||||||
|
|
||||||
@Value("${app.files.temp-dir}")
|
|
||||||
private String tempDir;
|
|
||||||
|
|
||||||
private final StoredFileRepository storedFileRepository;
|
|
||||||
private final ULID ulid;
|
|
||||||
|
|
||||||
public FileService(StoredFileRepository storedFileRepository, ULID ulid) {
|
|
||||||
this.storedFileRepository = storedFileRepository;
|
|
||||||
this.ulid = ulid;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.resolve(file.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
public String createNewFileIdentifier() {
|
|
||||||
return ulid.nextULID();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Path saveToTempFile(InputStream in, String filename) throws IOException {
|
|
||||||
Path tempDir = getTempDir();
|
|
||||||
String suffix = null;
|
|
||||||
if (filename != null) {
|
|
||||||
int idx = filename.lastIndexOf('.');
|
|
||||||
if (idx >= 0) {
|
|
||||||
suffix = filename.substring(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Path tempFile = Files.createTempFile(tempDir, null, suffix);
|
|
||||||
try (var out = Files.newOutputStream(tempFile)) {
|
|
||||||
in.transferTo(out);
|
|
||||||
}
|
|
||||||
return tempFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path getStorageDir() throws IOException {
|
|
||||||
Path dir = Path.of(storageDir);
|
|
||||||
if (Files.notExists(dir)) {
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
}
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path getTempDir() throws IOException {
|
|
||||||
Path dir = Path.of(tempDir);
|
|
||||||
if (Files.notExists(dir)) {
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
}
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scheduled task that removes any StoredFile entities for which no more
|
|
||||||
* physical file exists.
|
|
||||||
*/
|
|
||||||
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES)
|
|
||||||
@Transactional
|
|
||||||
public void removeOrphanedFiles() {
|
|
||||||
Pageable pageable = PageRequest.of(0, 100, Sort.by(Sort.Direction.DESC, "createdAt"));
|
|
||||||
Page<StoredFile> page = storedFileRepository.findAll(pageable);
|
|
||||||
while (!page.isEmpty()) {
|
|
||||||
for (var storedFile : page) {
|
|
||||||
try {
|
|
||||||
Path filePath = getStoragePathForFile(storedFile);
|
|
||||||
if (Files.notExists(filePath)) {
|
|
||||||
log.warn("Removing stored file {} because it no longer exists on the disk.", storedFile.getId());
|
|
||||||
storedFileRepository.delete(storedFile);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Couldn't get storage path for stored file.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pageable = pageable.next();
|
|
||||||
page = storedFileRepository.findAll(pageable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,131 +0,0 @@
|
||||||
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;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
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.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class UploadService {
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(UploadService.class);
|
|
||||||
|
|
||||||
private static final long MAX_UPLOAD_SIZE_BYTES = (1024 * 1024 * 1024); // 1 Gb
|
|
||||||
|
|
||||||
private final StoredFileRepository storedFileRepository;
|
|
||||||
private final VideoProcessingTaskRepository videoTaskRepository;
|
|
||||||
private final FileService fileService;
|
|
||||||
|
|
||||||
public UploadService(StoredFileRepository storedFileRepository,
|
|
||||||
VideoProcessingTaskRepository videoTaskRepository,
|
|
||||||
FileService fileService) {
|
|
||||||
this.storedFileRepository = storedFileRepository;
|
|
||||||
this.videoTaskRepository = videoTaskRepository;
|
|
||||||
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(HttpServletRequest request) {
|
|
||||||
String contentLengthStr = request.getHeader("Content-Length");
|
|
||||||
if (contentLengthStr == null || !contentLengthStr.matches("\\d+")) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.LENGTH_REQUIRED);
|
|
||||||
}
|
|
||||||
long contentLength = Long.parseUnsignedLong(contentLengthStr);
|
|
||||||
if (contentLength > MAX_UPLOAD_SIZE_BYTES) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE);
|
|
||||||
}
|
|
||||||
Path tempFile;
|
|
||||||
String filename = request.getHeader("X-Gymboard-Filename");
|
|
||||||
if (filename == null) filename = "unnamed.mp4";
|
|
||||||
try {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
String identifier = fileService.createNewFileIdentifier();
|
|
||||||
videoTaskRepository.save(new VideoProcessingTask(
|
|
||||||
VideoProcessingTask.Status.WAITING,
|
|
||||||
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 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,173 +0,0 @@
|
||||||
package nl.andrewlalis.gymboardcdn.service;
|
|
||||||
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.StoredFile;
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask;
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
|
|
||||||
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.concurrent.Executor;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class VideoProcessingService {
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(VideoProcessingService.class);
|
|
||||||
|
|
||||||
private final Executor taskExecutor;
|
|
||||||
private final VideoProcessingTaskRepository taskRepo;
|
|
||||||
private final StoredFileRepository storedFileRepository;
|
|
||||||
private final FileService fileService;
|
|
||||||
|
|
||||||
public VideoProcessingService(Executor taskExecutor, VideoProcessingTaskRepository taskRepo, StoredFileRepository storedFileRepository, FileService fileService) {
|
|
||||||
this.taskExecutor = taskExecutor;
|
|
||||||
this.taskRepo = taskRepo;
|
|
||||||
this.storedFileRepository = storedFileRepository;
|
|
||||||
this.fileService = fileService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.SECONDS)
|
|
||||||
public void startWaitingTasks() {
|
|
||||||
List<VideoProcessingTask> waitingTasks = taskRepo.findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status.WAITING);
|
|
||||||
for (var task : waitingTasks) {
|
|
||||||
log.info("Queueing processing of video {}.", task.getVideoIdentifier());
|
|
||||||
updateTask(task, VideoProcessingTask.Status.IN_PROGRESS);
|
|
||||||
taskExecutor.execute(() -> processVideo(task));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS)
|
|
||||||
public void removeOldTasks() {
|
|
||||||
LocalDateTime cutoff = LocalDateTime.now().minusHours(12);
|
|
||||||
List<VideoProcessingTask> oldTasks = taskRepo.findAllByCreatedAtBefore(cutoff);
|
|
||||||
for (var task : oldTasks) {
|
|
||||||
if (task.getStatus() == VideoProcessingTask.Status.COMPLETED) {
|
|
||||||
log.info("Deleting completed task for video {}.", task.getVideoIdentifier());
|
|
||||||
taskRepo.delete(task);
|
|
||||||
} else if (task.getStatus() == VideoProcessingTask.Status.FAILED) {
|
|
||||||
log.info("Deleting failed task for video {}.", task.getVideoIdentifier());
|
|
||||||
taskRepo.delete(task);
|
|
||||||
} else if (task.getStatus() == VideoProcessingTask.Status.IN_PROGRESS) {
|
|
||||||
log.info("Task for video {} was in progress for too long; deleting.", task.getVideoIdentifier());
|
|
||||||
taskRepo.delete(task);
|
|
||||||
} else if (task.getStatus() == VideoProcessingTask.Status.WAITING) {
|
|
||||||
log.info("Task for video {} was waiting for too long; deleting.", task.getVideoIdentifier());
|
|
||||||
taskRepo.delete(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processVideo(VideoProcessingTask task) {
|
|
||||||
log.info("Started processing video {}.", task.getVideoIdentifier());
|
|
||||||
|
|
||||||
Path tempFile = Path.of(task.getTempFilePath());
|
|
||||||
if (Files.notExists(tempFile) || !Files.isReadable(tempFile)) {
|
|
||||||
log.error("Temp file {} doesn't exist or isn't readable.", tempFile);
|
|
||||||
updateTask(task, VideoProcessingTask.Status.FAILED);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then begin running the actual FFMPEG processing.
|
|
||||||
Path tempDir = tempFile.getParent();
|
|
||||||
Path ffmpegOutputFile = tempDir.resolve(task.getVideoIdentifier());
|
|
||||||
try {
|
|
||||||
processVideoWithFFMPEG(tempDir, tempFile, ffmpegOutputFile);
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
log.error("""
|
|
||||||
Video processing failed for video {}:
|
|
||||||
Input file: {}
|
|
||||||
Output file: {}
|
|
||||||
Exception message: {}""",
|
|
||||||
task.getVideoIdentifier(),
|
|
||||||
tempFile,
|
|
||||||
ffmpegOutputFile,
|
|
||||||
e.getMessage()
|
|
||||||
);
|
|
||||||
updateTask(task, VideoProcessingTask.Status.FAILED);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// And finally, copy the output to the final location.
|
|
||||||
try {
|
|
||||||
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(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 processVideoWithFFMPEG(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",
|
|
||||||
"-f", "mp4",
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateTask(VideoProcessingTask task, VideoProcessingTask.Status status) {
|
|
||||||
task.setStatus(status);
|
|
||||||
taskRepo.saveAndFlush(task);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +1,8 @@
|
||||||
package nl.andrewlalis.gymboardcdn.api;
|
package nl.andrewlalis.gymboardcdn.uploads.api;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import nl.andrewlalis.gymboardcdn.ServiceOnly;
|
||||||
import nl.andrewlalis.gymboardcdn.service.UploadService;
|
import nl.andrewlalis.gymboardcdn.uploads.service.UploadService;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
@ -17,22 +17,17 @@ public class UploadController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(path = "/uploads/video", consumes = {"video/mp4"})
|
@PostMapping(path = "/uploads/video", consumes = {"video/mp4"})
|
||||||
public FileUploadResponse uploadVideo(HttpServletRequest request) {
|
public VideoUploadResponse uploadVideo(HttpServletRequest request) {
|
||||||
return uploadService.processableVideoUpload(request);
|
return uploadService.processableVideoUpload(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(path = "/uploads/video/{id}/status")
|
@PostMapping(path = "/uploads/video/{taskId}/start") @ServiceOnly
|
||||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String id) {
|
public void startVideoProcessing(@PathVariable long taskId) {
|
||||||
return uploadService.getVideoProcessingStatus(id);
|
uploadService.startVideoProcessing(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(path = "/files/{id}")
|
@GetMapping(path = "/uploads/video/{taskId}/status")
|
||||||
public void getFile(@PathVariable String id, HttpServletResponse response) {
|
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable long taskId) {
|
||||||
uploadService.streamFile(id, response);
|
return uploadService.getVideoProcessingStatus(taskId);
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(path = "/files/{id}/metadata")
|
|
||||||
public FileMetadataResponse getFileMetadata(@PathVariable String id) {
|
|
||||||
return uploadService.getFileMetadata(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.api;
|
||||||
|
|
||||||
|
public record VideoProcessingTaskStatusResponse(
|
||||||
|
String status,
|
||||||
|
String videoFileId,
|
||||||
|
String thumbnailFileId
|
||||||
|
) {}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.api;
|
||||||
|
|
||||||
|
public record VideoUploadResponse(
|
||||||
|
long taskId
|
||||||
|
) {}
|
|
@ -0,0 +1,96 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An entity to keep track of a task for processing a raw video into a better
|
||||||
|
* format for Gymboard to serve. Generally, tasks are processed like so:
|
||||||
|
* <ol>
|
||||||
|
* <li>A video is uploaded, and a new task is created with the NOT_STARTED status.</li>
|
||||||
|
* <li>Once the Gymboard API verifies the associated submission, it'll
|
||||||
|
* request to start the task, bringing it to the WAITING status.</li>
|
||||||
|
* <li>When a task executor picks up the waiting task, its status changes to IN_PROGRESS.</li>
|
||||||
|
* <li>If the video is processed successfully, then the task is COMPLETED, otherwise FAILED.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "task_video_processing")
|
||||||
|
public class VideoProcessingTask {
|
||||||
|
public enum Status {
|
||||||
|
NOT_STARTED,
|
||||||
|
WAITING,
|
||||||
|
IN_PROGRESS,
|
||||||
|
COMPLETED,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Status status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file id for the original, raw user-uploaded video file that needs to
|
||||||
|
* be processed.
|
||||||
|
*/
|
||||||
|
@Column(nullable = false, updatable = false, length = 26)
|
||||||
|
private String uploadFileId;
|
||||||
|
|
||||||
|
@Column(length = 26)
|
||||||
|
private String videoFileId;
|
||||||
|
|
||||||
|
@Column(length = 26)
|
||||||
|
private String thumbnailFileId;
|
||||||
|
|
||||||
|
public VideoProcessingTask() {}
|
||||||
|
|
||||||
|
public VideoProcessingTask(Status status, String uploadFileId) {
|
||||||
|
this.status = status;
|
||||||
|
this.uploadFileId = uploadFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Status getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(Status status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUploadFileId() {
|
||||||
|
return uploadFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVideoFileId() {
|
||||||
|
return videoFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVideoFileId(String videoFileId) {
|
||||||
|
this.videoFileId = videoFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getThumbnailFileId() {
|
||||||
|
return thumbnailFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setThumbnailFileId(String thumbnailFileId) {
|
||||||
|
this.thumbnailFileId = thumbnailFileId;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,16 +1,13 @@
|
||||||
package nl.andrewlalis.gymboardcdn.model;
|
package nl.andrewlalis.gymboardcdn.uploads.model;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface VideoProcessingTaskRepository extends JpaRepository<VideoProcessingTask, Long> {
|
public interface VideoProcessingTaskRepository extends JpaRepository<VideoProcessingTask, Long> {
|
||||||
Optional<VideoProcessingTask> findByVideoIdentifier(String identifier);
|
|
||||||
|
|
||||||
List<VideoProcessingTask> findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status status);
|
List<VideoProcessingTask> findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status status);
|
||||||
|
|
||||||
List<VideoProcessingTask> findAllByCreatedAtBefore(LocalDateTime cutoff);
|
List<VideoProcessingTask> findAllByCreatedAtBefore(LocalDateTime cutoff);
|
|
@ -0,0 +1,12 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.model;
|
||||||
|
|
||||||
|
public record VideoProcessingTaskStatusUpdate(
|
||||||
|
long taskId,
|
||||||
|
String status,
|
||||||
|
String videoFileId,
|
||||||
|
String thumbnailFileId
|
||||||
|
) {
|
||||||
|
public VideoProcessingTaskStatusUpdate(VideoProcessingTask task) {
|
||||||
|
this(task.getId(), task.getStatus().name(), task.getVideoFileId(), task.getThumbnailFileId());
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package nl.andrewlalis.gymboardcdn.service;
|
package nl.andrewlalis.gymboardcdn.uploads.service;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
|
@ -0,0 +1,103 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.service;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.api.VideoUploadResponse;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.api.VideoProcessingTaskStatusResponse;
|
||||||
|
import nl.andrewlalis.gymboardcdn.files.FileMetadata;
|
||||||
|
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
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;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UploadService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UploadService.class);
|
||||||
|
|
||||||
|
private static final long MAX_UPLOAD_SIZE_BYTES = (1024 * 1024 * 1024); // 1 Gb
|
||||||
|
|
||||||
|
private final VideoProcessingTaskRepository videoTaskRepository;
|
||||||
|
private final FileStorageService fileStorageService;
|
||||||
|
|
||||||
|
public UploadService(VideoProcessingTaskRepository videoTaskRepository, FileStorageService fileStorageService) {
|
||||||
|
this.videoTaskRepository = videoTaskRepository;
|
||||||
|
this.fileStorageService = fileStorageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 containing the id of the video processing task, to be
|
||||||
|
* given to the Gymboard API so that it can further manage processing after
|
||||||
|
* a submission is completed.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public VideoUploadResponse processableVideoUpload(HttpServletRequest request) {
|
||||||
|
String contentLengthStr = request.getHeader("Content-Length");
|
||||||
|
if (contentLengthStr == null || !contentLengthStr.matches("\\d+")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.LENGTH_REQUIRED);
|
||||||
|
}
|
||||||
|
long contentLength = Long.parseUnsignedLong(contentLengthStr);
|
||||||
|
if (contentLength > MAX_UPLOAD_SIZE_BYTES) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE);
|
||||||
|
}
|
||||||
|
String filename = request.getHeader("X-Gymboard-Filename");
|
||||||
|
if (filename == null) filename = "unnamed.mp4";
|
||||||
|
FileMetadata metadata = new FileMetadata(
|
||||||
|
filename,
|
||||||
|
request.getContentType(),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
String uploadFileId;
|
||||||
|
try {
|
||||||
|
uploadFileId = fileStorageService.save(request.getInputStream(), metadata, contentLength);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to save video upload to temp file.", e);
|
||||||
|
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
var task = videoTaskRepository.save(new VideoProcessingTask(
|
||||||
|
VideoProcessingTask.Status.NOT_STARTED,
|
||||||
|
uploadFileId
|
||||||
|
));
|
||||||
|
return new VideoUploadResponse(task.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the status of a video processing task.
|
||||||
|
* @param id The task id.
|
||||||
|
* @return The status of the video processing task.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(long id) {
|
||||||
|
VideoProcessingTask task = videoTaskRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
return new VideoProcessingTaskStatusResponse(
|
||||||
|
task.getStatus().name(),
|
||||||
|
task.getVideoFileId(),
|
||||||
|
task.getThumbnailFileId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks this task as waiting to be picked up for processing. The Gymboard
|
||||||
|
* API should send a message itself to start processing of an uploaded video
|
||||||
|
* once it validates a submission.
|
||||||
|
* @param taskId The task id.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void startVideoProcessing(long taskId) {
|
||||||
|
VideoProcessingTask task = videoTaskRepository.findById(taskId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
if (task.getStatus() == VideoProcessingTask.Status.NOT_STARTED) {
|
||||||
|
task.setStatus(VideoProcessingTask.Status.WAITING);
|
||||||
|
videoTaskRepository.save(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,229 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import nl.andrewlalis.gymboardcdn.files.FileMetadata;
|
||||||
|
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskStatusUpdate;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.service.process.ThumbnailGenerator;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.service.process.VideoProcessor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
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.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class VideoProcessingService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(VideoProcessingService.class);
|
||||||
|
|
||||||
|
private final Executor videoProcessingExecutor;
|
||||||
|
private final VideoProcessingTaskRepository taskRepo;
|
||||||
|
private final FileStorageService fileStorageService;
|
||||||
|
private final VideoProcessor videoProcessor;
|
||||||
|
private final ThumbnailGenerator thumbnailGenerator;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${app.api-origin}")
|
||||||
|
private String apiOrigin;
|
||||||
|
|
||||||
|
@Value("${app.api-secret}")
|
||||||
|
private String apiSecret;
|
||||||
|
|
||||||
|
public VideoProcessingService(Executor videoProcessingExecutor,
|
||||||
|
VideoProcessingTaskRepository taskRepo,
|
||||||
|
FileStorageService fileStorageService,
|
||||||
|
VideoProcessor videoProcessor,
|
||||||
|
ThumbnailGenerator thumbnailGenerator, ObjectMapper objectMapper) {
|
||||||
|
this.videoProcessingExecutor = videoProcessingExecutor;
|
||||||
|
this.taskRepo = taskRepo;
|
||||||
|
this.fileStorageService = fileStorageService;
|
||||||
|
this.videoProcessor = videoProcessor;
|
||||||
|
this.thumbnailGenerator = thumbnailGenerator;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateTask(VideoProcessingTask task, VideoProcessingTask.Status status) {
|
||||||
|
task.setStatus(status);
|
||||||
|
taskRepo.saveAndFlush(task);
|
||||||
|
if (status == VideoProcessingTask.Status.COMPLETED || status == VideoProcessingTask.Status.FAILED) {
|
||||||
|
sendTaskCompleteToApi(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.SECONDS)
|
||||||
|
public void startWaitingTasks() {
|
||||||
|
List<VideoProcessingTask> waitingTasks = taskRepo.findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status.WAITING);
|
||||||
|
for (var task : waitingTasks) {
|
||||||
|
log.info("Queueing processing of task {}.", task.getId());
|
||||||
|
updateTask(task, VideoProcessingTask.Status.IN_PROGRESS);
|
||||||
|
videoProcessingExecutor.execute(() -> processVideo(task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS)
|
||||||
|
public void removeOldTasks() {
|
||||||
|
LocalDateTime cutoff = LocalDateTime.now().minusHours(12);
|
||||||
|
List<VideoProcessingTask> oldTasks = taskRepo.findAllByCreatedAtBefore(cutoff);
|
||||||
|
for (var task : oldTasks) {
|
||||||
|
if (task.getStatus() == VideoProcessingTask.Status.COMPLETED) {
|
||||||
|
log.info("Deleting completed task {}.", task.getId());
|
||||||
|
deleteAllTaskFiles(task);
|
||||||
|
taskRepo.delete(task);
|
||||||
|
} else if (task.getStatus() == VideoProcessingTask.Status.FAILED) {
|
||||||
|
log.info("Deleting failed task {}.", task.getId());
|
||||||
|
taskRepo.delete(task);
|
||||||
|
} else if (task.getStatus() == VideoProcessingTask.Status.IN_PROGRESS) {
|
||||||
|
log.info("Task {} was in progress for too long; deleting.", task.getId());
|
||||||
|
deleteAllTaskFiles(task);
|
||||||
|
taskRepo.delete(task);
|
||||||
|
} else if (task.getStatus() == VideoProcessingTask.Status.WAITING) {
|
||||||
|
log.info("Task {} was waiting for too long; deleting.", task.getId());
|
||||||
|
deleteAllTaskFiles(task);
|
||||||
|
taskRepo.delete(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processVideo(VideoProcessingTask task) {
|
||||||
|
log.info("Started processing task {}.", task.getId());
|
||||||
|
|
||||||
|
Path uploadFile = fileStorageService.getStoragePathForFile(task.getUploadFileId());
|
||||||
|
Path rawUploadFile = uploadFile.resolveSibling(task.getUploadFileId() + "-vid-in");
|
||||||
|
if (Files.notExists(uploadFile) || !Files.isReadable(uploadFile)) {
|
||||||
|
log.error("Uploaded video file {} doesn't exist or isn't readable.", uploadFile);
|
||||||
|
updateTask(task, VideoProcessingTask.Status.FAILED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fileStorageService.copyTo(task.getUploadFileId(), rawUploadFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to copy raw video file {} to {}.", uploadFile, rawUploadFile);
|
||||||
|
e.printStackTrace();
|
||||||
|
updateTask(task, VideoProcessingTask.Status.FAILED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the actual processing here.
|
||||||
|
Path videoFile = uploadFile.resolveSibling(task.getUploadFileId() + "-vid-out.mp4");
|
||||||
|
Path thumbnailFile = uploadFile.resolveSibling(task.getUploadFileId() + "-thm-out.jpeg");
|
||||||
|
try {
|
||||||
|
log.info("Processing video for uploaded video file {}.", uploadFile.getFileName());
|
||||||
|
videoProcessor.processVideo(rawUploadFile, videoFile);
|
||||||
|
log.info("Generating thumbnail for uploaded video file {}.", uploadFile.getFileName());
|
||||||
|
thumbnailGenerator.generateThumbnailImage(videoFile, thumbnailFile);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
log.error("""
|
||||||
|
Video processing failed for task {}:
|
||||||
|
Input file: {}
|
||||||
|
Output file: {}
|
||||||
|
Exception message: {}""",
|
||||||
|
task.getId(),
|
||||||
|
rawUploadFile,
|
||||||
|
videoFile,
|
||||||
|
e.getMessage()
|
||||||
|
);
|
||||||
|
updateTask(task, VideoProcessingTask.Status.FAILED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// And finally, copy the output to the final location.
|
||||||
|
try {
|
||||||
|
// Save the video to a final file location.
|
||||||
|
var originalMetadata = fileStorageService.getMetadata(task.getUploadFileId());
|
||||||
|
FileMetadata metadata = new FileMetadata(originalMetadata.filename(), originalMetadata.mimeType(), true);
|
||||||
|
String videoFileId = fileStorageService.save(videoFile, metadata);
|
||||||
|
|
||||||
|
// Save the thumbnail too.
|
||||||
|
FileMetadata thumbnailMetadata = new FileMetadata("thumbnail.jpeg", "image/jpeg", true);
|
||||||
|
String thumbnailFileId = fileStorageService.save(thumbnailFile, thumbnailMetadata);
|
||||||
|
|
||||||
|
task.setVideoFileId(videoFileId);
|
||||||
|
task.setThumbnailFileId(thumbnailFileId);
|
||||||
|
updateTask(task, VideoProcessingTask.Status.COMPLETED);
|
||||||
|
log.info("Finished processing task {}.", task.getId());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to copy processed video to final storage location.", e);
|
||||||
|
updateTask(task, VideoProcessingTask.Status.FAILED);
|
||||||
|
} finally {
|
||||||
|
deleteAllTaskFiles(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an update message to the Gymboard API when a task finishes its
|
||||||
|
* processing. Note that Gymboard API will also eventually poll the CDN's
|
||||||
|
* own API to get task status if we fail to send it, so there's some
|
||||||
|
* redundancy built-in.
|
||||||
|
* @param task The task to send.
|
||||||
|
*/
|
||||||
|
private void sendTaskCompleteToApi(VideoProcessingTask task) {
|
||||||
|
String json;
|
||||||
|
try {
|
||||||
|
json = objectMapper.writeValueAsString(new VideoProcessingTaskStatusUpdate(task));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("JSON error while sending task data to API for task " + task.getId(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HttpClient httpClient = HttpClient.newBuilder().build();
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(apiOrigin + "/submissions/video-processing-complete"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Gymboard-Service-Secret", apiSecret)
|
||||||
|
.timeout(Duration.ofSeconds(3))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||||
|
.build();
|
||||||
|
try {
|
||||||
|
HttpResponse<Void> response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
log.error("API returned not-ok response {}", response.statusCode());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to send HTTP request to API.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to delete all temporary files related to a task's
|
||||||
|
* processing operations. If the task is FAILED, then files are kept for
|
||||||
|
* debugging purposes.
|
||||||
|
* @param task The task to delete files for.
|
||||||
|
*/
|
||||||
|
private void deleteAllTaskFiles(VideoProcessingTask task) {
|
||||||
|
if (task.getStatus() == VideoProcessingTask.Status.FAILED) {
|
||||||
|
log.warn("Retaining files for failed task {}, upload id {}.", task.getId(), task.getUploadFileId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path dir = fileStorageService.getStoragePathForFile(task.getUploadFileId()).getParent();
|
||||||
|
try (var s = Files.list(dir)) {
|
||||||
|
var files = s.toList();
|
||||||
|
for (var file : files) {
|
||||||
|
String filename = file.getFileName().toString().strip();
|
||||||
|
if (Files.isRegularFile(file) && filename.startsWith(task.getUploadFileId())) {
|
||||||
|
try {
|
||||||
|
Files.delete(file);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to delete file " + file + " related to task " + task.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to list files in " + dir + " when deleting files for task " + task.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.service.process;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.service.CommandFailedException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public abstract class FfmpegCommandExecutor {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FfmpegCommandExecutor.class);
|
||||||
|
|
||||||
|
protected abstract String[] buildCommand(Path inputFile, Path outputFile);
|
||||||
|
|
||||||
|
public void run(String label, Path inputFile, Path outputFile) throws IOException {
|
||||||
|
String inputFilename = inputFile.getFileName().toString().strip();
|
||||||
|
Path stdout = inputFile.resolveSibling(inputFilename + "-ffmpeg-" + label + "-out.log");
|
||||||
|
Path stderr = inputFile.resolveSibling(inputFilename + "-ffmpeg-" + label + "-err.log");
|
||||||
|
String[] command = buildCommand(inputFile, outputFile);
|
||||||
|
Process process = new ProcessBuilder(buildCommand(inputFile, outputFile))
|
||||||
|
.redirectOutput(stdout.toFile())
|
||||||
|
.redirectError(stderr.toFile())
|
||||||
|
.start();
|
||||||
|
try {
|
||||||
|
int result = process.waitFor();
|
||||||
|
if (result != 0) {
|
||||||
|
throw new CommandFailedException(command, result, stdout, stderr);
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new IOException("Interrupted while waiting for ffmpeg to finish.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to clean up output files when the command exited successfully.
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(stdout);
|
||||||
|
Files.deleteIfExists(stderr);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to delete output files after successful ffmpeg execution.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.service.process;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public class FfmpegThumbnailGenerator extends FfmpegCommandExecutor implements ThumbnailGenerator {
|
||||||
|
@Override
|
||||||
|
public void generateThumbnailImage(Path videoInputFile, Path outputFilePath) throws IOException {
|
||||||
|
super.run("thm", videoInputFile, outputFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String[] buildCommand(Path inputFile, Path outputFile) {
|
||||||
|
return new String[]{
|
||||||
|
"ffmpeg",
|
||||||
|
"-i", inputFile.toString(),
|
||||||
|
"-vframes", "1",
|
||||||
|
outputFile.toString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.service.process;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public class FfmpegVideoProcessor extends FfmpegCommandExecutor implements VideoProcessor {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FfmpegVideoProcessor.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void processVideo(Path inputFilePath, Path outputFilePath) throws IOException {
|
||||||
|
Instant start = Instant.now();
|
||||||
|
long inputFileSize = Files.size(inputFilePath);
|
||||||
|
|
||||||
|
super.run("vid", inputFilePath, outputFilePath);
|
||||||
|
|
||||||
|
Duration duration = Duration.between(start, Instant.now());
|
||||||
|
long outputFileSize = Files.size(outputFilePath);
|
||||||
|
double reductionFactor = inputFileSize / (double) outputFileSize;
|
||||||
|
double durationSeconds = duration.toMillis() / 1000.0;
|
||||||
|
log.info(
|
||||||
|
"Processed video {} from {} to {} bytes, {} reduction in {} seconds.",
|
||||||
|
inputFilePath.getFileName().toString(),
|
||||||
|
inputFileSize,
|
||||||
|
outputFileSize,
|
||||||
|
String.format("%.3f%%", reductionFactor),
|
||||||
|
String.format("%.3f", durationSeconds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String[] buildCommand(Path inputFile, Path outputFile) {
|
||||||
|
return new String[]{
|
||||||
|
"ffmpeg",
|
||||||
|
"-i", inputFile.toString(),
|
||||||
|
"-vf", "scale=640x480:flags=lanczos",
|
||||||
|
"-vcodec", "libx264",
|
||||||
|
"-crf", "28",
|
||||||
|
"-f", "mp4",
|
||||||
|
outputFile.toString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.service.process;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public interface ThumbnailGenerator {
|
||||||
|
void generateThumbnailImage(Path videoInputFile, Path outputFilePath) throws IOException;
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.service.process;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public interface VideoProcessor {
|
||||||
|
void processVideo(Path inputFilePath, Path outputFilePath) throws IOException;
|
||||||
|
}
|
|
@ -6,7 +6,9 @@ spring.jpa.hibernate.ddl-auto=update
|
||||||
|
|
||||||
server.port=8082
|
server.port=8082
|
||||||
|
|
||||||
|
# A secret header token that other services must provide to use service-only endpoints.
|
||||||
|
app.service-secret=testing
|
||||||
|
|
||||||
app.web-origin=http://localhost:9000
|
app.web-origin=http://localhost:9000
|
||||||
app.api-origin=http://localhost:8080
|
app.api-origin=http://localhost:8080
|
||||||
app.files.storage-dir=./cdn-files/
|
app.api-secret=testing
|
||||||
app.files.temp-dir=./cdn-files/tmp/
|
|
||||||
|
|
|
@ -1,22 +1,11 @@
|
||||||
package nl.andrewlalis.gymboardcdn.service;
|
package nl.andrewlalis.gymboardcdn.service;
|
||||||
|
|
||||||
import jakarta.servlet.ServletInputStream;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import nl.andrewlalis.gymboardcdn.api.FileUploadResponse;
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask;
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.Mockito;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.mockito.AdditionalAnswers.returnsFirstArg;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
public class UploadServiceTest {
|
public class UploadServiceTest {
|
||||||
/**
|
/**
|
||||||
|
@ -27,31 +16,30 @@ public class UploadServiceTest {
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void processableVideoUploadSuccess() throws IOException {
|
public void processableVideoUploadSuccess() throws IOException {
|
||||||
StoredFileRepository storedFileRepository = Mockito.mock(StoredFileRepository.class);
|
// TODO: Refactor all of this!
|
||||||
VideoProcessingTaskRepository videoTaskRepository = Mockito.mock(VideoProcessingTaskRepository.class);
|
// VideoProcessingTaskRepository videoTaskRepository = Mockito.mock(VideoProcessingTaskRepository.class);
|
||||||
when(videoTaskRepository.save(any(VideoProcessingTask.class)))
|
// when(videoTaskRepository.save(any(VideoProcessingTask.class)))
|
||||||
.then(returnsFirstArg());
|
// .then(returnsFirstArg());
|
||||||
FileService fileService = Mockito.mock(FileService.class);
|
// FileService fileService = Mockito.mock(FileService.class);
|
||||||
when(fileService.saveToTempFile(any(InputStream.class), any(String.class)))
|
// when(fileService.saveToTempFile(any(InputStream.class), any(String.class)))
|
||||||
.thenReturn(Path.of("test-cdn-files", "tmp", "bleh.mp4"));
|
// .thenReturn(Path.of("test-cdn-files", "tmp", "bleh.mp4"));
|
||||||
|
//
|
||||||
when(fileService.createNewFileIdentifier()).thenReturn("abc");
|
// when(fileService.createNewFileIdentifier()).thenReturn("abc");
|
||||||
|
//
|
||||||
UploadService uploadService = new UploadService(
|
// UploadService uploadService = new UploadService(
|
||||||
storedFileRepository,
|
// videoTaskRepository,
|
||||||
videoTaskRepository,
|
// fileService
|
||||||
fileService
|
// );
|
||||||
);
|
// HttpServletRequest mockRequest = mock(HttpServletRequest.class);
|
||||||
HttpServletRequest mockRequest = mock(HttpServletRequest.class);
|
// when(mockRequest.getHeader("X-Filename")).thenReturn("testing.mp4");
|
||||||
when(mockRequest.getHeader("X-Filename")).thenReturn("testing.mp4");
|
// when(mockRequest.getHeader("Content-Length")).thenReturn("123");
|
||||||
when(mockRequest.getHeader("Content-Length")).thenReturn("123");
|
// ServletInputStream mockRequestInputStream = mock(ServletInputStream.class);
|
||||||
ServletInputStream mockRequestInputStream = mock(ServletInputStream.class);
|
// when(mockRequest.getInputStream()).thenReturn(mockRequestInputStream);
|
||||||
when(mockRequest.getInputStream()).thenReturn(mockRequestInputStream);
|
// var expectedResponse = new FileUploadResponse("abc");
|
||||||
var expectedResponse = new FileUploadResponse("abc");
|
// var response = uploadService.processableVideoUpload(mockRequest);
|
||||||
var response = uploadService.processableVideoUpload(mockRequest);
|
// assertEquals(expectedResponse, response);
|
||||||
assertEquals(expectedResponse, response);
|
// verify(fileService, times(1)).saveToTempFile(any(), any());
|
||||||
verify(fileService, times(1)).saveToTempFile(any(), any());
|
// verify(videoTaskRepository, times(1)).save(any());
|
||||||
verify(videoTaskRepository, times(1)).save(any());
|
// verify(fileService, times(1)).createNewFileIdentifier();
|
||||||
verify(fileService, times(1)).createNewFileIdentifier();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import std.stdio;
|
import std.stdio;
|
||||||
|
|
||||||
import cli;
|
import cli;
|
||||||
import command;
|
|
||||||
import services;
|
import services;
|
||||||
|
|
||||||
import consolecolors;
|
import consolecolors;
|
||||||
|
@ -10,12 +9,11 @@ void main() {
|
||||||
ServiceManager serviceManager = new ServiceManager();
|
ServiceManager serviceManager = new ServiceManager();
|
||||||
CliHandler cliHandler = new CliHandler();
|
CliHandler cliHandler = new CliHandler();
|
||||||
cliHandler.register("service", new ServiceCommand(serviceManager));
|
cliHandler.register("service", new ServiceCommand(serviceManager));
|
||||||
cwriteln("\n<blue>Gymboard CLI</blue>: <grey>Command-line interface for managing Gymboard services.</grey>");
|
cwriteln("Gymboard CLI: Type <cyan>help</cyan> for more information. Type <red>exit</red> to exit the CLI.");
|
||||||
cwriteln(" Type <cyan>help</cyan> for more information.\n Type <red>exit</red> to exit the CLI.\n");
|
while (!cliHandler.shouldExit) {
|
||||||
while (!cliHandler.isExitRequested) {
|
|
||||||
cwrite("> ".blue);
|
cwrite("> ".blue);
|
||||||
cliHandler.readAndHandleCommand();
|
cliHandler.readAndHandleCommand();
|
||||||
}
|
}
|
||||||
serviceManager.stopAll();
|
serviceManager.stopAll();
|
||||||
cwriteln("Goodbye!".blue);
|
cwriteln("Goodbye!".green);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,45 @@ import std.typecons;
|
||||||
|
|
||||||
import consolecolors;
|
import consolecolors;
|
||||||
|
|
||||||
import command.base;
|
interface CliCommand {
|
||||||
|
void handle(string[] args);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CliHandler {
|
||||||
|
private CliCommand[string] commands;
|
||||||
|
public bool shouldExit = false;
|
||||||
|
|
||||||
|
public void register(string name, CliCommand command) {
|
||||||
|
this.commands[name] = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void readAndHandleCommand() {
|
||||||
|
string[] commandAndArgs = readln().strip().split!isWhite();
|
||||||
|
if (commandAndArgs.length == 0) return;
|
||||||
|
string command = commandAndArgs[0].toLower();
|
||||||
|
if (command == "help") {
|
||||||
|
showHelp();
|
||||||
|
} else if (command == "exit") {
|
||||||
|
shouldExit = true;
|
||||||
|
} else if (command in commands) {
|
||||||
|
commands[command].handle(commandAndArgs.length > 1 ? commandAndArgs[1 .. $] : []);
|
||||||
|
} else {
|
||||||
|
cwritefln("Unknown command: %s".red, command.orange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void showHelp() {
|
||||||
|
writeln(q"HELP
|
||||||
|
Gymboard CLI: A tool for streamlining development.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
help Shows this message.
|
||||||
|
exit Exits the CLI, stopping any running services.
|
||||||
|
HELP");
|
||||||
|
}
|
||||||
|
|
||||||
import services;
|
import services;
|
||||||
|
|
||||||
class ServiceCommand : CliCommand {
|
class ServiceCommand : CliCommand {
|
||||||
|
@ -48,14 +86,6 @@ class ServiceCommand : CliCommand {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string name() const {
|
|
||||||
return "Service";
|
|
||||||
}
|
|
||||||
|
|
||||||
string description() const {
|
|
||||||
return "bleh";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a service command contains as its second argument a valid
|
* Validates that a service command contains as its second argument a valid
|
||||||
* service name.
|
* service name.
|
||||||
|
|
|
@ -1,102 +0,0 @@
|
||||||
module command.base;
|
|
||||||
|
|
||||||
import consolecolors;
|
|
||||||
import std.stdio;
|
|
||||||
import std.string;
|
|
||||||
import std.uni;
|
|
||||||
|
|
||||||
interface CliCommand {
|
|
||||||
void handle(string[] args);
|
|
||||||
string name() const;
|
|
||||||
string description() const;
|
|
||||||
}
|
|
||||||
|
|
||||||
class CliHandler {
|
|
||||||
private CliCommand[string] commands;
|
|
||||||
private bool exitRequested = false;
|
|
||||||
|
|
||||||
this() {
|
|
||||||
commands["help"] = new HelpCommand(this);
|
|
||||||
commands["exit"] = new ExitCommand(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
void register(string name, CliCommand command) {
|
|
||||||
this.commands[name.strip().toLower()] = command;
|
|
||||||
}
|
|
||||||
|
|
||||||
void readAndHandleCommand() {
|
|
||||||
string[] commandAndArgs = readln().strip().split!isWhite();
|
|
||||||
if (commandAndArgs.length == 0) return;
|
|
||||||
string command = commandAndArgs[0].toLower();
|
|
||||||
if (command in commands) {
|
|
||||||
commands[command].handle(commandAndArgs.length > 1 ? commandAndArgs[1 .. $] : []);
|
|
||||||
} else {
|
|
||||||
cwritefln("Unknown command: %s".red, command.orange);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setExitRequested() {
|
|
||||||
this.exitRequested = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isExitRequested() const {
|
|
||||||
return this.exitRequested;
|
|
||||||
}
|
|
||||||
|
|
||||||
CliCommand[string] getCommands() {
|
|
||||||
return this.commands;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class HelpCommand : CliCommand {
|
|
||||||
private CliHandler handler;
|
|
||||||
|
|
||||||
this(CliHandler handler) {
|
|
||||||
this.handler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
void handle(string[] args) {
|
|
||||||
import std.algorithm;
|
|
||||||
|
|
||||||
cwriteln("<blue>Gymboard CLI Help</blue>: <grey>Information about how to use this program.</grey>");
|
|
||||||
|
|
||||||
string[] commandNames = this.handler.getCommands().keys;
|
|
||||||
sort(commandNames);
|
|
||||||
uint longestCommandNameLength = commandNames.map!(n => cast(uint) n.length).maxElement;
|
|
||||||
|
|
||||||
foreach (name; commandNames) {
|
|
||||||
CliCommand command = this.handler.getCommands()[name];
|
|
||||||
|
|
||||||
string formattedName = cyan(leftJustify(name, longestCommandNameLength + 1, ' '));
|
|
||||||
cwriteln(formattedName, command.description().grey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
string name() const {
|
|
||||||
return "help";
|
|
||||||
}
|
|
||||||
|
|
||||||
string description() const {
|
|
||||||
return "Shows help information.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExitCommand : CliCommand {
|
|
||||||
private CliHandler handler;
|
|
||||||
|
|
||||||
this(CliHandler handler) {
|
|
||||||
this.handler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
void handle(string[] args) {
|
|
||||||
this.handler.setExitRequested();
|
|
||||||
}
|
|
||||||
|
|
||||||
string name() const {
|
|
||||||
return "exit";
|
|
||||||
}
|
|
||||||
|
|
||||||
string description() const {
|
|
||||||
return "Exits the CLI, gracefully stopping any services or running jobs.";
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
module command;
|
|
||||||
|
|
||||||
public import command.base;
|
|
Loading…
Reference in New Issue