refactor(media)!: unify processing under asynchronous job workflow #3

Merged
dylan merged 14 commits from refactor/generalize-job-abstraction into master 2026-06-25 20:26:09 +01:00
25 changed files with 511 additions and 765 deletions
Showing only changes of commit da6bf16d07 - Show all commits

View File

@@ -5,6 +5,10 @@ import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.services.ClipService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -16,6 +20,7 @@ import java.util.Optional;
public class ClipController {
private final ClipService clipService;
private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public ClipController(ClipService clipService) {
this.clipService = clipService;
@@ -79,6 +84,34 @@ public class ClipController {
);
}
@GetMapping("/{id}/media")
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
Resource resource = clipService.downloadClip(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = clipService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
private ClipResponse convertToDTO(Clip clip) {
return new ClipResponse(
clip.getId(),

View File

@@ -1,81 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.services.DownloadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("api/v1/download")
public class DownloadController {
private final DownloadService downloadService;
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
@Autowired
public DownloadController(DownloadService downloadService) {
this.downloadService = downloadService;
}
@GetMapping("/output/{uuid}")
public ResponseEntity<Resource> downloadFile(@PathVariable String uuid) {
Resource resource = downloadService.downloadOutput(uuid);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/input/{uuid}")
public ResponseEntity<Resource> downloadInput(@PathVariable String uuid) {
Resource resource = downloadService.downloadInput(uuid);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/clip/{id}")
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
Resource resource = downloadService.downloadClip(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/thumbnail/{id}")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = downloadService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
}

View File

@@ -1,44 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.JobStatus;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.services.EditService;
import com.ddf.vodsystem.dto.APIResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("api/v1")
public class EditController {
private final EditService editService;
private static final String SUCCESS = "success";
public EditController(EditService editService) {
this.editService = editService;
}
@PostMapping("edit/{uuid}")
public ResponseEntity<APIResponse<Void>> edit(@PathVariable("uuid") String uuid, @ModelAttribute ClipOptions clipOptions) {
editService.edit(uuid, clipOptions);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Editing started for UUID: " + uuid, null));
}
@GetMapping("/process/{uuid}")
public ResponseEntity<APIResponse<Void>> process(@PathVariable("uuid") String uuid) {
editService.process(uuid);
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}")
public ResponseEntity<APIResponse<JobStatus>> getStatus(@PathVariable("uuid") String uuid) {
JobStatus status = editService.getStatus(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Status for UUID: " + uuid, status));
}
}

View File

@@ -0,0 +1,69 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.JobResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.exceptions.NotReadyException;
import com.ddf.vodsystem.services.JobRegistryService;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("api/v1/job")
public class JobController {
private final JobRegistryService jobRegistryService;
private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public JobController(JobRegistryService jobRegistryService){
this.jobRegistryService = jobRegistryService;
}
@GetMapping("/{uuid}")
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);
JobResponse jobResponse = new JobResponse(
job.getUuid(),
job.getProgressTracker().getProgress(),
job.getProgressTracker().isComplete(),
job.getState(),
job.getErrorOutput(),
job.getCreatedAt()
);
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Job successfully retreived",
jobResponse
));
}
@GetMapping("/{uuid}/download")
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);
if (job.getDownload() == null) {
throw new NotReadyException("Job " + uuid + " not ready for download.");
}
Resource resource = new FileSystemResource(job.getDownload());
if (!resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
}

View File

@@ -0,0 +1,61 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.UUIDResponse;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.MediaService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.Instant;
@RestController
@RequestMapping("/api/v1/media")
public class MediaController {
private final MediaService mediaService;
private static final String SUCCESS = "success";
public MediaController(MediaService mediaService) {
this.mediaService = mediaService;
}
@PostMapping("/compress")
public ResponseEntity<APIResponse<UUIDResponse>> compress(
@RequestParam("file")MultipartFile file,
@RequestParam("options") ClipOptions options
) throws IOException, InterruptedException {
Job job = mediaService.compress(file, options);
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Compression successfully started",
new UUIDResponse(job.getUuid())
));
}
@PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestParam("start") Instant startTime,
@RequestParam("end") Instant endTime) throws IOException {
Job job = mediaService.saveSection(startTime, endTime);
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Section saving successfully started",
new UUIDResponse(job.getUuid())
));
}
@PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestParam("duration") float duration) throws IOException {
Job job = mediaService.clip(duration);
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Clipping successfully started",
new UUIDResponse(job.getUuid())
));
}
}

