More uploads refactoring.
This commit is contained in:
parent
c00697b3d1
commit
d66fa71ae2
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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 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;
|
|
@ -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(
|
||||
String filename,
|
|
@ -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();
|
|
@ -1,4 +1,4 @@
|
|||
package nl.andrewlalis.gymboardcdn.model;
|
||||
package nl.andrewlalis.gymboardcdn.files;
|
||||
|
||||
public record FullFileMetadata(
|
||||
String filename,
|
|
@ -1,4 +1,4 @@
|
|||
package nl.andrewlalis.gymboardcdn.util;
|
||||
package nl.andrewlalis.gymboardcdn.files.util;
|
||||
|
||||
/*
|
||||
* 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 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);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package nl.andrewlalis.gymboardcdn.api;
|
||||
package nl.andrewlalis.gymboardcdn.uploads.api;
|
||||
|
||||
public record VideoProcessingTaskStatusResponse(
|
||||
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.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);
|
|
@ -1,4 +1,4 @@
|
|||
package nl.andrewlalis.gymboardcdn.service;
|
||||
package nl.andrewlalis.gymboardcdn.uploads.service;
|
||||
|
||||
import java.io.IOException;
|
||||
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 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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
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 {
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue