chore(prod): sync with main #4
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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())
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.ddf.vodsystem.controllers.dto;
|
||||
|
||||
public record UUIDResponse(String uuid) {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
8
src/main/java/com/ddf/vodsystem/dto/JobState.java
Normal file
8
src/main/java/com/ddf/vodsystem/dto/JobState.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package com.ddf.vodsystem.dto;
|
||||
|
||||
public enum JobState {
|
||||
READY,
|
||||
PROCESSING,
|
||||
SUCCEEDED,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ddf.vodsystem.exceptions;
|
||||
|
||||
public class NotReadyException extends RuntimeException {
|
||||
public NotReadyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
227
src/main/java/com/ddf/vodsystem/services/MediaService.java
Normal file
227
src/main/java/com/ddf/vodsystem/services/MediaService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user