View File

@@ -1,48 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.JobRegistryService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/metadata")
public class MetadataController {
private final JobRegistryService jobRegistryService;
public MetadataController(
JobRegistryService jobRegistryService) {
this.jobRegistryService = jobRegistryService;
}
@GetMapping("/original/{uuid}")
public ResponseEntity<APIResponse<ClipOptions>> getMetadata(@PathVariable String uuid) {
ClipOptions originalMetadata = jobRegistryService.getJob(uuid).getInputClipOptions();
if (originalMetadata == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new APIResponse<>("error", "Original metadata not found", null));
}
return ResponseEntity.ok()
.body(new APIResponse<>("success", "Original metadata retrieved", originalMetadata));
}
@GetMapping("/converted/{uuid}")
public ResponseEntity<APIResponse<ClipOptions>> getConvertedMetadata(@PathVariable String uuid) {
ClipOptions convertedMetadata = jobRegistryService.getJob(uuid).getOutputClipOptions();
if (convertedMetadata == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new APIResponse<>("error", "Converted metadata not found", null));
}
return ResponseEntity.ok()
.body(new APIResponse<>("success", "Converted metadata retrieved", convertedMetadata));
}
}

View File

@@ -1,9 +1,7 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger;
@@ -11,7 +9,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@@ -49,20 +46,6 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>("success", "Heartbeat recorded", null));
}
@PostMapping("/save")
public ResponseEntity<APIResponse<Void>> saveSection(
@RequestBody SaveSectionRequest request) throws IOException, InterruptedException {
streamService.saveSection(request.startTime(), request.endTime());
return ResponseEntity.accepted().body(new APIResponse<>("success", "Section saved", null));
}
@PostMapping("/clip")
public ResponseEntity<APIResponse<Void>> saveClip(
@RequestBody ClipSectionRequest request) throws IOException, InterruptedException {
streamService.clip(request.duration());
return ResponseEntity.accepted().body(new APIResponse<>("success", "Clip saved", null));
}
@GetMapping("/history/{userId}")
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
List<Stream> history = streamService.getStreamHistory(userId);

View File

@@ -1,32 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.UploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("api/v1/upload")
public class UploadController {
private final UploadService uploadService;
@Autowired
public UploadController(UploadService uploadService) {
this.uploadService = uploadService;
}
@PostMapping()
public ResponseEntity<APIResponse<Object>> uploadVideo(@RequestParam("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("Invalid file");
}
String uuid = uploadService.upload(file);
APIResponse<Object> response = new APIResponse<>("success", "File uploaded successfully", java.util.Collections.singletonMap("uuid", uuid));
return new ResponseEntity<>(response, HttpStatus.OK);
}
}

View File

