Backend MP4 conversion (#23)

* ADD conversion queue

* ADD RemuxService for MP4 conversion

* REMOVE unused conversion queue

* REORGANISE Job-related classes

* ADD Job stages

* REVERT to old commit, using Spring Async instead

* ADD asynchronous processing for video tasks

* PATCH and streamline progress tracking

* ADD asynchronous video processing and job restructuring

* REFACTOR job service method

* ADD job remux functionality

* ADD remuxing endpoint

* PATCH complete flag not updating in API response

* ADD progress type in frontend

* ADD reset functionality for job status

* PATCH missing progress bar for subsequent exports

* REDESIGN settings box

* ADD tracking video file conversion in frontend

* PATCH extension bug

* REMOVE autowired decorator
This commit is contained in:
Dylan De Faoite
2025-07-31 20:48:34 +02:00
committed by GitHub
parent 338eb605fd
commit 20f7ec8db4
24 changed files with 324 additions and 185 deletions

View File

@@ -9,7 +9,7 @@ type prop = {
export default function ConfigBox({setMetadata, className}: prop) { export default function ConfigBox({setMetadata, className}: prop) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
var vals = e.target.value.split(","); const vals = e.target.value.split(",");
setMetadata((prevState: VideoMetadata) => ({ setMetadata((prevState: VideoMetadata) => ({
...prevState, ...prevState,
width: parseInt(vals[0]), width: parseInt(vals[0]),
@@ -33,14 +33,14 @@ export default function ConfigBox({setMetadata, className}: prop) {
return ( return (
<div className={clsx("flex flex-col gap-2 p-10", className)}> <div className={clsx("flex flex-col gap-2 p-10", className)}>
<h2 className={"text-3xl font-bold mb-4"}>Export Settings</h2> <h2 className={"text-xl font-bold"}>Export</h2>
<Selector label={"Resolution"}> <Selector label={"Resolution"}>
<select id="resolution" <select id="resolution"
name="resolution" name="resolution"
defaultValue="1280,720" defaultValue="1280,720"
onChange={updateRes} onChange={updateRes}
className={"border-black bg-gray-200 rounded-md w-full"}> className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<option value="3840,2160">2160p</option> <option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option> <option value="2560,1440">1440p</option>
<option value="1920,1080">1080p</option> <option value="1920,1080">1080p</option>
@@ -55,7 +55,7 @@ export default function ConfigBox({setMetadata, className}: prop) {
name="fps" name="fps"
defaultValue="30" defaultValue="30"
onChange={updateFps} onChange={updateFps}
className={"border-black bg-gray-200 rounded-md w-full"}> className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<option value="60">60</option> <option value="60">60</option>
<option value="30">30</option> <option value="30">30</option>
<option value="15">15</option> <option value="15">15</option>
@@ -67,7 +67,7 @@ export default function ConfigBox({setMetadata, className}: prop) {
min="1" min="1"
defaultValue="10" defaultValue="10"
onChange={updateFileSize} onChange={updateFileSize}
className={"border-black bg-gray-200 rounded-md w-full"} className={"border-black bg-gray-200 rounded-md w-full p-2"}
/> />
</Selector> </Selector>

View File

@@ -1,6 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import type {VideoMetadata} from "../../utils/types.ts"; import type {VideoMetadata} from "../../utils/types.ts";
import Selector from "../Selector.tsx";
type MetadataBoxProps = { type MetadataBoxProps = {
setMetadata: Function setMetadata: Function
@@ -10,9 +9,9 @@ type MetadataBoxProps = {
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => { const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
return ( return (
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}> <div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
<h2 className={"text-3xl font-bold mb-4 col-span-2"}>Metadata Settings</h2> {/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/}
<Selector label={"Title"}> <p className={"w-full font-bold text-xl "}>Title</p>
<input <input
type="text" type="text"
placeholder="Enter title" placeholder="Enter title"
@@ -22,18 +21,6 @@ const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
}))} }))}
className={"border-black bg-gray-200 rounded-md w-full p-2"} className={"border-black bg-gray-200 rounded-md w-full p-2"}
/> />
</Selector>
<Selector label={"Description"}>
<textarea
placeholder="Enter description"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
description: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2 pb-10 resize-none max-h"}
/>
</Selector>
</div> </div>
) )
} }

View File

@@ -49,11 +49,13 @@ const ClipEdit = () => {
const pollProgress = async (id: string, intervalId: number) => { const pollProgress = async (id: string, intervalId: number) => {
getProgress(id) getProgress(id)
.then((progress) => { .then((progress) => {
setProgress(progress); setProgress(progress.process.progress);
if (progress >= 1) { if (progress.process.complete) {
clearInterval(intervalId); clearInterval(intervalId);
setDownloadable(true); setDownloadable(true);
} else {
setDownloadable(false)
} }
}) })
.catch((err: Error) => { .catch((err: Error) => {

View File

@@ -1,25 +1,75 @@
import {useState} from "react"; import {useState} from "react";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import { uploadFile } from "../utils/endpoints" import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
import BlueButton from "../components/buttons/BlueButton.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
const clipUpload = () => { const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [progress, setProgress] = useState<number>(0);
const isVideoFileSupported = (file: File): boolean => {
const video = document.createElement("video");
if (file.type && video.canPlayType(file.type) !== "") {
return true;
}
const extension = file.name.split(".").pop()?.toLowerCase();
const extensionToMime: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
ogg: "video/ogg",
mov: "video/quicktime",
};
if (extension && extensionToMime[extension]) {
return video.canPlayType(extensionToMime[extension]) !== "";
}
return false;
};
const press = (() => { const press = (() => {
if (!file) { if (!file) {
setError("Please choose a file"); setError("Please choose a file");
return; return;
} }
uploadFile(file) uploadFile(file)
.then(uuid => navigate(`/create/${uuid}`)) .then(uuid => {
if (isVideoFileSupported(file)) {
navigate(`/create/${uuid}`)
} else {
convertFile(uuid);
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
}
})
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`)); .catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
}); });
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress.conversion.progress);
if (progress.conversion.complete) {
clearInterval(intervalId);
navigate(`/create/${id}`)
}
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
});
}
return ( return (
<Box className={"flex flex-col justify-between gap-3 p-5"}> <Box className={"flex flex-col justify-between gap-3 p-5"}>
<input <input
@@ -37,8 +87,12 @@ const clipUpload = () => {
</BlueButton> </BlueButton>
<label className={"text-center text-red-500"}>{error}</label> <label className={"text-center text-red-500"}>{error}</label>
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress>
</Box> </Box>
) )
}; };
export default clipUpload; export default ClipUpload;

View File

@@ -1,4 +1,4 @@
import type {VideoMetadata, APIResponse, User, Clip} from "./types.ts"; import type {VideoMetadata, APIResponse, User, Clip, ProgressResult } from "./types.ts";
/** /**
* Uploads a file to the backend. * Uploads a file to the backend.
@@ -74,11 +74,25 @@ const processFile = async (uuid: string) => {
} }
}; };
const convertFile = async (uuid: string) => {
const response = await fetch(`/api/v1/convert/${uuid}`);
if (!response.ok) {
throw new Error(`Failed to convert file: ${response.status}`);
}
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error("Failed to convert file: " + result.message);
}
};
/** /**
* Fetches the processing progress percentage. * Fetches the processing progress percentage.
* @param uuid - The UUID of the video file. * @param uuid - The UUID of the video file.
*/ */
const getProgress = async (uuid: string): Promise<number> => { const getProgress = async (uuid: string): Promise<ProgressResult> => {
const response = await fetch(`/api/v1/progress/${uuid}`); const response = await fetch(`/api/v1/progress/${uuid}`);
if (!response.ok) { if (!response.ok) {
@@ -91,11 +105,11 @@ const getProgress = async (uuid: string): Promise<number> => {
throw new Error(`Failed to fetch progress: ${result.message}`); throw new Error(`Failed to fetch progress: ${result.message}`);
} }
if (!result.data || typeof result.data.progress !== 'number') { if (!result.data) {
throw new Error('Invalid progress data received'); throw new Error('Invalid progress data received');
} }
return result.data.progress; return result.data;
}; };
/** /**
@@ -189,6 +203,7 @@ export {
uploadFile, uploadFile,
editFile, editFile,
processFile, processFile,
convertFile,
getProgress, getProgress,
getMetadata, getMetadata,
getUser, getUser,

View File

@@ -30,9 +30,21 @@ type Clip = {
createdAt: string, createdAt: string,
} }
type ProgressResult = {
process: {
progress: number,
complete: boolean
};
conversion: {
progress: number,
complete: boolean
};
};
export type { export type {
APIResponse, APIResponse,
VideoMetadata, VideoMetadata,
User, User,
Clip Clip,
ProgressResult
} }

View File

@@ -2,10 +2,12 @@ package com.ddf.vodsystem;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableAsync
public class VodSystemApplication { public class VodSystemApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -0,0 +1,22 @@
package com.ddf.vodsystem.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class AsyncConfig {
@Bean(name = "ffmpegTaskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("ffmpegExecutor-");
executor.initialize();
return executor;
}
}

View File

@@ -32,7 +32,7 @@ public class SecurityConfig {
.requestMatchers("/api/v1/download/clip/**").authenticated() .requestMatchers("/api/v1/download/clip/**").authenticated()
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll() .requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll()
.requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll() .requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll()
.requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**").permitAll() .requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**", "/api/v1/convert/**").permitAll()
.requestMatchers("/api/v1/metadata/**").permitAll() .requestMatchers("/api/v1/metadata/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )

View File

@@ -1,9 +1,8 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.JobStatus;
import com.ddf.vodsystem.dto.VideoMetadata; import com.ddf.vodsystem.dto.VideoMetadata;
import com.ddf.vodsystem.services.EditService; import com.ddf.vodsystem.services.EditService;
import lombok.AllArgsConstructor;
import lombok.Data;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -26,22 +25,20 @@ public class EditController {
} }
@GetMapping("/process/{uuid}") @GetMapping("/process/{uuid}")
public ResponseEntity<APIResponse<Void>> convert(@PathVariable("uuid") String uuid) { public ResponseEntity<APIResponse<Void>> process(@PathVariable("uuid") String uuid) {
editService.process(uuid); editService.process(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Processing started for UUID: " + uuid, null)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Processing started for UUID: " + uuid, null));
} }
@GetMapping("/convert/{uuid}")
public ResponseEntity<APIResponse<Void>> convert(@PathVariable("uuid") String uuid) {
editService.convert(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Conversion started for UUID: " + uuid, null));
}
@GetMapping("/progress/{uuid}") @GetMapping("/progress/{uuid}")
public ResponseEntity<APIResponse<ProgressResponse>> getProgress(@PathVariable("uuid") String uuid) { public ResponseEntity<APIResponse<JobStatus>> getProgress(@PathVariable("uuid") String uuid) {
float progress = editService.getProgress(uuid); JobStatus status = editService.getProgress(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Progress for UUID: " + uuid, status));
ProgressResponse progressResponse = new ProgressResponse(progress);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Progress for UUID: " + uuid, progressResponse));
}
@Data
@AllArgsConstructor
public static class ProgressResponse {
private float progress;
} }
} }

View File

@@ -1,9 +1,7 @@
package com.ddf.vodsystem.entities; package com.ddf.vodsystem.dto;
import java.io.File; import java.io.File;
import com.ddf.vodsystem.dto.ProgressTracker;
import com.ddf.vodsystem.dto.VideoMetadata;
import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContext;
import lombok.Data; import lombok.Data;
@@ -22,8 +20,7 @@ public class Job {
private SecurityContext securityContext; private SecurityContext securityContext;
// job status // job status
private JobStatus status = JobStatus.NOT_READY; private JobStatus status = new JobStatus();
private ProgressTracker progress = new ProgressTracker(0.0f);
public Job(String uuid, File inputFile, File outputFile, VideoMetadata inputVideoMetadata) { public Job(String uuid, File inputFile, File outputFile, VideoMetadata inputVideoMetadata) {
this.uuid = uuid; this.uuid = uuid;

View File

@@ -0,0 +1,11 @@
package com.ddf.vodsystem.dto;
import lombok.Data;
@Data
public class JobStatus {
private ProgressTracker process = new ProgressTracker();
private ProgressTracker conversion = new ProgressTracker();
}

View File

@@ -1,19 +1,30 @@
package com.ddf.vodsystem.dto; package com.ddf.vodsystem.dto;
import lombok.Getter;
@Getter
public class ProgressTracker { public class ProgressTracker {
private float progress; private float progress = 0.0f;
private boolean isComplete = false;
public ProgressTracker(float initialProgress) { public synchronized float getProgress() {
this.progress = initialProgress; return progress;
} }
public void setProgress(float newProgress) { public synchronized boolean isComplete() {
return isComplete;
}
public synchronized void setProgress(float newProgress) {
if (newProgress < 0 || newProgress > 1) { if (newProgress < 0 || newProgress > 1) {
throw new IllegalArgumentException("Progress must be between 0 and 1"); throw new IllegalArgumentException("Progress must be between 0 and 1");
} }
this.progress = newProgress; this.progress = newProgress;
} }
public synchronized void markComplete() {
this.isComplete = true;
}
public synchronized void reset() {
this.progress = 0.0f;
this.isComplete = false;
}
} }

View File

@@ -1,9 +0,0 @@
package com.ddf.vodsystem.entities;
public enum JobStatus {
NOT_READY,
PENDING,
RUNNING,
FINISHED,
FAILED
}

View File

@@ -8,7 +8,6 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.ClipRepository; import com.ddf.vodsystem.repositories.ClipRepository;
@@ -57,26 +56,22 @@ public class ClipService {
* @param progress A tracker to monitor the progress of the video processing. * @param progress A tracker to monitor the progress of the video processing.
* @throws IOException if an I/O error occurs during file processing. * @throws IOException if an I/O error occurs during file processing.
* @throws InterruptedException if the thread is interrupted during processing. * @throws InterruptedException if the thread is interrupted during processing.
* @return An Optional containing the created Clip if the user is authenticated, otherwise an empty Optional.
*/ */
public Optional<Clip> create(VideoMetadata inputMetadata, public void create(VideoMetadata inputMetadata,
VideoMetadata outputMetadata, VideoMetadata outputMetadata,
File inputFile, File inputFile,
File outputFile, File outputFile,
ProgressTracker progress) throws IOException, InterruptedException { ProgressTracker progress)
metadataService.normalizeVideoMetadata(inputMetadata, outputMetadata); throws IOException, InterruptedException {
compressionService.compress(inputFile, outputFile, outputMetadata, progress);
Float fileSize = metadataService.getVideoMetadata(outputFile).getFileSize();
outputMetadata.setFileSize(fileSize);
User user = userService.getUser(); User user = userService.getUser();
metadataService.normalizeVideoMetadata(inputMetadata, outputMetadata);
if (user == null) { compressionService.compress(inputFile, outputFile, outputMetadata, progress)
return Optional.empty(); .thenRun(() -> {
if (user != null) {
persistClip(outputMetadata, user, outputFile, inputFile.getName());
} }
});
return Optional.of(persistClip(outputMetadata, user, outputFile, inputFile.getName()));
} }
public List<Clip> getClipsByUser() { public List<Clip> getClipsByUser() {
@@ -136,7 +131,7 @@ public class ClipService {
return user.getId().equals(clip.getUser().getId()); return user.getId().equals(clip.getUser().getId());
} }
private Clip persistClip(VideoMetadata videoMetadata, private void persistClip(VideoMetadata videoMetadata,
User user, User user,
File tempFile, File tempFile,
String fileName) { String fileName) {
@@ -166,6 +161,6 @@ public class ClipService {
clip.setFileSize(videoMetadata.getFileSize()); clip.setFileSize(videoMetadata.getFileSize());
clip.setVideoPath(clipFile.getPath()); clip.setVideoPath(clipFile.getPath());
clip.setThumbnailPath(thumbnailFile.getPath()); clip.setThumbnailPath(thumbnailFile.getPath());
return clipRepository.save(clip); clipRepository.save(clip);
} }
} }

View File

@@ -1,13 +1,11 @@
package com.ddf.vodsystem.services; package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.entities.JobStatus;
import com.ddf.vodsystem.exceptions.JobNotFinished; import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound; import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.entities.Job; import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.ClipRepository; import com.ddf.vodsystem.repositories.ClipRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -21,7 +19,6 @@ public class DownloadService {
private final ClipRepository clipRepository; private final ClipRepository clipRepository;
private final ClipService clipService; private final ClipService clipService;
@Autowired
public DownloadService(JobService jobService, public DownloadService(JobService jobService,
ClipRepository clipRepository, ClipRepository clipRepository,
ClipService clipService) { ClipService clipService) {
@@ -48,7 +45,7 @@ public class DownloadService {
throw new JobNotFound("Job doesn't exist"); throw new JobNotFound("Job doesn't exist");
} }
if (job.getStatus() != JobStatus.FINISHED) { if (!job.getStatus().getProcess().isComplete()) {
throw new JobNotFinished("Job is not finished"); throw new JobNotFinished("Job is not finished");
} }

View File

@@ -1,7 +1,8 @@
package com.ddf.vodsystem.services; package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.JobStatus;
import com.ddf.vodsystem.dto.VideoMetadata; import com.ddf.vodsystem.dto.VideoMetadata;
import com.ddf.vodsystem.entities.*; import com.ddf.vodsystem.dto.Job;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@@ -20,17 +21,18 @@ public class EditService {
} }
public void process(String uuid) { public void process(String uuid) {
jobService.jobReady(uuid);
}
public float getProgress(String uuid) {
Job job = jobService.getJob(uuid); Job job = jobService.getJob(uuid);
jobService.processJob(job);
if (job.getStatus() == JobStatus.FINISHED) {
return 1f;
} }
return job.getProgress().getProgress(); public void convert(String uuid) {
Job job = jobService.getJob(uuid);
jobService.convertJob(job);
}
public JobStatus getProgress(String uuid) {
Job job = jobService.getJob(uuid);
return job.getStatus();
} }
private void validateClipConfig(VideoMetadata videoMetadata) { private void validateClipConfig(VideoMetadata videoMetadata) {

View File

@@ -1,22 +1,17 @@
package com.ddf.vodsystem.services; package com.ddf.vodsystem.services;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.services.media.RemuxService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.ddf.vodsystem.entities.Job;
import com.ddf.vodsystem.entities.JobStatus;
import com.ddf.vodsystem.exceptions.JobNotFound; import com.ddf.vodsystem.exceptions.JobNotFound;
import jakarta.annotation.PostConstruct;
/** /**
* Service for managing and processing jobs in a background thread. * Service for managing and processing jobs in a background thread.
* Uses a blocking queue to avoid busy waiting and ensures jobs are processed sequentially. * Uses a blocking queue to avoid busy waiting and ensures jobs are processed sequentially.
@@ -25,15 +20,19 @@ import jakarta.annotation.PostConstruct;
public class JobService { public class JobService {
private static final Logger logger = LoggerFactory.getLogger(JobService.class); private static final Logger logger = LoggerFactory.getLogger(JobService.class);
private final ConcurrentHashMap<String, Job> jobs = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Job> jobs = new ConcurrentHashMap<>();
private final BlockingQueue<Job> jobQueue = new LinkedBlockingQueue<>();
private final ClipService clipService; private final ClipService clipService;
private final RemuxService remuxService;
private final DirectoryService directoryService;
/** /**
* Constructs a JobService with the given CompressionService. * Constructs a JobService with the given CompressionService.
* @param clipService the compression service to use for processing jobs * @param clipService the compression service to use for processing jobs
*/ */
public JobService(ClipService clipService) { public JobService(ClipService clipService,
RemuxService remuxService, DirectoryService directoryService) {
this.clipService = clipService; this.clipService = clipService;
this.remuxService = remuxService;
this.directoryService = directoryService;
} }
/** /**
@@ -61,76 +60,49 @@ public class JobService {
return job; return job;
} }
public void convertJob(Job job) {
logger.info("Converting job: {}", job.getUuid());
File tempFile = new File(job.getInputFile().getAbsolutePath() + ".temp");
directoryService.copyFile(job.getInputFile(), tempFile);
job.getStatus().getConversion().reset();
try {
remuxService.remux(
tempFile,
job.getInputFile(),
job.getStatus().getConversion(),
job.getInputVideoMetadata().getEndPoint())
.thenRun(() -> {
job.getStatus().getConversion().markComplete();
directoryService.deleteFile(tempFile);
});
} catch (IOException | InterruptedException e) {
logger.error("Error converting job {}: {}", job.getUuid(), e.getMessage());
Thread.currentThread().interrupt();
}
}
/** /**
* Marks a job as ready and adds it to the processing queue. * Marks a job as ready and adds it to the processing queue.
* @param uuid the UUID of the job to mark as ready * @param job The job to process
*/ */
public void jobReady(String uuid) { public void processJob(Job job) {
Job job = getJob(uuid);
SecurityContext context = SecurityContextHolder.getContext();
job.setSecurityContext(context);
logger.info("Job ready: {}", job.getUuid()); logger.info("Job ready: {}", job.getUuid());
job.setStatus(JobStatus.PENDING); job.getStatus().getProcess().reset();
jobQueue.add(job);
}
/**
* Processes a job by running the compression service.
* @param job the job to process
*/
private void processJob(Job job) {
SecurityContext previousContext = SecurityContextHolder.getContext(); // optional, for restoring later
try { try {
if (job.getSecurityContext() != null) {
SecurityContextHolder.setContext(job.getSecurityContext());
}
clipService.create( clipService.create(
job.getInputVideoMetadata(), job.getInputVideoMetadata(),
job.getOutputVideoMetadata(), job.getOutputVideoMetadata(),
job.getInputFile(), job.getInputFile(),
job.getOutputFile(), job.getOutputFile(),
job.getProgress() job.getStatus().getProcess()
); );
job.setStatus(JobStatus.FINISHED);
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
logger.error("Error processing job {}: {}", job.getUuid(), e.getMessage());
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
logger.error("Error while running job {}", job.getUuid(), e);
} finally {
// 🔄 Restore previous context to avoid leaking across jobs
SecurityContextHolder.setContext(previousContext);
} }
} }
/**
* Starts the background processing loop in a daemon thread.
* The loop blocks until a job is available and then processes it.
*/
@PostConstruct
private void startProcessingLoop() {
Thread thread = new Thread(() -> {
logger.info("Starting processing loop");
while (true) {
try {
Job job = jobQueue.take(); // Blocks until a job is available
logger.info("Starting job {}", job.getUuid());
job.setStatus(JobStatus.RUNNING);
processJob(job);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Processing loop interrupted", e);
break;
}
}
});
thread.setDaemon(true);
thread.start();
}
} }

View File

@@ -1,9 +1,9 @@
package com.ddf.vodsystem.services; package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Job; import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.dto.VideoMetadata; import com.ddf.vodsystem.dto.VideoMetadata;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.services.media.MetadataService; import com.ddf.vodsystem.services.media.MetadataService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -11,6 +11,9 @@ import java.io.File;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.Base64; import java.util.Base64;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -23,7 +26,6 @@ public class UploadService {
private final MetadataService metadataService; private final MetadataService metadataService;
private final DirectoryService directoryService; private final DirectoryService directoryService;
@Autowired
public UploadService(JobService jobService, public UploadService(JobService jobService,
MetadataService metadataService, MetadataService metadataService,
DirectoryService directoryService) { DirectoryService directoryService) {
@@ -38,12 +40,19 @@ public class UploadService {
String extension = directoryService.getFileExtension(file.getOriginalFilename()); String extension = directoryService.getFileExtension(file.getOriginalFilename());
File inputFile = directoryService.getTempInputFile(uuid + "." + extension); File inputFile = directoryService.getTempInputFile(uuid + "." + extension);
File outputFile = directoryService.getTempOutputFile(uuid + "." + extension); File outputFile = directoryService.getTempOutputFile(uuid + ".mp4");
directoryService.saveAtDir(inputFile, file); directoryService.saveAtDir(inputFile, file);
// add job // add job
logger.info("Uploaded file and creating job with UUID: {}", uuid); logger.info("Uploaded file and creating job with UUID: {}", uuid);
VideoMetadata videoMetadata = metadataService.getVideoMetadata(inputFile);
VideoMetadata videoMetadata;
try {
videoMetadata = metadataService.getVideoMetadata(inputFile).get(5, TimeUnit.SECONDS);
} catch (ExecutionException | TimeoutException | InterruptedException e) {
Thread.currentThread().interrupt();
throw new FFMPEGException(e.getMessage());
}
Job job = new Job(uuid, inputFile, outputFile, videoMetadata); Job job = new Job(uuid, inputFile, outputFile, videoMetadata);
jobService.add(job); jobService.add(job);

View File

@@ -1,15 +1,18 @@
package com.ddf.vodsystem.services.media; package com.ddf.vodsystem.services.media;
import com.ddf.vodsystem.dto.CommandOutput;
import com.ddf.vodsystem.dto.ProgressTracker; import com.ddf.vodsystem.dto.ProgressTracker;
import com.ddf.vodsystem.dto.VideoMetadata; import com.ddf.vodsystem.dto.VideoMetadata;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -22,12 +25,21 @@ public class CompressionService {
private static final float BITRATE_MULTIPLIER = 0.9f; private static final float BITRATE_MULTIPLIER = 0.9f;
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
public void compress(File inputFile, File outputFile, VideoMetadata videoMetadata, ProgressTracker progress) throws IOException, InterruptedException { @Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> compress(File inputFile,
File outputFile,
VideoMetadata videoMetadata,
ProgressTracker progress
) throws IOException, InterruptedException {
logger.info("Compressing video from {} to {}", inputFile.getAbsolutePath(), outputFile.getAbsolutePath()); logger.info("Compressing video from {} to {}", inputFile.getAbsolutePath(), outputFile.getAbsolutePath());
float length = videoMetadata.getEndPoint() - videoMetadata.getStartPoint(); float length = videoMetadata.getEndPoint() - videoMetadata.getStartPoint();
List<String> command = buildCommand(inputFile, outputFile, videoMetadata); List<String> command = buildCommand(inputFile, outputFile, videoMetadata);
CommandRunner.run(command, line -> setProgress(line, progress, length));
CommandOutput result = CommandRunner.run(command, line -> setProgress(line, progress, length));
progress.markComplete();
return CompletableFuture.completedFuture(result);
} }
private void setProgress(String line, ProgressTracker progress, float length) { private void setProgress(String line, ProgressTracker progress, float length) {

View File

@@ -7,17 +7,21 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
@Service @Service
public class MetadataService { public class MetadataService {
private static final Logger logger = LoggerFactory.getLogger(MetadataService.class); private static final Logger logger = LoggerFactory.getLogger(MetadataService.class);
public VideoMetadata getVideoMetadata(File file) { @Async("ffmpegTaskExecutor")
public Future<VideoMetadata> getVideoMetadata(File file) {
logger.info("Getting metadata for file {}", file.getAbsolutePath()); logger.info("Getting metadata for file {}", file.getAbsolutePath());
List<String> command = List.of( List<String> command = List.of(
@@ -40,7 +44,7 @@ public class MetadataService {
} }
JsonNode node = mapper.readTree(outputBuilder.toString()); JsonNode node = mapper.readTree(outputBuilder.toString());
return parseVideoMetadata(node); return CompletableFuture.completedFuture(parseVideoMetadata(node));
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new FFMPEGException("Error while getting video metadata: " + e); throw new FFMPEGException("Error while getting video metadata: " + e);

View File

@@ -0,0 +1,46 @@
package com.ddf.vodsystem.services.media;
import com.ddf.vodsystem.dto.CommandOutput;
import com.ddf.vodsystem.dto.ProgressTracker;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class RemuxService {
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
@Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> remux(File inputFile,
File outputFile,
ProgressTracker remuxProgress,
float length
) throws IOException, InterruptedException {
List<String> command = List.of(
"ffmpeg",
"-progress", "pipe:1",
"-y",
"-i", inputFile.getAbsolutePath(),
"-c:v", "h264",
"-c:a", "aac",
"-f", "mp4",
outputFile.getAbsolutePath()
);
return CompletableFuture.completedFuture(CommandRunner.run(command, line -> setProgress(line, remuxProgress, length)));
}
private void setProgress(String line, ProgressTracker progress, float length) {
Matcher matcher = timePattern.matcher(line);
if (matcher.find()) {
float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
progress.setProgress(timeInMs / length);
}
}
}

View File

@@ -1,8 +1,8 @@
package com.ddf.vodsystem.services.media; package com.ddf.vodsystem.services.media;
import com.ddf.vodsystem.dto.CommandOutput;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File; import java.io.File;
@@ -13,7 +13,8 @@ import java.util.List;
public class ThumbnailService { public class ThumbnailService {
private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class); private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class);
public CommandOutput createThumbnail(File inputFile, File outputFile, Float timeInVideo) throws IOException, InterruptedException { @Async("ffmpegTaskExecutor")
public void createThumbnail(File inputFile, File outputFile, Float timeInVideo) throws IOException, InterruptedException {
logger.info("Creating thumbnail at {} seconds", timeInVideo); logger.info("Creating thumbnail at {} seconds", timeInVideo);
List<String> command = List.of( List<String> command = List.of(
@@ -24,6 +25,6 @@ public class ThumbnailService {
outputFile.getAbsolutePath() outputFile.getAbsolutePath()
); );
return CommandRunner.run(command); CommandRunner.run(command);
} }
} }

View File

@@ -1,5 +1,5 @@
-- DROP TABLE IF EXISTS clips; DROP TABLE IF EXISTS clips;
-- DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,