More uploads refactoring.

This commit is contained in:
Andrew Lalis 2023-04-04 17:06:38 +02:00
parent c00697b3d1
commit d66fa71ae2
23 changed files with 277 additions and 157 deletions

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboardcdn;
import nl.andrewlalis.gymboardcdn.service.FileStorageService;
import nl.andrewlalis.gymboardcdn.util.ULID;
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
import nl.andrewlalis.gymboardcdn.files.util.ULID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.api;
package nl.andrewlalis.gymboardcdn;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;

View File

@ -1,5 +0,0 @@
package nl.andrewlalis.gymboardcdn.api;
public record FileUploadResponse(
String id
) {}

View File

@ -1,8 +1,6 @@
package nl.andrewlalis.gymboardcdn.api;
package nl.andrewlalis.gymboardcdn.files;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

View File

@ -0,0 +1,7 @@
package nl.andrewlalis.gymboardcdn.files;
public record FileMetadata (
String filename,
String mimeType,
boolean accessible
) {}

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.api;
package nl.andrewlalis.gymboardcdn.files;
public record FileMetadataResponse(
String filename,

View File

@ -1,10 +1,8 @@
package nl.andrewlalis.gymboardcdn.service;
package nl.andrewlalis.gymboardcdn.files;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboardcdn.model.FileMetadata;
import nl.andrewlalis.gymboardcdn.model.FullFileMetadata;
import nl.andrewlalis.gymboardcdn.util.ULID;
import nl.andrewlalis.gymboardcdn.files.util.ULID;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@ -46,6 +44,10 @@ public class FileStorageService {
this.baseStorageDir = baseStorageDir;
}
public String generateFileId() {
return ulid.nextULID();
}
/**
* Saves a new file to the storage.
* @param in The input stream to the file contents.
@ -106,8 +108,8 @@ public class FileStorageService {
FileMetadata metadata = readMetadata(in);
LocalDateTime date = dateFromULID(ULID.parseULID(rawId));
return new FullFileMetadata(
metadata.filename,
metadata.mimeType,
metadata.filename(),
metadata.mimeType(),
Files.size(filePath) - HEADER_SIZE,
date.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
);
@ -131,7 +133,7 @@ public class FileStorageService {
try (var in = Files.newInputStream(filePath)) {
FileMetadata metadata = readMetadata(in);
response.setContentType(metadata.mimeType);
response.setContentType(metadata.mimeType());
response.setContentLengthLong(Files.size(filePath) - HEADER_SIZE);
response.addHeader("Cache-Control", "max-age=604800, immutable");
var out = response.getOutputStream();

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.model;
package nl.andrewlalis.gymboardcdn.files;
public record FullFileMetadata(
String filename,

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.util;
package nl.andrewlalis.gymboardcdn.files.util;
/*
* sulky-modules - several general-purpose modules.

View File

@ -1,6 +0,0 @@
package nl.andrewlalis.gymboardcdn.model;
public class FileMetadata {
public String filename;
public String mimeType;
}

View File

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

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboardcdn.api;
package nl.andrewlalis.gymboardcdn.uploads.api;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@ -16,12 +16,17 @@ public class UploadController {
}
@PostMapping(path = "/uploads/video", consumes = {"video/mp4"})
public FileUploadResponse uploadVideo(HttpServletRequest request) {
public VideoUploadResponse uploadVideo(HttpServletRequest request) {
return uploadService.processableVideoUpload(request);
}
@GetMapping(path = "/uploads/video/{id}/status")
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String id) {
return uploadService.getVideoProcessingStatus(id);
@PostMapping(path = "/uploads/video/{taskId}/start")
public void startVideoProcessing(@PathVariable long taskId) {
uploadService.startVideoProcessing(taskId);
}
@GetMapping(path = "/uploads/video/{taskId}/status")
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable long taskId) {
return uploadService.getVideoProcessingStatus(taskId);
}
}

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.api;
package nl.andrewlalis.gymboardcdn.uploads.api;
public record VideoProcessingTaskStatusResponse(
String status

View File

@ -0,0 +1,5 @@
package nl.andrewlalis.gymboardcdn.uploads.api;
public record VideoUploadResponse(
long taskId
) {}

View File

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

View File

@ -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.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface VideoProcessingTaskRepository extends JpaRepository<VideoProcessingTask, Long> {
Optional<VideoProcessingTask> findByVideoIdentifier(String identifier);
List<VideoProcessingTask> findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status status);
List<VideoProcessingTask> findAllByCreatedAtBefore(LocalDateTime cutoff);

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.service;
package nl.andrewlalis.gymboardcdn.uploads.service;
import java.io.IOException;
import java.nio.file.Path;

View File

@ -1,11 +1,12 @@
package nl.andrewlalis.gymboardcdn.service;
package nl.andrewlalis.gymboardcdn.uploads.service;
import jakarta.servlet.http.HttpServletRequest;
import nl.andrewlalis.gymboardcdn.api.FileUploadResponse;
import nl.andrewlalis.gymboardcdn.api.VideoProcessingTaskStatusResponse;
import nl.andrewlalis.gymboardcdn.model.FileMetadata;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
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;
@ -33,11 +34,12 @@ public class UploadService {
* 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.
* @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 FileUploadResponse processableVideoUpload(HttpServletRequest request) {
public VideoUploadResponse processableVideoUpload(HttpServletRequest request) {
String contentLengthStr = request.getHeader("Content-Length");
if (contentLengthStr == null || !contentLengthStr.matches("\\d+")) {
throw new ResponseStatusException(HttpStatus.LENGTH_REQUIRED);
@ -46,34 +48,51 @@ public class UploadService {
if (contentLength > MAX_UPLOAD_SIZE_BYTES) {
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE);
}
FileMetadata metadata = new FileMetadata();
metadata.mimeType = request.getContentType();
metadata.filename = request.getHeader("X-Gymboard-Filename");
if (metadata.filename == null) metadata.filename = "unnamed.mp4";
String fileId;
String filename = request.getHeader("X-Gymboard-Filename");
if (filename == null) filename = "unnamed.mp4";
FileMetadata metadata = new FileMetadata(
filename,
request.getContentType(),
false
);
String uploadFileId;
try {
fileId = fileStorageService.save(request.getInputStream(), metadata, contentLength);
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);
}
videoTaskRepository.save(new VideoProcessingTask(
VideoProcessingTask.Status.WAITING,
fileId,
"bleh"
var task = videoTaskRepository.save(new VideoProcessingTask(
VideoProcessingTask.Status.NOT_STARTED,
uploadFileId,
fileStorageService.generateFileId()
));
return new FileUploadResponse("bleh");
return new VideoUploadResponse(task.getId());
}
/**
* 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.
*/
@Transactional(readOnly = true)
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) {
VideoProcessingTask task = videoTaskRepository.findByVideoIdentifier(id)
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(long id) {
VideoProcessingTask task = videoTaskRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
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);
}
}