@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/auth/")
@RequestMapping("/api/v1/user/")
public class UserController {
private final UserService userService;
@@ -25,7 +25,7 @@ public class UserController {
this.userService = userService;
}
@GetMapping("/user")
@GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser();

View File

@@ -0,0 +1,15 @@
package com.ddf.vodsystem.controllers.dto;
import com.ddf.vodsystem.dto.JobState;
import java.time.Instant;
public record JobResponse (
String uuid,
float progress,
boolean isComplete,
JobState state,
String errorOutput,
Instant createdAt
) {
}

View File

@@ -0,0 +1,3 @@
package com.ddf.vodsystem.controllers.dto;
public record UUIDResponse(String uuid) {}

View File

@@ -1,28 +1,21 @@
package com.ddf.vodsystem.dto;
import java.io.File;
import lombok.Data;
import java.io.File;
import java.time.Instant;
@Data
public class Job {
private String uuid;
private File inputFile;
private File outputFile;
private ProgressTracker progressTracker = new ProgressTracker();
private JobState state = JobState.READY;
private String errorOutput;
// configs
private ClipOptions inputClipOptions;
private ClipOptions outputClipOptions = new ClipOptions();
private File download;
private Instant createdAt = Instant.now();
// job status
private JobStatus status = new JobStatus();
public Job(String uuid,
File inputFile,
File outputFile,
ClipOptions inputClipOptions) {
public Job(String uuid) {
this.uuid = uuid;
this.inputFile = inputFile;
this.outputFile = outputFile;
this.inputClipOptions = inputClipOptions;
}
}

View File

@@ -0,0 +1,8 @@
package com.ddf.vodsystem.dto;
public enum JobState {
READY,
PROCESSING,
SUCCEEDED,
FAILED
}

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.exceptions;
public class NotReadyException extends RuntimeException {
public NotReadyException(String message) {
super(message);
}
}

View File

@@ -12,15 +12,14 @@ import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import com.ddf.vodsystem.exceptions.ClipNotFound;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.exceptions.StorageException;
import com.ddf.vodsystem.exceptions.*;
import com.ddf.vodsystem.repositories.ClipRepository;
import com.ddf.vodsystem.services.media.MetadataService;
import com.ddf.vodsystem.services.media.ThumbnailService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@Service
@@ -146,6 +145,51 @@ public class ClipService {
return user.get().getId().equals(clip.getUser().getId());
}
public Resource downloadClip(Long id) {
Optional<Clip> possibleClip = getClipById(id);
if (possibleClip.isEmpty()) {
throw new ClipNotFound("Clip " + id + " doesn't exist");
}
Clip clip = possibleClip.get();
if (!isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip");
}
String path = clip.getVideoPath();
File file = new File(path);
if (!file.exists()) {
throw new JobNotFound("Clip file not found");
}
return new FileSystemResource(file);
}
public Resource downloadThumbnail(Long id) {
Optional<Clip> possibleClip = getClipById(id);
if (possibleClip.isEmpty()) {
throw new ClipNotFound("Clip " + id + " doesn't exist");
}
Clip clip = possibleClip.get();
if (!isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip thumbnail");
}
String path = clip.getThumbnailPath();
File file = new File(path);
if (!file.exists()) {
throw new JobNotFound("Thumbnail file not found");
}
return new FileSystemResource(file);
}
/**
* Persists a clip to the database
* @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description

View File

@@ -119,17 +119,6 @@ public class DirectoryService {
logger.info("Deleted source file: {}", source.getAbsolutePath());
}
public String getFileExtension(String filePath) {
Path path = Paths.get(filePath);
String fileName = path.getFileName().toString();
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex == -1) {
return ""; // No extension
}
return fileName.substring(dotIndex + 1);
}
private void createDirectory(String dir) throws IOException {
// Create the directory if it doesn't exist
Path outputPath = Paths.get(dir);

View File

@@ -1,86 +0,0 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.ClipRepository;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.io.File;
@Service
public class DownloadService {
private final JobRegistryService jobRegistryService;
private final ClipRepository clipRepository;
private final ClipService clipService;
public DownloadService(JobRegistryService jobRegistryService,
ClipRepository clipRepository,
ClipService clipService) {
this.jobRegistryService = jobRegistryService;
this.clipRepository = clipRepository;
this.clipService = clipService;
}
public Resource downloadInput(String uuid) {
Job job = jobRegistryService.getJob(uuid);
File file = job.getInputFile();
return new FileSystemResource(file);
}
public Resource downloadOutput(String uuid) {
Job job = jobRegistryService.getJob(uuid);
if (!job.getStatus().getProcess().isComplete()) {
throw new JobNotFinished("Job is not finished");
}
File file = job.getOutputFile();
return new FileSystemResource(file);
}
public Resource downloadClip(Clip clip) {
if (!clipService.isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip");
}
String path = clip.getVideoPath();
File file = new File(path);
if (!file.exists()) {
throw new JobNotFound("Clip file not found");
}
return new FileSystemResource(file);
}
public Resource downloadClip(Long id) {
Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id));
return downloadClip(clip);
}
public Resource downloadThumbnail(Clip clip) {
if (!clipService.isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip thumbnail");
}
String path = clip.getThumbnailPath();
File file = new File(path);
if (!file.exists()) {
throw new JobNotFound("Thumbnail file not found");
}
return new FileSystemResource(file);
}
public Resource downloadThumbnail(Long id) {
Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id));
return downloadThumbnail(clip);
}
}

View File

@@ -1,51 +0,0 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.JobStatus;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.services.media.MetadataService;
import org.springframework.stereotype.Service;
@Service
public class EditService {
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(EditService.class);
private final MetadataService metadataService;
private final JobRegistryService jobRegistryService;
private final JobOrchestrationService jobOrchestrationService;
public EditService(JobOrchestrationService jobOrchestrationService,
MetadataService metadataService,
JobRegistryService jobRegistryService) {
this.metadataService = metadataService;
this.jobRegistryService = jobRegistryService;
this.jobOrchestrationService = jobOrchestrationService;
}
public void edit(String uuid, ClipOptions clipOptions) {
Job job = jobRegistryService.getJob(uuid);
metadataService.validateMetadata(job.getInputClipOptions(), clipOptions);
job.setOutputClipOptions(clipOptions);
logger.info("Job {} - Updated clip config to {}", job.getUuid(), clipOptions);
}
public void process(String uuid) {
Job job = jobRegistryService.getJob(uuid);
jobOrchestrationService.processJob(job);
logger.info("Job {} - Started processing", uuid);
}
public void convert(String uuid) {
Job job = jobRegistryService.getJob(uuid);
jobOrchestrationService.convertJob(job);
logger.info("Job {} - Started converting", uuid);
}
public JobStatus getStatus(String uuid) {
Job job = jobRegistryService.getJob(uuid);
return job.getStatus();
}
}

View File

@@ -1,156 +0,0 @@
package com.ddf.vodsystem.services;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Optional;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.StorageException;
import com.ddf.vodsystem.services.media.CompressionService;
import com.ddf.vodsystem.services.media.RemuxService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* Orchestrates the processing pipeline for a {@link Job}: converting (remuxing)
* the input file and, separately, compressing/persisting the output clip.
* <p>
* Job storage and lookup are handled by {@link JobRegistryService}; this class
* is only responsible for acting on a {@link Job} instance once it's been retrieved.
* Long-running steps (remux, compress) are kicked off asynchronously via
* {@link java.util.concurrent.CompletableFuture}-returning service calls rather
* than blocked on, so callers should not assume the job is finished when these
* methods return.
*/
@Service
public class JobOrchestrationService {
private static final Logger logger = LoggerFactory.getLogger(JobOrchestrationService.class);
private final ClipService clipService;
private final RemuxService remuxService;
private final DirectoryService directoryService;
private final CompressionService compressionService;
private final UserService userService;
/**
* Constructs the orchestration service with its collaborating services.
*
* @param clipService used to persist a completed clip's metadata once processing succeeds
* @param remuxService performs the remux step during conversion
* @param compressionService performs the compression step during processing
* @param directoryService used for filesystem operations (e.g. copying the input file to a temp location)
* @param userService used to resolve the currently logged-in user when persisting a clip
*/
public JobOrchestrationService(ClipService clipService,
RemuxService remuxService,
CompressionService compressionService,
DirectoryService directoryService,
UserService userService) {
this.clipService = clipService;
this.remuxService = remuxService;
this.directoryService = directoryService;
this.compressionService = compressionService;
this.userService = userService;
}
/**
* Converts a job's input file in place by remuxing it.
* <p>
* The original input file is first copied to a temporary {@code .temp} file so the
* remux can read from a stable source; on successful completion the temp file is
* deleted. The remux itself runs asynchronously — this method returns once the
* remux has been kicked off, not once it has finished. Progress/completion is
* tracked via {@code job.getStatus().getConversion()}, which is reset at the
* start of this call and marked complete in the remux's completion callback.
*
* @param job the job whose input file should be converted
* @throws StorageException if the temporary file cannot be created, or if it
* cannot be deleted after a successful remux
*/
public void convertJob(Job job) {
logger.info("Converting job: {}", job.getUuid());
File tempFile = new File(job.getInputFile().getAbsolutePath() + ".temp");
try {
directoryService.copyFile(job.getInputFile(), tempFile);
} catch (IOException e) {
throw new StorageException("Could not create temporary file: " + tempFile, e);
}
job.getStatus().getConversion().reset();
try {
remuxService.remux(
tempFile,
job.getInputFile(),
job.getStatus().getConversion(),
job.getInputClipOptions().getDuration())
.thenRun(() -> {
job.getStatus().getConversion().markComplete();
try {
Files.deleteIfExists(Paths.get(tempFile.getAbsolutePath()));
} catch (IOException e) {
throw new StorageException("Could not delete path at: " + tempFile.getAbsolutePath(), e);
}
}).whenComplete((ignored, throwable) -> {
if (throwable != null) {
logger.error("Remux failed for jobId={}", job.getUuid(), throwable);
} else {
logger.info("Remux completed for jobId={}", job.getUuid());
}
});
} catch (IOException | InterruptedException e) {
logger.error("Error converting job {}: {}", job.getUuid(), e.getMessage());
Thread.currentThread().interrupt();
}
}
/**
* Compresses a job's output file according to its output clip options and, on
* success, persists the resulting clip against the currently logged-in user (if any).
* <p>
* Compression runs asynchronously — this method returns once compression has been
* kicked off, not once it has finished. Progress is tracked via
* {@code job.getStatus().getProcess()}, which is reset at the start of this call.
* If compression or persistence fails, the job's status is marked failed with the
* triggering exception's message.
*
* @param job the job to process
*/
public void processJob(Job job) {
logger.info("Job ready: {}", job.getUuid());
job.getStatus().getProcess().reset();
try {
Optional<User> user = userService.getLoggedInUser();
compressionService.compress(job.getInputFile(), job.getOutputFile(), job.getOutputClipOptions(), job.getStatus().getProcess())
.thenRun(() -> user.ifPresent(userVal ->
clipService.persistClip(
job.getOutputClipOptions().getTitle(),
job.getOutputClipOptions().getDescription(),
userVal,
job.getOutputFile(),
job.getInputFile().getName()
)
)).exceptionally(
ex -> {
job.getStatus().setFailed(true);
job.getStatus().setFailedReason(ex.getMessage());
logger.error("Error processing job {}: {}", job.getUuid(), ex.getMessage());
return null;
}
);
} catch (IOException | InterruptedException e) {
job.getStatus().setFailed(true);
job.getStatus().setFailedReason(e.getMessage());
logger.error("Error processing job {}: {}", job.getUuid(), e.getMessage());
Thread.currentThread().interrupt();
}
}
}

View File

@@ -6,12 +6,12 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory store for {@link Job} instances, keyed by job UUID.
* Provides add/lookup only — actual job processing is handled by
* {@link JobOrchestrationService}.
* Provides add/lookup only
*/
@Service
public class JobRegistryService {
@@ -19,13 +19,16 @@ public class JobRegistryService {
private final ConcurrentHashMap<String, Job> jobs = new ConcurrentHashMap<>();
/**
* Adds a new job to the job map.
*
* @param job the job to add
* Creates and returns a new job
* @return Job
*/
public void add(Job job) {
logger.info("Added job: {}", job.getUuid());
jobs.put(job.getUuid(), job);
public Job generateJob() {
String uuid = UUID.randomUUID().toString();
Job job = new Job(uuid);
jobs.put(uuid, job);
logger.info("New job with UUID {} created", uuid);
return job;
}
/**

View File

@@ -0,0 +1,227 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.dto.JobState;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.media.CompressionService;
import com.ddf.vodsystem.services.media.StreamActionsService;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class MediaService {
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(MediaService.class);
private final JobRegistryService jobRegistryService;
private final DirectoryService directoryService;
private final CompressionService compressionService;
private final UserService userService;
private final ClipService clipService;
private static final float CLIP_MAX_LENGTH = 180;
private static final int HLS_SEGMENT_LENGTH = 3;
private final StreamActionsService streamActionsService;
public MediaService(JobRegistryService jobRegistryService,
DirectoryService directoryService,
CompressionService compressionService,
UserService userService,
ClipService clipService, StreamActionsService streamActionsService) {
this.jobRegistryService = jobRegistryService;
this.directoryService = directoryService;
this.compressionService = compressionService;
this.userService = userService;
this.clipService = clipService;
this.streamActionsService = streamActionsService;
}
public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException {
Job job = jobRegistryService.generateJob();
Optional<User> user = userService.getLoggedInUser();
String filename = job.getUuid() + ".mp4";
File tempInputFile = directoryService.getTempInputFile(filename);
File tempOutputFile = directoryService.getTempOutputFile(filename);
directoryService.saveMultipartFile(tempInputFile, file);
job.setState(JobState.PROCESSING);
compressionService.compress(
tempInputFile,
tempOutputFile,
clipOptions,
job.getProgressTracker()
).thenRun(() -> {
job.setDownload(tempOutputFile);
job.setState(JobState.SUCCEEDED);
logger.info("Compression job {} succeeded", job.getUuid());
// save clip if user context
user.ifPresent(userVal ->
clipService.persistClip(
clipOptions.getTitle(),
clipOptions.getDescription(),
userVal,
tempOutputFile,
filename
)
);
}).exceptionally(ex -> {
job.setState(JobState.FAILED);
job.setErrorOutput(ex.getMessage());
logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
return null;
});
return job;
}
/**
* Saves a clip of the currently authenticated user's stream between two points in time.
* <p>
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
* trim offset into the first (partially-overlapping) segment and the total clip duration,
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
*
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
* @param endTime the exclusive end of the section to save
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
* or if no stream segments exist in the given range
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public Job saveSection(Instant startTime, Instant endTime) throws IOException {
if (startTime.isAfter(endTime)) {
throw new IllegalArgumentException("Start time must be before end time");
}
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
Job job = jobRegistryService.generateJob();
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
if (fileSegments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
job.setState(JobState.PROCESSING);
File outputFile = directoryService.getVodFile(user.getId(), UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(
fileSegments,
trimOffset,
duration,
outputFile,
job.getProgressTracker()
).thenRun(() -> {
job.setDownload(outputFile);
job.setState(JobState.SUCCEEDED);
logger.info("Stream save of job {} succeeded", job.getUuid());
}).exceptionally(ex -> {
job.setState(JobState.FAILED);
job.setErrorOutput(ex.getMessage());
logger.error("Stream section save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
return null;
});
return job;
}
/**
* Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
* <p>
* Computes a time window ending at the current instant and beginning {@code duration} seconds
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
*
* @param duration the length of the clip in seconds, measured back from now; must be greater
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
* are supported and resolved to the millisecond.
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
* or if no stream segments exist in the computed window
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public Job clip(float duration) throws IOException {
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
}
// minusSeconds() is possible, but only does integer seconds, not float
Instant endTime = Instant.now();
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
Job job = jobRegistryService.generateJob();
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
if (fileSegments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
File outputFile = directoryService.getTempOutputFile(UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile, job.getProgressTracker())
.thenRun(() -> {
job.setDownload(outputFile);
job.setState(JobState.SUCCEEDED);
logger.info("Stream clip of job {} succeeded", job.getUuid());
clipService.persistClip(
Instant.now().toString(),
"",
user,
outputFile,
outputFile.getName()
);
}
).exceptionally(ex -> {
job.setState(JobState.FAILED);
job.setErrorOutput(ex.getMessage());
logger.error("Clip save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
return null;
});
return job;
}
private List<File> getSegmentsInRange(String streamDirectory, Instant startTime, Instant endTime) {
File dir = new File(streamDirectory);
File[] tsFiles = dir.listFiles((d, name) -> name.endsWith(".ts"));
if (tsFiles == null) return List.of();
long startMs = startTime.toEpochMilli();
long endMs = endTime.toEpochMilli();
return Arrays.stream(tsFiles)
.filter(f -> {
long segMs = parseTimestampMs(f);
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
})
.sorted(Comparator.comparingLong(this::parseTimestampMs))
.collect(Collectors.toList());
}
private long parseTimestampMs(File file) {
String name = file.getName().replace(".ts", "");
long value = Long.parseLong(name);
// nginx hls_fragment_naming system uses seconds; convert to ms
return value < 1_000_000_000_000L ? value * 1000L : value;
}
}

View File

@@ -6,45 +6,28 @@ import com.ddf.vodsystem.exceptions.AlreadyStreaming;
import com.ddf.vodsystem.exceptions.KeyNotFound;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.StreamRepository;
import com.ddf.vodsystem.services.media.StreamActionsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class StreamService {
private static final Logger logger = LoggerFactory.getLogger(StreamService.class);
private static final int HEARTBEAT_TIMEOUT_SECONDS = 60;
private static final float CLIP_MAX_LENGTH = 180;
private static final int HLS_SEGMENT_LENGTH = 3;
private final StreamRepository streamRepository;
private final UserService userService;
private final StreamActionsService streamActionsService;
private final DirectoryService directoryService;
private final ClipService clipService;
public StreamService(StreamRepository streamRepository,
UserService userService,
StreamActionsService streamActionsService,
DirectoryService directoryService,
ClipService clipService) {
UserService userService) {
this.streamRepository = streamRepository;
this.userService = userService;
this.streamActionsService = streamActionsService;
this.directoryService = directoryService;
this.clipService = clipService;
}
public Stream startStream(String streamKey) {
@@ -111,92 +94,6 @@ public class StreamService {
return streamRepository.findByUser(streamUser);
}
/**
* Saves a clip of the currently authenticated user's stream between two points in time.
* <p>
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
* trim offset into the first (partially-overlapping) segment and the total clip duration,
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
*
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
* @param endTime the exclusive end of the section to save
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
* or if no stream segments exist in the given range
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public void saveSection(Instant startTime, Instant endTime) throws IOException {
if (startTime.isAfter(endTime)) {
throw new IllegalArgumentException("Start time must be before end time");
}
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
if (fileSegments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
File outputFile = directoryService.getVodFile(user.getId(), UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile);
}
/**
* Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
* <p>
* Computes a time window ending at the current instant and beginning {@code duration} seconds
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
*
* @param duration the length of the clip in seconds, measured back from now; must be greater
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
* are supported and resolved to the millisecond.
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
* or if no stream segments exist in the computed window
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public void clip(float duration) throws IOException {
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
}
// minusSeconds() is possible, but only does integer seconds, not float
Instant endTime = Instant.now();
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
if (fileSegments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
File outputFile = directoryService.getTempOutputFile(UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile)
.thenRun(() ->
clipService.persistClip(
Instant.now().toString(),
"",
user,
outputFile,
outputFile.getName()
)
);
}
@Scheduled(fixedDelay = 30_000)
@Transactional
public void endStaleStreams() {
@@ -216,28 +113,4 @@ public class StreamService {
return userService.getUserByStreamKey(streamKey)
.orElseThrow(() -> new KeyNotFound("Stream key not found: " + streamKey));
}
private List<File> getSegmentsInRange(String streamDirectory, Instant startTime, Instant endTime) {
File dir = new File(streamDirectory);
File[] tsFiles = dir.listFiles((d, name) -> name.endsWith(".ts"));
if (tsFiles == null) return List.of();
long startMs = startTime.toEpochMilli();
long endMs = endTime.toEpochMilli();
return Arrays.stream(tsFiles)
.filter(f -> {
long segMs = parseTimestampMs(f);
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
})
.sorted(Comparator.comparingLong(this::parseTimestampMs))
.collect(Collectors.toList());
}
private long parseTimestampMs(File file) {
String name = file.getName().replace(".ts", "");
long value = Long.parseLong(name);
// nginx hls_fragment_naming system uses seconds; convert to ms
return value < 1_000_000_000_000L ? value * 1000L : value;
}
}

View File

@@ -1,79 +0,0 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.StorageException;
import com.ddf.vodsystem.services.media.MetadataService;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Base64;
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.LoggerFactory;
@Service
public class UploadService {
private static final Logger logger = LoggerFactory.getLogger(UploadService.class);
private final JobRegistryService jobRegistryService;
private final MetadataService metadataService;
private final DirectoryService directoryService;
public UploadService(JobRegistryService jobRegistryService,
MetadataService metadataService,
DirectoryService directoryService) {
this.jobRegistryService = jobRegistryService;
this.metadataService = metadataService;
this.directoryService = directoryService;
}
public String upload(MultipartFile file) {
// generate uuid, filename
String uuid = generateShortUUID();
String extension = directoryService.getFileExtension(file.getOriginalFilename());
File inputFile = directoryService.getTempInputFile(uuid + "." + extension);
File outputFile = directoryService.getTempOutputFile(uuid + ".mp4");
try {
directoryService.saveMultipartFile(inputFile, file);
} catch (IOException e) {
throw new StorageException("Could not save Multipart file at: " + inputFile, e);
}
logger.info("Multi-part file uploaded at {}", inputFile);
ClipOptions clipOptions = getMetadataWithTimeout(inputFile);
Job job = new Job(uuid, inputFile, outputFile, clipOptions);
jobRegistryService.add(job);
return uuid;
}
private static String generateShortUUID() {
UUID uuid = UUID.randomUUID();
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
bb.putLong(uuid.getMostSignificantBits());
bb.putLong(uuid.getLeastSignificantBits());
return Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array());
}
private ClipOptions getMetadataWithTimeout(File file) {
try {
return metadataService.getVideoMetadata(file).get(5, TimeUnit.SECONDS);
} catch (ExecutionException | TimeoutException | InterruptedException e) {
Thread.currentThread().interrupt();
throw new FFMPEGException(e.getMessage());
}
}
}

View File

@@ -1,14 +1,19 @@
package com.ddf.vodsystem.services.media;
import com.ddf.vodsystem.dto.CommandOutput;
import com.ddf.vodsystem.dto.ProgressTracker;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.List;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class CommandRunner {
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
public static CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
@@ -40,6 +45,14 @@ public class CommandRunner {
return run(command, null);
}
public static 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);
}
}
private CommandRunner() {
// Private constructor to prevent instantiation
}

View File

@@ -1,5 +1,6 @@
package com.ddf.vodsystem.services.media;
import com.ddf.vodsystem.dto.ProgressTracker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
@@ -50,7 +51,8 @@ public class StreamActionsService {
List<File> segments,
float trimOffset,
float duration,
File outputFile
File outputFile,
ProgressTracker progressTracker
) {
try {
String concatInput = segments.stream()
@@ -69,7 +71,7 @@ public class StreamActionsService {
logger.info("Saving section with ({} segments) to '{}'",
segments.size(), outputFile.getAbsolutePath());
CommandRunner.run(command, line -> logger.debug("{}", line));
CommandRunner.run(command, line -> CommandRunner.setProgress(line, progressTracker, duration));
return CompletableFuture.completedFuture(outputFile);
} catch (Exception e) {