diff --git a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java index d4d0e8d..6a6cbfc 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java @@ -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 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 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(), diff --git a/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java b/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java deleted file mode 100644 index f91928c..0000000 --- a/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java +++ /dev/null @@ -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 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 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 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 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); - } -} diff --git a/src/main/java/com/ddf/vodsystem/controllers/EditController.java b/src/main/java/com/ddf/vodsystem/controllers/EditController.java deleted file mode 100644 index 180dd71..0000000 --- a/src/main/java/com/ddf/vodsystem/controllers/EditController.java +++ /dev/null @@ -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> 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> 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> 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> getStatus(@PathVariable("uuid") String uuid) { - JobStatus status = editService.getStatus(uuid); - return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Status for UUID: " + uuid, status)); - } -} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/JobController.java b/src/main/java/com/ddf/vodsystem/controllers/JobController.java new file mode 100644 index 0000000..cbff6fc --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/JobController.java @@ -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> 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 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); + } + +} diff --git a/src/main/java/com/ddf/vodsystem/controllers/MediaController.java b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java new file mode 100644 index 0000000..2351214 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java @@ -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> 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> 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> 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()) + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java b/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java deleted file mode 100644 index 45f99e8..0000000 --- a/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java +++ /dev/null @@ -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> 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> 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)); - } -} diff --git a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java index b75f4e1..21b9762 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java @@ -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> 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> 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>> getStreamHistory(@PathVariable Long userId) { List history = streamService.getStreamHistory(userId); diff --git a/src/main/java/com/ddf/vodsystem/controllers/UploadController.java b/src/main/java/com/ddf/vodsystem/controllers/UploadController.java deleted file mode 100644 index 01f2698..0000000 --- a/src/main/java/com/ddf/vodsystem/controllers/UploadController.java +++ /dev/null @@ -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> uploadVideo(@RequestParam("file") MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new IllegalArgumentException("Invalid file"); - } - - String uuid = uploadService.upload(file); - APIResponse response = new APIResponse<>("success", "File uploaded successfully", java.util.Collections.singletonMap("uuid", uuid)); - return new ResponseEntity<>(response, HttpStatus.OK); - } -} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index 940c31d..75febcd 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -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> user() { Optional user = userService.getLoggedInUser(); diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/JobResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/JobResponse.java new file mode 100644 index 0000000..1c347b1 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/JobResponse.java @@ -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 +) { +} diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/UUIDResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/UUIDResponse.java new file mode 100644 index 0000000..568097b --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/UUIDResponse.java @@ -0,0 +1,3 @@ +package com.ddf.vodsystem.controllers.dto; + +public record UUIDResponse(String uuid) {} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/dto/Job.java b/src/main/java/com/ddf/vodsystem/dto/Job.java index 6e560d3..00a1d77 100644 --- a/src/main/java/com/ddf/vodsystem/dto/Job.java +++ b/src/main/java/com/ddf/vodsystem/dto/Job.java @@ -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; } } diff --git a/src/main/java/com/ddf/vodsystem/dto/JobState.java b/src/main/java/com/ddf/vodsystem/dto/JobState.java new file mode 100644 index 0000000..de5d4fa --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/JobState.java @@ -0,0 +1,8 @@ +package com.ddf.vodsystem.dto; + +public enum JobState { + READY, + PROCESSING, + SUCCEEDED, + FAILED +} diff --git a/src/main/java/com/ddf/vodsystem/exceptions/NotReadyException.java b/src/main/java/com/ddf/vodsystem/exceptions/NotReadyException.java new file mode 100644 index 0000000..3d0026a --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/exceptions/NotReadyException.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.exceptions; + +public class NotReadyException extends RuntimeException { + public NotReadyException(String message) { + super(message); + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index 7b692ae..4729183 100644 --- a/src/main/java/com/ddf/vodsystem/services/ClipService.java +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -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 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 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 diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java index c60fdea..cc0bee6 100644 --- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -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); diff --git a/src/main/java/com/ddf/vodsystem/services/DownloadService.java b/src/main/java/com/ddf/vodsystem/services/DownloadService.java deleted file mode 100644 index 6373a32..0000000 --- a/src/main/java/com/ddf/vodsystem/services/DownloadService.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/com/ddf/vodsystem/services/EditService.java b/src/main/java/com/ddf/vodsystem/services/EditService.java deleted file mode 100644 index 62d7559..0000000 --- a/src/main/java/com/ddf/vodsystem/services/EditService.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java b/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java deleted file mode 100644 index 09e577b..0000000 --- a/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java +++ /dev/null @@ -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. - *

- * 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. - *

- * 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). - *

- * 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 = 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(); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java b/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java index 266ea7e..c4b7d4b 100644 --- a/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java +++ b/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java @@ -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 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; } /** diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java new file mode 100644 index 0000000..610137d --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java @@ -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 = 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. + *

+ * 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 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. + *

+ * 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 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 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; + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index 309ffa9..ca2f39a 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -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. - *

- * 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 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. - *

- * 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 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 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; - } } diff --git a/src/main/java/com/ddf/vodsystem/services/UploadService.java b/src/main/java/com/ddf/vodsystem/services/UploadService.java deleted file mode 100644 index c25bc7a..0000000 --- a/src/main/java/com/ddf/vodsystem/services/UploadService.java +++ /dev/null @@ -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()); - } - } - -} diff --git a/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java b/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java index 25b3a3e..e3478d5 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java +++ b/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java @@ -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 command, Consumer 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 } diff --git a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java index 091c1bb..900649a 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -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 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) {