More uploads refactoring.
This commit is contained in:
parent
c00697b3d1
commit
d66fa71ae2
|
@ -1,7 +1,7 @@
|
||||||
package nl.andrewlalis.gymboardcdn;
|
package nl.andrewlalis.gymboardcdn;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboardcdn.service.FileStorageService;
|
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
|
||||||
import nl.andrewlalis.gymboardcdn.util.ULID;
|
import nl.andrewlalis.gymboardcdn.files.util.ULID;
|
||||||
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;
|
||||||
|
|
|
@ -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,8 +1,6 @@
|
||||||
package nl.andrewlalis.gymboardcdn.api;
|
package nl.andrewlalis.gymboardcdn.files;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import nl.andrewlalis.gymboardcdn.model.FullFileMetadata;
|
|
||||||
import nl.andrewlalis.gymboardcdn.service.FileStorageService;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
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;
|
|
@ -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,
|
|
@ -1,10 +1,8 @@
|
||||||
package nl.andrewlalis.gymboardcdn.service;
|
package nl.andrewlalis.gymboardcdn.files;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import nl.andrewlalis.gymboardcdn.model.FileMetadata;
|
import nl.andrewlalis.gymboardcdn.files.util.ULID;
|
||||||
import nl.andrewlalis.gymboardcdn.model.FullFileMetadata;
|
|
||||||
import nl.andrewlalis.gymboardcdn.util.ULID;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
@ -46,6 +44,10 @@ public class FileStorageService {
|
||||||
this.baseStorageDir = baseStorageDir;
|
this.baseStorageDir = baseStorageDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String generateFileId() {
|
||||||
|
return ulid.nextULID();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves a new file to the storage.
|
* Saves a new file to the storage.
|
||||||
* @param in The input stream to the file contents.
|
* @param in The input stream to the file contents.
|
||||||
|
@ -106,8 +108,8 @@ public class FileStorageService {
|
||||||
FileMetadata metadata = readMetadata(in);
|
FileMetadata metadata = readMetadata(in);
|
||||||
LocalDateTime date = dateFromULID(ULID.parseULID(rawId));
|
LocalDateTime date = dateFromULID(ULID.parseULID(rawId));
|
||||||
return new FullFileMetadata(
|
return new FullFileMetadata(
|
||||||
metadata.filename,
|
metadata.filename(),
|
||||||
metadata.mimeType,
|
metadata.mimeType(),
|
||||||
Files.size(filePath) - HEADER_SIZE,
|
Files.size(filePath) - HEADER_SIZE,
|
||||||
date.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
date.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
);
|
);
|
||||||
|
@ -131,7 +133,7 @@ public class FileStorageService {
|
||||||
|
|
||||||
try (var in = Files.newInputStream(filePath)) {
|
try (var in = Files.newInputStream(filePath)) {
|
||||||
FileMetadata metadata = readMetadata(in);
|
FileMetadata metadata = readMetadata(in);
|
||||||
response.setContentType(metadata.mimeType);
|
response.setContentType(metadata.mimeType());
|
||||||
response.setContentLengthLong(Files.size(filePath) - HEADER_SIZE);
|
response.setContentLengthLong(Files.size(filePath) - HEADER_SIZE);
|
||||||
response.addHeader("Cache-Control", "max-age=604800, immutable");
|
response.addHeader("Cache-Control", "max-age=604800, immutable");
|
||||||
var out = response.getOutputStream();
|
var out = response.getOutputStream();
|
|
@ -1,4 +1,4 @@
|
||||||
package nl.andrewlalis.gymboardcdn.model;
|
package nl.andrewlalis.gymboardcdn.files;
|
||||||
|
|
||||||
public record FullFileMetadata(
|
public record FullFileMetadata(
|
||||||
String filename,
|
String filename,
|
|
@ -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,6 +0,0 @@
|
||||||
package nl.andrewlalis.gymboardcdn.model;
|
|
||||||
|
|
||||||
public class FileMetadata {
|
|
||||||
public String filename;
|
|
||||||
public String mimeType;
|
|
||||||
}
|
|
|
@ -1,62 +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;
|
|
||||||
|
|
||||||
@Column(nullable = false, updatable = false, length = 26)
|
|
||||||
private String rawUploadFileId;
|
|
||||||
|
|
||||||
public VideoProcessingTask() {}
|
|
||||||
|
|
||||||
public VideoProcessingTask(Status status, String rawUploadFileId) {
|
|
||||||
this.status = status;
|
|
||||||
this.rawUploadFileId = rawUploadFileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 getRawUploadFileId() {
|
|
||||||
return rawUploadFileId;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,7 @@
|
||||||
package nl.andrewlalis.gymboardcdn.api;
|
package nl.andrewlalis.gymboardcdn.uploads.api;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
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;
|
||||||
|
@ -16,12 +16,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")
|
||||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String id) {
|
public void startVideoProcessing(@PathVariable long taskId) {
|
||||||
return uploadService.getVideoProcessingStatus(id);
|
uploadService.startVideoProcessing(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(path = "/uploads/video/{taskId}/status")
|
||||||
|
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable long taskId) {
|
||||||
|
return uploadService.getVideoProcessingStatus(taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package nl.andrewlalis.gymboardcdn.api;
|
package nl.andrewlalis.gymboardcdn.uploads.api;
|
||||||
|
|
||||||
public record VideoProcessingTaskStatusResponse(
|
public record VideoProcessingTaskStatusResponse(
|
||||||
String status
|
String status
|
|
@ -0,0 +1,5 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.api;
|
||||||
|
|
||||||
|
public record VideoUploadResponse(
|
||||||
|
long taskId
|
||||||
|
) {}
|
|
@ -0,0 +1,87 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file id for the final processed video file. This doesn't exist yet,
|
||||||
|
* but we generate the video id right away, just in case there's a need to
|
||||||
|
* preemptively link to it.
|
||||||
|
*/
|
||||||
|
@Column(nullable = false, updatable = false, length = 26)
|
||||||
|
private String videoFileId;
|
||||||
|
|
||||||
|
public VideoProcessingTask() {}
|
||||||
|
|
||||||
|
public VideoProcessingTask(Status status, String uploadFileId, String videoFileId) {
|
||||||
|
this.status = status;
|
||||||
|
this.uploadFileId = uploadFileId;
|
||||||
|
this.videoFileId = videoFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
|
@ -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;
|
|
@ -1,11 +1,12 @@
|
||||||
package nl.andrewlalis.gymboardcdn.service;
|
package nl.andrewlalis.gymboardcdn.uploads.service;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import nl.andrewlalis.gymboardcdn.api.FileUploadResponse;
|
import nl.andrewlalis.gymboardcdn.uploads.api.VideoUploadResponse;
|
||||||
import nl.andrewlalis.gymboardcdn.api.VideoProcessingTaskStatusResponse;
|
import nl.andrewlalis.gymboardcdn.uploads.api.VideoProcessingTaskStatusResponse;
|
||||||
import nl.andrewlalis.gymboardcdn.model.FileMetadata;
|
import nl.andrewlalis.gymboardcdn.files.FileMetadata;
|
||||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask;
|
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
|
||||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository;
|
||||||
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;
|
||||||
|
@ -33,11 +34,12 @@ public class UploadService {
|
||||||
* Handles uploading of a processable video file that will be processed
|
* Handles uploading of a processable video file that will be processed
|
||||||
* before being stored in the system.
|
* before being stored in the system.
|
||||||
* @param request The request from which we can read the file.
|
* @param request The request from which we can read the file.
|
||||||
* @return A response that contains an identifier that can be used to check
|
* @return A response containing the id of the video processing task, to be
|
||||||
* the status of the video processing, and eventually fetch the video.
|
* given to the Gymboard API so that it can further manage processing after
|
||||||
|
* a submission is completed.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public FileUploadResponse processableVideoUpload(HttpServletRequest request) {
|
public VideoUploadResponse processableVideoUpload(HttpServletRequest request) {
|
||||||
String contentLengthStr = request.getHeader("Content-Length");
|
String contentLengthStr = request.getHeader("Content-Length");
|
||||||
if (contentLengthStr == null || !contentLengthStr.matches("\\d+")) {
|
if (contentLengthStr == null || !contentLengthStr.matches("\\d+")) {
|
||||||
throw new ResponseStatusException(HttpStatus.LENGTH_REQUIRED);
|
throw new ResponseStatusException(HttpStatus.LENGTH_REQUIRED);
|
||||||
|
@ -46,34 +48,51 @@ public class UploadService {
|
||||||
if (contentLength > MAX_UPLOAD_SIZE_BYTES) {
|
if (contentLength > MAX_UPLOAD_SIZE_BYTES) {
|
||||||
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE);
|
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE);
|
||||||
}
|
}
|
||||||
FileMetadata metadata = new FileMetadata();
|
String filename = request.getHeader("X-Gymboard-Filename");
|
||||||
metadata.mimeType = request.getContentType();
|
if (filename == null) filename = "unnamed.mp4";
|
||||||
metadata.filename = request.getHeader("X-Gymboard-Filename");
|
FileMetadata metadata = new FileMetadata(
|
||||||
if (metadata.filename == null) metadata.filename = "unnamed.mp4";
|
filename,
|
||||||
String fileId;
|
request.getContentType(),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
String uploadFileId;
|
||||||
try {
|
try {
|
||||||
fileId = fileStorageService.save(request.getInputStream(), metadata, contentLength);
|
uploadFileId = fileStorageService.save(request.getInputStream(), metadata, contentLength);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Failed to save video upload to temp file.", e);
|
log.error("Failed to save video upload to temp file.", e);
|
||||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
videoTaskRepository.save(new VideoProcessingTask(
|
var task = videoTaskRepository.save(new VideoProcessingTask(
|
||||||
VideoProcessingTask.Status.WAITING,
|
VideoProcessingTask.Status.NOT_STARTED,
|
||||||
fileId,
|
uploadFileId,
|
||||||
"bleh"
|
fileStorageService.generateFileId()
|
||||||
));
|
));
|
||||||
return new FileUploadResponse("bleh");
|
return new VideoUploadResponse(task.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the status of a video processing task.
|
* Gets the status of a video processing task.
|
||||||
* @param id The video identifier.
|
* @param id The task id.
|
||||||
* @return The status of the video processing task.
|
* @return The status of the video processing task.
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) {
|
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(long id) {
|
||||||
VideoProcessingTask task = videoTaskRepository.findByVideoIdentifier(id)
|
VideoProcessingTask task = videoTaskRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||||
return new VideoProcessingTaskStatusResponse(task.getStatus().name());
|
return new VideoProcessingTaskStatusResponse(task.getStatus().name());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
task.setStatus(VideoProcessingTask.Status.WAITING);
|
||||||
|
videoTaskRepository.save(task);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,10 @@
|
||||||
package nl.andrewlalis.gymboardcdn.service;
|
package nl.andrewlalis.gymboardcdn.uploads.service;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask;
|
import nl.andrewlalis.gymboardcdn.files.FileMetadata;
|
||||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
|
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
|
||||||
|
import nl.andrewlalis.gymboardcdn.files.util.ULID;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask;
|
||||||
|
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
@ -65,7 +68,7 @@ public class VideoProcessingService {
|
||||||
private void processVideo(VideoProcessingTask task) {
|
private void processVideo(VideoProcessingTask task) {
|
||||||
log.info("Started processing task {}.", task.getId());
|
log.info("Started processing task {}.", task.getId());
|
||||||
|
|
||||||
Path tempFilePath = fileStorageService.getStoragePathForFile(task.getRawUploadFileId());
|
Path tempFilePath = fileStorageService.getStoragePathForFile(task.getUploadFileId());
|
||||||
if (Files.notExists(tempFilePath) || !Files.isReadable(tempFilePath)) {
|
if (Files.notExists(tempFilePath) || !Files.isReadable(tempFilePath)) {
|
||||||
log.error("Temp file {} doesn't exist or isn't readable.", tempFilePath);
|
log.error("Temp file {} doesn't exist or isn't readable.", tempFilePath);
|
||||||
updateTask(task, VideoProcessingTask.Status.FAILED);
|
updateTask(task, VideoProcessingTask.Status.FAILED);
|
||||||
|
@ -74,19 +77,20 @@ public class VideoProcessingService {
|
||||||
|
|
||||||
// Then begin running the actual FFMPEG processing.
|
// Then begin running the actual FFMPEG processing.
|
||||||
Path tempDir = tempFilePath.getParent();
|
Path tempDir = tempFilePath.getParent();
|
||||||
Files.createTempFile()
|
Path ffmpegOutputFile = tempDir.resolve(task.getUploadFileId() + "-video-out");
|
||||||
Path ffmpegOutputFile = tempDir.resolve(task.getVideoIdentifier());
|
Path ffmpegThumbnailOutputFile = tempDir.resolve(task.getUploadFileId() + "-thumbnail-out");
|
||||||
try {
|
try {
|
||||||
processVideoWithFFMPEG(tempDir, tempFile, ffmpegOutputFile);
|
generateThumbnailWithFFMPEG(tempDir, tempFilePath, ffmpegThumbnailOutputFile);
|
||||||
|
processVideoWithFFMPEG(tempDir, tempFilePath, ffmpegOutputFile);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
log.error("""
|
log.error("""
|
||||||
Video processing failed for video {}:
|
Video processing failed for task {}:
|
||||||
Input file: {}
|
Input file: {}
|
||||||
Output file: {}
|
Output file: {}
|
||||||
Exception message: {}""",
|
Exception message: {}""",
|
||||||
task.getVideoIdentifier(),
|
task.getId(),
|
||||||
tempFile,
|
tempFilePath,
|
||||||
ffmpegOutputFile,
|
ffmpegOutputFile,
|
||||||
e.getMessage()
|
e.getMessage()
|
||||||
);
|
);
|
||||||
|
@ -95,24 +99,41 @@ public class VideoProcessingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// And finally, copy the output to the final location.
|
// And finally, copy the output to the final location.
|
||||||
try {
|
try (
|
||||||
StoredFile storedFile = new StoredFile(
|
var videoIn = Files.newInputStream(ffmpegOutputFile);
|
||||||
task.getVideoIdentifier(),
|
var thumbnailIn = Files.newInputStream(ffmpegThumbnailOutputFile)
|
||||||
task.getFilename(),
|
) {
|
||||||
"video/mp4",
|
// Save the video to a final file location.
|
||||||
Files.size(ffmpegOutputFile),
|
var originalMetadata = fileStorageService.getMetadata(task.getUploadFileId());
|
||||||
task.getCreatedAt()
|
FileMetadata metadata = new FileMetadata(
|
||||||
|
originalMetadata.filename(),
|
||||||
|
originalMetadata.mimeType(),
|
||||||
|
true
|
||||||
);
|
);
|
||||||
Path finalFilePath = fileService.getStoragePathForFile(storedFile);
|
fileStorageService.save(ULID.parseULID(task.getVideoFileId()), videoIn, metadata, Files.size(ffmpegOutputFile));
|
||||||
Files.move(ffmpegOutputFile, finalFilePath);
|
// Save the thumbnail too.
|
||||||
Files.deleteIfExists(tempFile);
|
FileMetadata thumbnailMetadata = new FileMetadata(
|
||||||
Files.deleteIfExists(ffmpegOutputFile);
|
"thumbnail.jpeg",
|
||||||
storedFileRepository.saveAndFlush(storedFile);
|
"image/jpeg",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
fileStorageService.save(thumbnailIn, thumbnailMetadata, Files.size(ffmpegThumbnailOutputFile));
|
||||||
updateTask(task, VideoProcessingTask.Status.COMPLETED);
|
updateTask(task, VideoProcessingTask.Status.COMPLETED);
|
||||||
log.info("Finished processing video {}.", task.getVideoIdentifier());
|
log.info("Finished processing task {}.", task.getId());
|
||||||
|
|
||||||
|
// TODO: Send HTTP POST to API, with video id and thumbnail id.
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Failed to copy processed video to final storage location.", e);
|
log.error("Failed to copy processed video to final storage location.", e);
|
||||||
updateTask(task, VideoProcessingTask.Status.FAILED);
|
updateTask(task, VideoProcessingTask.Status.FAILED);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fileStorageService.delete(task.getUploadFileId());
|
||||||
|
Files.deleteIfExists(ffmpegOutputFile);
|
||||||
|
Files.deleteIfExists(ffmpegThumbnailOutputFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Couldn't delete temporary FFMPEG output file: {}", ffmpegOutputFile);
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
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;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public class FfmpegVideoProcessor implements VideoProcessor {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FfmpegVideoProcessor.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void processVideo(Path inputFilePath, Path outputFilePath) throws IOException {
|
||||||
|
String inputFilename = inputFilePath.getFileName().toString().strip();
|
||||||
|
Path stdoutFile = inputFilePath.resolveSibling(inputFilename + "-ffmpeg-video-stdout.log");
|
||||||
|
Path stderrFile = inputFilePath.resolveSibling(inputFilename + "-ffmpeg-video-stderr.log");
|
||||||
|
final String[] command = {
|
||||||
|
"ffmpeg",
|
||||||
|
"-i", inputFilePath.toAbsolutePath().toString(),
|
||||||
|
"-vf", "scale=640x480:flags=lanczos",
|
||||||
|
"-vcodec", "libx264",
|
||||||
|
"-crf", "28",
|
||||||
|
"-f", "mp4",
|
||||||
|
outputFilePath.toAbsolutePath().toString()
|
||||||
|
};
|
||||||
|
long startFileSize = Files.size(inputFilePath);
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
Process process = new ProcessBuilder(command)
|
||||||
|
.redirectOutput(stdoutFile.toAbsolutePath().toFile())
|
||||||
|
.redirectError(stderrFile.toAbsolutePath().toFile())
|
||||||
|
.start();
|
||||||
|
int result = process.waitFor();
|
||||||
|
if (result != 0) {
|
||||||
|
throw new CommandFailedException(command, result, stdoutFile, stderrFile);
|
||||||
|
}
|
||||||
|
long endFileSize = Files.size(outputFilePath);
|
||||||
|
Duration duration = Duration.between(startTime, Instant.now());
|
||||||
|
double reductionFactor = startFileSize / (double) endFileSize;
|
||||||
|
String reductionFactorStr = String.format("%.3f%%", reductionFactor * 100);
|
||||||
|
log.info("Processed video from {} bytes to {} bytes in {} seconds, {} reduction.", startSize, endSize, dur.getSeconds(), reductionFactorStr);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package nl.andrewlalis.gymboardcdn.uploads.service.process;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public interface ThumbnailGenerator {
|
||||||
|
void generateThumbnailImage(Path videoInputFile, Path outputFilePath);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -1,21 +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.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 {
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in New Issue