Added Video Processing logic.
This commit is contained in:
parent
f76f080311
commit
7d9c210278
|
@ -1,2 +1,4 @@
|
|||
# Gymboard CDN
|
||||
A content delivery and management system for Gymboard, which exposes endpoints for uploading and fetching content.
|
||||
|
||||
This service stores file content in a directory defined by the `app.files.storage-dir` configuration property. Within the storage directory, files are stored like so: `/<year>/<month>/<day>/<file>`.
|
||||
|
|
|
@ -21,6 +21,10 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
|
|
|
@ -4,11 +4,13 @@ import org.springframework.beans.factory.annotation.Value;
|
|||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class Config {
|
||||
@Value("${app.web-origin}")
|
||||
private String webOrigin;
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
package nl.andrewlalis.gymboardcdn;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* The service that manages storing and retrieving files from a base filesystem.
|
||||
*/
|
||||
@Service
|
||||
public class FileService {
|
||||
@Value("${app.files.storage-dir}")
|
||||
private String storageDir;
|
||||
|
||||
@Value("${app.files.temp-dir}")
|
||||
private String tempDir;
|
||||
|
||||
public Path saveToTempFile(MultipartFile file) throws IOException {
|
||||
Path tempDir = getTempDir();
|
||||
String suffix = null;
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename != null) {
|
||||
int idx = filename.lastIndexOf('.');
|
||||
if (idx >= 0) {
|
||||
suffix = filename.substring(idx);
|
||||
}
|
||||
}
|
||||
Path tempFile = Files.createTempFile(tempDir, null, suffix);
|
||||
file.transferTo(tempFile);
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
public Path saveToStorage(String filename, InputStream in) throws IOException {
|
||||
throw new RuntimeException("Not implemented!");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
package nl.andrewlalis.gymboardcdn;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
public class UploadController {
|
||||
@PostMapping(path = "/uploads", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public void uploadContent(@RequestParam MultipartFile file) {
|
||||
|
||||
}
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package nl.andrewlalis.gymboardcdn;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class UploadService {
|
||||
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package nl.andrewlalis.gymboardcdn.api;
|
||||
|
||||
public record FileUploadResponse(
|
||||
String identifier
|
||||
) {}
|
|
@ -0,0 +1,25 @@
|
|||
package nl.andrewlalis.gymboardcdn.api;
|
||||
|
||||
import nl.andrewlalis.gymboardcdn.service.UploadService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
public class UploadController {
|
||||
private final UploadService uploadService;
|
||||
|
||||
public UploadController(UploadService uploadService) {
|
||||
this.uploadService = uploadService;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/uploads/video", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public FileUploadResponse uploadVideo(@RequestParam MultipartFile file) {
|
||||
return uploadService.processableVideoUpload(file);
|
||||
}
|
||||
|
||||
@GetMapping(path = "/uploads/video/{identifier}/status")
|
||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String identifier) {
|
||||
return uploadService.getVideoProcessingStatus(identifier);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package nl.andrewlalis.gymboardcdn.api;
|
||||
|
||||
public record VideoProcessingTaskStatusResponse(
|
||||
String status
|
||||
) {}
|
|
@ -0,0 +1,86 @@
|
|||
package nl.andrewlalis.gymboardcdn.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "stored_file")
|
||||
public class StoredFile {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long 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 internal id that's used to find this file wherever it's placed on
|
||||
* our service's storage. It is universally unique.
|
||||
*/
|
||||
@Column(nullable = false, updatable = false, unique = true)
|
||||
private String identifier;
|
||||
|
||||
/**
|
||||
* 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 name, String identifier, String mimeType, long size, LocalDateTime uploadedAt) {
|
||||
this.name = name;
|
||||
this.identifier = identifier;
|
||||
this.mimeType = mimeType;
|
||||
this.size = size;
|
||||
this.uploadedAt = uploadedAt;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getIdentifier() {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public LocalDateTime getUploadedAt() {
|
||||
return uploadedAt;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package nl.andrewlalis.gymboardcdn.model;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
Optional<StoredFile> findByIdentifier(String identifier);
|
||||
boolean existsByIdentifier(String identifier);
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
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)
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package nl.andrewlalis.gymboardcdn.model;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface VideoProcessingTaskRepository extends JpaRepository<VideoProcessingTask, Long> {
|
||||
Optional<VideoProcessingTask> findByVideoIdentifier(String identifier);
|
||||
|
||||
boolean existsByVideoIdentifier(String identifier);
|
||||
|
||||
List<VideoProcessingTask> findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status status);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package nl.andrewlalis.gymboardcdn.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class CommandFailedException extends IOException {
|
||||
private final Path stdoutFile;
|
||||
private final Path stderrFile;
|
||||
private final int exitCode;
|
||||
private final String[] command;
|
||||
|
||||
public CommandFailedException(String[] command, int exitCode, Path stdoutFile, Path stderrFile) {
|
||||
super(
|
||||
String.format(
|
||||
"Command \"%s\" exited with code %d. stdout available at %s and stderr available at %s.",
|
||||
String.join(" ", command),
|
||||
exitCode,
|
||||
stdoutFile.toAbsolutePath(),
|
||||
stderrFile.toAbsolutePath()
|
||||
)
|
||||
);
|
||||
this.command = command;
|
||||
this.exitCode = exitCode;
|
||||
this.stdoutFile = stdoutFile;
|
||||
this.stderrFile = stderrFile;
|
||||
}
|
||||
|
||||
public Path getStdoutFile() {
|
||||
return stdoutFile;
|
||||
}
|
||||
|
||||
public Path getStderrFile() {
|
||||
return stderrFile;
|
||||
}
|
||||
|
||||
public int getExitCode() {
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
public String[] getCommand() {
|
||||
return command;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
package nl.andrewlalis.gymboardcdn.service;
|
||||
|
||||
import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
|
||||
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
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.Random;
|
||||
|
||||
/**
|
||||
* 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 VideoProcessingTaskRepository videoProcessingTaskRepository;
|
||||
|
||||
public FileService(StoredFileRepository storedFileRepository, VideoProcessingTaskRepository videoProcessingTaskRepository) {
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.videoProcessingTaskRepository = videoProcessingTaskRepository;
|
||||
}
|
||||
|
||||
public Path getStorageDirForTime(LocalDateTime time) throws IOException {
|
||||
Path dir = getStorageDir()
|
||||
.resolve(Integer.toString(time.getYear()))
|
||||
.resolve(Integer.toString(time.getMonthValue()))
|
||||
.resolve(Integer.toString(time.getDayOfMonth()));
|
||||
if (Files.notExists(dir)) Files.createDirectories(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
public String createNewFileIdentifier() {
|
||||
String ident = generateRandomIdentifier();
|
||||
int attempts = 0;
|
||||
while (storedFileRepository.existsByIdentifier(ident) || videoProcessingTaskRepository.existsByVideoIdentifier(ident)) {
|
||||
ident = generateRandomIdentifier();
|
||||
attempts++;
|
||||
if (attempts > 10) {
|
||||
log.warn("Took more than 10 attempts to generate a unique file identifier.");
|
||||
}
|
||||
if (attempts > 100) {
|
||||
log.error("Couldn't generate a unique file identifier after 100 attempts. Quitting!");
|
||||
throw new RuntimeException("Couldn't generate a unique file identifier.");
|
||||
}
|
||||
}
|
||||
return ident;
|
||||
}
|
||||
|
||||
private String generateRandomIdentifier() {
|
||||
StringBuilder sb = new StringBuilder(9);
|
||||
String alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
Random rand = new Random();
|
||||
for (int i = 0; i < 9; i++) sb.append(alphabet.charAt(rand.nextInt(alphabet.length())));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public Path saveToTempFile(MultipartFile file) throws IOException {
|
||||
Path tempDir = getTempDir();
|
||||
String suffix = null;
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename != null) {
|
||||
int idx = filename.lastIndexOf('.');
|
||||
if (idx >= 0) {
|
||||
suffix = filename.substring(idx);
|
||||
}
|
||||
}
|
||||
Path tempFile = Files.createTempFile(tempDir, null, suffix);
|
||||
file.transferTo(tempFile);
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
public Path saveToStorage(String filename, InputStream in) throws IOException {
|
||||
throw new RuntimeException("Not implemented!");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package nl.andrewlalis.gymboardcdn.service;
|
||||
|
||||
import nl.andrewlalis.gymboardcdn.api.FileUploadResponse;
|
||||
import nl.andrewlalis.gymboardcdn.api.VideoProcessingTaskStatusResponse;
|
||||
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.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Service
|
||||
public class UploadService {
|
||||
private static final Logger log = LoggerFactory.getLogger(UploadService.class);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FileUploadResponse processableVideoUpload(MultipartFile file) {
|
||||
Path tempFile;
|
||||
try {
|
||||
tempFile = fileService.saveToTempFile(file);
|
||||
} 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,
|
||||
file.getOriginalFilename(),
|
||||
tempFile.toString(),
|
||||
identifier
|
||||
));
|
||||
return new FileUploadResponse(identifier);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String identifier) {
|
||||
VideoProcessingTask task = videoTaskRepository.findByVideoIdentifier(identifier)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
return new VideoProcessingTaskStatusResponse(task.getStatus().name());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
LocalDateTime uploadedAt = task.getCreatedAt();
|
||||
try {
|
||||
Path finalFilePath = fileService.getStorageDirForTime(uploadedAt)
|
||||
.resolve(task.getVideoIdentifier());
|
||||
Files.move(ffmpegOutputFile, finalFilePath);
|
||||
Files.deleteIfExists(tempFile);
|
||||
Files.deleteIfExists(ffmpegOutputFile);
|
||||
storedFileRepository.saveAndFlush(new StoredFile(
|
||||
task.getFilename(),
|
||||
task.getVideoIdentifier(),
|
||||
"video/mp4",
|
||||
Files.size(ffmpegOutputFile),
|
||||
uploadedAt
|
||||
));
|
||||
updateTask(task, VideoProcessingTask.Status.COMPLETED);
|
||||
} 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,3 +1,7 @@
|
|||
spring.datasource.username=gymboard-cdn-dev
|
||||
spring.datasource.password=testpass
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5433/gymboard-cdn-dev
|
||||
|
||||
server.port=8082
|
||||
|
||||
app.web-origin=http://localhost:9000
|
||||
|
|
Loading…
Reference in New Issue