View File

@ -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.model.VideoProcessingTaskRepository;
import nl.andrewlalis.gymboardcdn.files.FileMetadata;
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.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
@ -65,7 +68,7 @@ public class VideoProcessingService {
private void processVideo(VideoProcessingTask task) {
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)) {
log.error("Temp file {} doesn't exist or isn't readable.", tempFilePath);
updateTask(task, VideoProcessingTask.Status.FAILED);
@ -74,19 +77,20 @@ public class VideoProcessingService {
// Then begin running the actual FFMPEG processing.
Path tempDir = tempFilePath.getParent();
Files.createTempFile()
Path ffmpegOutputFile = tempDir.resolve(task.getVideoIdentifier());
Path ffmpegOutputFile = tempDir.resolve(task.getUploadFileId() + "-video-out");
Path ffmpegThumbnailOutputFile = tempDir.resolve(task.getUploadFileId() + "-thumbnail-out");
try {
processVideoWithFFMPEG(tempDir, tempFile, ffmpegOutputFile);
generateThumbnailWithFFMPEG(tempDir, tempFilePath, ffmpegThumbnailOutputFile);
processVideoWithFFMPEG(tempDir, tempFilePath, ffmpegOutputFile);
} catch (Exception e) {
e.printStackTrace();
log.error("""
Video processing failed for video {}:
Video processing failed for task {}:
Input file: {}
Output file: {}
Exception message: {}""",
task.getVideoIdentifier(),
tempFile,
task.getId(),
tempFilePath,
ffmpegOutputFile,
e.getMessage()
);
@ -95,24 +99,41 @@ public class VideoProcessingService {
}
// 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()
try (
var videoIn = Files.newInputStream(ffmpegOutputFile);
var thumbnailIn = Files.newInputStream(ffmpegThumbnailOutputFile)
) {
// Save the video to a final file location.
var originalMetadata = fileStorageService.getMetadata(task.getUploadFileId());
FileMetadata metadata = new FileMetadata(
originalMetadata.filename(),
originalMetadata.mimeType(),
true
);
Path finalFilePath = fileService.getStoragePathForFile(storedFile);
Files.move(ffmpegOutputFile, finalFilePath);
Files.deleteIfExists(tempFile);
Files.deleteIfExists(ffmpegOutputFile);
storedFileRepository.saveAndFlush(storedFile);
fileStorageService.save(ULID.parseULID(task.getVideoFileId()), videoIn, metadata, Files.size(ffmpegOutputFile));
// Save the thumbnail too.
FileMetadata thumbnailMetadata = new FileMetadata(
"thumbnail.jpeg",
"image/jpeg",
true
);
fileStorageService.save(thumbnailIn, thumbnailMetadata, Files.size(ffmpegThumbnailOutputFile));
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) {
log.error("Failed to copy processed video to final storage location.", e);
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();
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,21 +1,11 @@
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.mockito.Mockito;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
public class UploadServiceTest {
/**