From 9185a41147a69e338669f83084cdcb8a6611d0f7 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 20 Jun 2026 22:04:42 +0100 Subject: [PATCH 01/13] feat(frontend): remove server-side upload process and switch to browser process --- frontend/src/pages/ClipEdit.tsx | 104 +++++++++++++++++------------- frontend/src/pages/ClipUpload.tsx | 58 +---------------- 2 files changed, 63 insertions(+), 99 deletions(-) diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index 64d6c4d..21bd7c8 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -1,11 +1,11 @@ -import { useParams } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { useEffect, useRef, useState } from "react"; import Playbar from "./../components/video/Playbar"; import PlaybackSlider from "./../components/video/PlaybackSlider"; import ClipRangeSlider from "./../components/video/ClipRangeSlider"; import ConfigBox from "../components/video/ConfigBox.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx"; -import {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints" +import { editFile, processFile, getProgress, uploadFile } from "../utils/endpoints" import type { VideoMetadata } from "../utils/types.ts"; import Box from "../components/Box.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx"; @@ -13,13 +13,16 @@ import MetadataBox from "../components/video/MetadataBox.tsx"; const API_URL = import.meta.env.VITE_API_URL; const ClipEdit = () => { - const { id } = useParams(); + const location = useLocation(); + const localFile: File = location.state?.file; + const videoRef = useRef(null); - const videoUrl = API_URL + `/api/v1/download/input/${id}` + const [videoUrl, setVideoUrl] = useState(''); + const [uploadedId, setUploadedId] = useState(null); + const [metadata, setMetadata] = useState(null); const [playbackValue, setPlaybackValue] = useState(0); const [outputMetadata, setOutputMetadata] = useState({ - // default values title: "", description: "", startPoint: 0, @@ -33,20 +36,47 @@ const ClipEdit = () => { const [downloadable, setDownloadable] = useState(false); const [error, setError] = useState(null); - const sendData = async() => { - if (!id) return; + useEffect(() => { + if (!localFile) return; + const url = URL.createObjectURL(localFile); + setVideoUrl(url); + return () => URL.revokeObjectURL(url); + }, [localFile]); - editFile(id, outputMetadata) - .then(() => { + const handleVideoMetadataLoaded = () => { + const video = videoRef.current; + if (!video) return; + setMetadata({ + title: '', + description: '', + startPoint: 0, + duration: video.duration, + width: video.videoWidth, + height: video.videoHeight, + fps: 30, + fileSize: localFile?.size ?? 0, + }); + }; - processFile(id) - .catch((err: Error) => setError(`Failed to process file: ${err.message}`)); + const sendData = async () => { + if (!localFile) return; - }) + let fileId: string; + try { + fileId = await uploadFile(localFile); + setUploadedId(fileId); + } catch (err: unknown) { + setError(`Failed to upload file: ${err instanceof Error ? err.message : 'Unknown error'}`); + return; + } + + editFile(fileId, outputMetadata) + .then(() => processFile(fileId) + .catch((err: Error) => setError(`Failed to process file: ${err.message}`))) .catch((err: Error) => setError(`Failed to edit file: ${err.message}`)); - const interval = setInterval(async() => await pollProgress(id, interval), 500); - } + const interval = setInterval(async () => await pollProgress(fileId, interval), 500); + }; const pollProgress = async (id: string, intervalId: number) => { getProgress(id) @@ -57,17 +87,19 @@ const ClipEdit = () => { clearInterval(intervalId); setDownloadable(true); } else { - setDownloadable(false) + setDownloadable(false); } - }) + }) .catch((err: Error) => { setError(`Failed to fetch progress: ${err.message}`); clearInterval(intervalId); }); - } + }; const handleDownload = async () => { - const response = await fetch(API_URL + `/api/v1/download/output/${id}`); + if (!uploadedId) return; + + const response = await fetch(API_URL + `/api/v1/download/output/${uploadedId}`); if (!response.ok) { console.error('Download failed'); @@ -79,43 +111,27 @@ const ClipEdit = () => { const a = document.createElement('a'); a.href = url; - a.download = `${id}.mp4`; + a.download = `${uploadedId}.mp4`; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); }; - useEffect(() => { - if (!id) return; - - getMetadata(id) - .then((data) => setMetadata(data ?? null)) - .catch((err) => console.error("Metadata fetch failed:", err)); - }, [id]); - return (
- + ref={videoRef} + src={videoUrl} + className={"w-full rounded-lg shadow-sm bg-black"} + onLoadedMetadata={handleVideoMetadataLoaded} + /> - - + + - {metadata && {
); -} +}; -export default ClipEdit; \ No newline at end of file +export default ClipEdit; diff --git a/frontend/src/pages/ClipUpload.tsx b/frontend/src/pages/ClipUpload.tsx index c373e89..cf4cbb4 100644 --- a/frontend/src/pages/ClipUpload.tsx +++ b/frontend/src/pages/ClipUpload.tsx @@ -1,6 +1,5 @@ import {useState} from "react"; import {useNavigate} from "react-router-dom"; -import {uploadFile, convertFile, getProgress} from "../utils/endpoints" import BlueButton from "../components/buttons/BlueButton.tsx"; import Box from "../components/Box.tsx"; @@ -8,63 +7,14 @@ const ClipUpload = () => { const [file, setFile] = useState(null); const navigate = useNavigate(); const [error, setError] = useState(null); - const [progress, setProgress] = useState(0); - const isVideoFileSupported = (file: File): boolean => { - const video = document.createElement("video"); - - if (file.type && video.canPlayType(file.type) !== "") { - return true; - } - - const extension = file.name.split(".").pop()?.toLowerCase(); - const extensionToMime: Record = { - mp4: "video/mp4", - webm: "video/webm", - ogg: "video/ogg", - mov: "video/quicktime", - }; - - if (extension && extensionToMime[extension]) { - return video.canPlayType(extensionToMime[extension]) !== ""; - } - - return false; - }; - - const press = (() => { + const press = () => { if (!file) { setError("Please choose a file"); return; } - - uploadFile(file) - .then(uuid => { - if (isVideoFileSupported(file)) { - navigate(`/create/${uuid}`) - } else { - convertFile(uuid); - const interval = setInterval(async() => await pollProgress(uuid, interval), 500); - } - }) - .catch((e: Error) => setError(`Failed to upload file: ${e.message}`)); - }); - - const pollProgress = async (id: string, intervalId: number) => { - getProgress(id) - .then((progress) => { - setProgress(progress.conversion.progress); - - if (progress.conversion.complete) { - clearInterval(intervalId); - navigate(`/create/${id}`) - } - }) - .catch((err: Error) => { - setError(`Failed to fetch progress: ${err.message}`); - clearInterval(intervalId); - }); - } + navigate('/create/new', { state: { file } }); + }; return (
@@ -84,8 +34,6 @@ const ClipUpload = () => { Upload {error &&

{error}

} - -
) -- 2.49.1 From da6bf16d079e5475c7c42982ceddb437afba1802 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 22 Jun 2026 20:54:16 +0100 Subject: [PATCH 02/13] refactor(media): consolidate upload/edit/download into MediaService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge UploadService, EditService, JobOrchestrationService, and DownloadService into a single MediaService with a unified Job lifecycle (READY → PROCESSING → SUCCEEDED/FAILED via JobState enum). - Add MediaController (/api/v1/media) exposing compress, save, and clip - Add JobController (/api/v1/job) for status polling and file download - Move clip/thumbnail download into ClipController (/{id}/media, /{id}/thumbnail) - Move stream saveSection/clip from StreamService into MediaService - Simplify Job DTO: remove dual ClipOptions/files, add ProgressTracker and download file - Refactor JobRegistryService.add() → generateJob() to co-locate creation and registration - Wire FFmpeg out_time_ms progress parsing into StreamActionsService via CommandRunner - Rename GET /api/v1/auth/user → GET /api/v1/user/me - Delete DownloadController, MetadataController, UploadController --- .../vodsystem/controllers/ClipController.java | 33 +++ .../controllers/DownloadController.java | 81 ------- .../vodsystem/controllers/EditController.java | 44 ---- .../vodsystem/controllers/JobController.java | 69 ++++++ .../controllers/MediaController.java | 61 +++++ .../controllers/MetadataController.java | 48 ---- .../controllers/StreamController.java | 17 -- .../controllers/UploadController.java | 32 --- .../vodsystem/controllers/UserController.java | 4 +- .../controllers/dto/JobResponse.java | 15 ++ .../controllers/dto/UUIDResponse.java | 3 + src/main/java/com/ddf/vodsystem/dto/Job.java | 25 +- .../java/com/ddf/vodsystem/dto/JobState.java | 8 + .../exceptions/NotReadyException.java | 7 + .../ddf/vodsystem/services/ClipService.java | 52 +++- .../vodsystem/services/DirectoryService.java | 11 - .../vodsystem/services/DownloadService.java | 86 ------- .../ddf/vodsystem/services/EditService.java | 51 ---- .../services/JobOrchestrationService.java | 156 ------------ .../services/JobRegistryService.java | 19 +- .../ddf/vodsystem/services/MediaService.java | 227 ++++++++++++++++++ .../ddf/vodsystem/services/StreamService.java | 129 +--------- .../ddf/vodsystem/services/UploadService.java | 79 ------ .../services/media/CommandRunner.java | 13 + .../services/media/StreamActionsService.java | 6 +- 25 files changed, 511 insertions(+), 765 deletions(-) delete mode 100644 src/main/java/com/ddf/vodsystem/controllers/DownloadController.java delete mode 100644 src/main/java/com/ddf/vodsystem/controllers/EditController.java create mode 100644 src/main/java/com/ddf/vodsystem/controllers/JobController.java create mode 100644 src/main/java/com/ddf/vodsystem/controllers/MediaController.java delete mode 100644 src/main/java/com/ddf/vodsystem/controllers/MetadataController.java delete mode 100644 src/main/java/com/ddf/vodsystem/controllers/UploadController.java create mode 100644 src/main/java/com/ddf/vodsystem/controllers/dto/JobResponse.java create mode 100644 src/main/java/com/ddf/vodsystem/controllers/dto/UUIDResponse.java create mode 100644 src/main/java/com/ddf/vodsystem/dto/JobState.java create mode 100644 src/main/java/com/ddf/vodsystem/exceptions/NotReadyException.java delete mode 100644 src/main/java/com/ddf/vodsystem/services/DownloadService.java delete mode 100644 src/main/java/com/ddf/vodsystem/services/EditService.java delete mode 100644 src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java create mode 100644 src/main/java/com/ddf/vodsystem/services/MediaService.java delete mode 100644 src/main/java/com/ddf/vodsystem/services/UploadService.java 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) { -- 2.49.1 From dc5af8f8686d3050de54e4a90c0e572daf3a3432 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 22 Jun 2026 22:08:56 +0100 Subject: [PATCH 03/13] docs(bruno): overhaul bruno to match new API --- bruno/VoD-System/Auth/Get Google Token.bru | 30 ------------------- bruno/VoD-System/Auth/Get User.bru | 11 ------- bruno/VoD-System/Auth/Login.bru | 21 ------------- bruno/VoD-System/Clips/Get all clips.bru | 2 +- bruno/VoD-System/Clips/Update Clip.bru | 2 +- bruno/VoD-System/Clips/folder.bru | 1 + .../Download/Converted Download.bru | 11 ------- .../VoD-System/Download/Original Download.bru | 11 ------- bruno/VoD-System/Download/folder.bru | 3 -- bruno/VoD-System/Edit/Edit.bru | 17 ----------- bruno/VoD-System/Edit/Process.bru | 11 ------- bruno/VoD-System/Edit/Progress.bru | 11 ------- bruno/VoD-System/Edit/folder.bru | 3 -- .../Metadata/Converted Metadata.bru | 11 ------- .../VoD-System/Metadata/Original Metadata.bru | 11 ------- bruno/VoD-System/Metadata/folder.bru | 3 -- bruno/VoD-System/Streams/Save Clip.bru | 22 -------------- bruno/VoD-System/Streams/Save Section.bru | 23 -------------- bruno/VoD-System/Streams/folder.bru | 2 +- bruno/VoD-System/Upload.bru | 2 +- .../configuration/SecurityConfig.java | 8 ++--- .../vodsystem/controllers/JobController.java | 5 ++-- .../controllers/MediaController.java | 18 ++++++----- .../vodsystem/controllers/UserController.java | 2 +- .../com/ddf/vodsystem/dto/ClipOptions.java | 16 ++++++++++ .../com/ddf/vodsystem/security/JwtFilter.java | 10 +++++-- .../ddf/vodsystem/services/ClipService.java | 2 +- 27 files changed, 46 insertions(+), 223 deletions(-) delete mode 100644 bruno/VoD-System/Auth/Get Google Token.bru delete mode 100644 bruno/VoD-System/Auth/Get User.bru delete mode 100644 bruno/VoD-System/Auth/Login.bru delete mode 100644 bruno/VoD-System/Download/Converted Download.bru delete mode 100644 bruno/VoD-System/Download/Original Download.bru delete mode 100644 bruno/VoD-System/Download/folder.bru delete mode 100644 bruno/VoD-System/Edit/Edit.bru delete mode 100644 bruno/VoD-System/Edit/Process.bru delete mode 100644 bruno/VoD-System/Edit/Progress.bru delete mode 100644 bruno/VoD-System/Edit/folder.bru delete mode 100644 bruno/VoD-System/Metadata/Converted Metadata.bru delete mode 100644 bruno/VoD-System/Metadata/Original Metadata.bru delete mode 100644 bruno/VoD-System/Metadata/folder.bru delete mode 100644 bruno/VoD-System/Streams/Save Clip.bru delete mode 100644 bruno/VoD-System/Streams/Save Section.bru diff --git a/bruno/VoD-System/Auth/Get Google Token.bru b/bruno/VoD-System/Auth/Get Google Token.bru deleted file mode 100644 index d810c7e..0000000 --- a/bruno/VoD-System/Auth/Get Google Token.bru +++ /dev/null @@ -1,30 +0,0 @@ -meta { - name: Get Google Token - type: http - seq: 3 -} - -get { - url: - body: none - auth: oauth2 -} - -auth:oauth2 { - grant_type: authorization_code - callback_url: http://localhost:8080 - authorization_url: https://accounts.google.com/o/oauth2/v2/auth - access_token_url: https://oauth2.googleapis.com/token - refresh_token_url: - client_id: {{google_client_id}} - client_secret: {{google_secret}} - scope: profile email openid - state: - pkce: true - credentials_placement: body - credentials_id: credentials - token_placement: header - token_header_prefix: Bearer - auto_fetch_token: true - auto_refresh_token: false -} diff --git a/bruno/VoD-System/Auth/Get User.bru b/bruno/VoD-System/Auth/Get User.bru deleted file mode 100644 index 28036c9..0000000 --- a/bruno/VoD-System/Auth/Get User.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Get User - type: http - seq: 1 -} - -get { - url: {{base_url}}/auth/user - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Auth/Login.bru b/bruno/VoD-System/Auth/Login.bru deleted file mode 100644 index a317e24..0000000 --- a/bruno/VoD-System/Auth/Login.bru +++ /dev/null @@ -1,21 +0,0 @@ -meta { - name: Login - type: http - seq: 2 -} - -post { - url: {{base_url}}/auth/login - body: json - auth: none -} - -body:json { - { - "token": "{{$oauth2.credentials.id_token}}" - } -} - -script:post-response { - bru.setEnvVar("token", res.getBody().data.token); -} diff --git a/bruno/VoD-System/Clips/Get all clips.bru b/bruno/VoD-System/Clips/Get all clips.bru index d78388d..4d33b6b 100644 --- a/bruno/VoD-System/Clips/Get all clips.bru +++ b/bruno/VoD-System/Clips/Get all clips.bru @@ -1,7 +1,7 @@ meta { name: Get all clips type: http - seq: 1 + seq: 2 } get { diff --git a/bruno/VoD-System/Clips/Update Clip.bru b/bruno/VoD-System/Clips/Update Clip.bru index a7ee47c..7aa91b8 100644 --- a/bruno/VoD-System/Clips/Update Clip.bru +++ b/bruno/VoD-System/Clips/Update Clip.bru @@ -1,7 +1,7 @@ meta { name: Update Clip type: http - seq: 2 + seq: 3 } patch { diff --git a/bruno/VoD-System/Clips/folder.bru b/bruno/VoD-System/Clips/folder.bru index b1359ee..cb80f7c 100644 --- a/bruno/VoD-System/Clips/folder.bru +++ b/bruno/VoD-System/Clips/folder.bru @@ -1,5 +1,6 @@ meta { name: Clips + seq: 3 } auth { diff --git a/bruno/VoD-System/Download/Converted Download.bru b/bruno/VoD-System/Download/Converted Download.bru deleted file mode 100644 index d68bf22..0000000 --- a/bruno/VoD-System/Download/Converted Download.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Converted Download - type: http - seq: 2 -} - -get { - url: {{base_url}}/download/output/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Download/Original Download.bru b/bruno/VoD-System/Download/Original Download.bru deleted file mode 100644 index 91d8e26..0000000 --- a/bruno/VoD-System/Download/Original Download.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Original Download - type: http - seq: 1 -} - -get { - url: {{base_url}}/download/input/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Download/folder.bru b/bruno/VoD-System/Download/folder.bru deleted file mode 100644 index be111cc..0000000 --- a/bruno/VoD-System/Download/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Download -} diff --git a/bruno/VoD-System/Edit/Edit.bru b/bruno/VoD-System/Edit/Edit.bru deleted file mode 100644 index 63a5e17..0000000 --- a/bruno/VoD-System/Edit/Edit.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Edit - type: http - seq: 1 -} - -post { - url: {{base_url}}/edit/{{uuid}} - body: formUrlEncoded - auth: inherit -} - -body:form-urlencoded { - startPoint: 10 - duration: 40 - title: best possible title -} diff --git a/bruno/VoD-System/Edit/Process.bru b/bruno/VoD-System/Edit/Process.bru deleted file mode 100644 index a44770d..0000000 --- a/bruno/VoD-System/Edit/Process.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Process - type: http - seq: 2 -} - -get { - url: {{base_url}}/process/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Edit/Progress.bru b/bruno/VoD-System/Edit/Progress.bru deleted file mode 100644 index c54718f..0000000 --- a/bruno/VoD-System/Edit/Progress.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Progress - type: http - seq: 3 -} - -get { - url: {{base_url}}/progress/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Edit/folder.bru b/bruno/VoD-System/Edit/folder.bru deleted file mode 100644 index 8dd7263..0000000 --- a/bruno/VoD-System/Edit/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Edit -} diff --git a/bruno/VoD-System/Metadata/Converted Metadata.bru b/bruno/VoD-System/Metadata/Converted Metadata.bru deleted file mode 100644 index 0bda004..0000000 --- a/bruno/VoD-System/Metadata/Converted Metadata.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Converted Metadata - type: http - seq: 2 -} - -get { - url: {{base_url}}/metadata/converted/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Metadata/Original Metadata.bru b/bruno/VoD-System/Metadata/Original Metadata.bru deleted file mode 100644 index 1a56e14..0000000 --- a/bruno/VoD-System/Metadata/Original Metadata.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Original Metadata - type: http - seq: 1 -} - -get { - url: {{base_url}}/metadata/original/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Metadata/folder.bru b/bruno/VoD-System/Metadata/folder.bru deleted file mode 100644 index b670ec8..0000000 --- a/bruno/VoD-System/Metadata/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Metadata -} diff --git a/bruno/VoD-System/Streams/Save Clip.bru b/bruno/VoD-System/Streams/Save Clip.bru deleted file mode 100644 index e3d2338..0000000 --- a/bruno/VoD-System/Streams/Save Clip.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: Save Clip - type: http - seq: 3 -} - -post { - url: {{base_url}}/stream/clip - body: json - auth: inherit -} - -body:json { - { - "duration": 60 - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/bruno/VoD-System/Streams/Save Section.bru b/bruno/VoD-System/Streams/Save Section.bru deleted file mode 100644 index 4d77571..0000000 --- a/bruno/VoD-System/Streams/Save Section.bru +++ /dev/null @@ -1,23 +0,0 @@ -meta { - name: Save Section - type: http - seq: 2 -} - -post { - url: {{base_url}}/stream/save - body: json - auth: inherit -} - -body:json { - { - "startTime": "2026-06-20T15:00:00Z", - "endTime": "2026-06-20T15:01:00Z" - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/bruno/VoD-System/Streams/folder.bru b/bruno/VoD-System/Streams/folder.bru index 2aa144d..51c98c0 100644 --- a/bruno/VoD-System/Streams/folder.bru +++ b/bruno/VoD-System/Streams/folder.bru @@ -1,6 +1,6 @@ meta { name: Streams - seq: 7 + seq: 6 } auth { diff --git a/bruno/VoD-System/Upload.bru b/bruno/VoD-System/Upload.bru index 7a6c424..ca92bbd 100644 --- a/bruno/VoD-System/Upload.bru +++ b/bruno/VoD-System/Upload.bru @@ -1,7 +1,7 @@ meta { name: Upload type: http - seq: 1 + seq: 2 } post { diff --git a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java index 81bf0e6..6c79878 100644 --- a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java +++ b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java @@ -30,11 +30,9 @@ public class SecurityConfig { .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll() - .requestMatchers("/api/v1/download/clip/**","/api/v1/clips/", "/api/v1/clips/**").authenticated() - .requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll() - .requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll() - .requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**", "/api/v1/convert/**").permitAll() - .requestMatchers("/api/v1/metadata/**").permitAll() + .requestMatchers("/api/v1/media/**").authenticated() + .requestMatchers("/api/v1/jobs/**").authenticated() + .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/ddf/vodsystem/controllers/JobController.java b/src/main/java/com/ddf/vodsystem/controllers/JobController.java index cbff6fc..8bf6352 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/JobController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/JobController.java @@ -3,6 +3,7 @@ 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.ClipNotFound; import com.ddf.vodsystem.exceptions.NotReadyException; import com.ddf.vodsystem.services.JobRegistryService; import org.springframework.core.io.FileSystemResource; @@ -17,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("api/v1/job") +@RequestMapping("api/v1/jobs") public class JobController { private final JobRegistryService jobRegistryService; private static final String SUCCESS = "success"; @@ -57,7 +58,7 @@ public class JobController { Resource resource = new FileSystemResource(job.getDownload()); if (!resource.exists()) { - return ResponseEntity.notFound().build(); + throw new ClipNotFound("Job download file " + job.getDownload() + " does not exist."); } return ResponseEntity.ok() diff --git a/src/main/java/com/ddf/vodsystem/controllers/MediaController.java b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java index 2351214..7643e7b 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/MediaController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java @@ -1,16 +1,19 @@ package com.ddf.vodsystem.controllers; +import com.ddf.vodsystem.controllers.dto.ClipSectionRequest; +import com.ddf.vodsystem.controllers.dto.SaveSectionRequest; 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 jakarta.validation.Valid; +import org.springframework.http.MediaType; 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") @@ -22,10 +25,10 @@ public class MediaController { this.mediaService = mediaService; } - @PostMapping("/compress") + @PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> compress( @RequestParam("file")MultipartFile file, - @RequestParam("options") ClipOptions options + @Valid @ModelAttribute ClipOptions options ) throws IOException, InterruptedException { Job job = mediaService.compress(file, options); return ResponseEntity.ok(new APIResponse<>( @@ -38,9 +41,8 @@ public class MediaController { @PostMapping("/save") public ResponseEntity> save( - @RequestParam("start") Instant startTime, - @RequestParam("end") Instant endTime) throws IOException { - Job job = mediaService.saveSection(startTime, endTime); + @RequestBody SaveSectionRequest saveSectionRequest) throws IOException { + Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime()); return ResponseEntity.ok(new APIResponse<>( SUCCESS, "Section saving successfully started", @@ -50,8 +52,8 @@ public class MediaController { @PostMapping("/clip") public ResponseEntity> clip( - @RequestParam("duration") float duration) throws IOException { - Job job = mediaService.clip(duration); + @RequestBody ClipSectionRequest clipSectionRequest) throws IOException { + Job job = mediaService.clip(clipSectionRequest.duration()); return ResponseEntity.ok(new APIResponse<>( SUCCESS, "Clipping successfully started", diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index 75febcd..dead6ab 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/user/") +@RequestMapping("/api/v1/users") public class UserController { private final UserService userService; diff --git a/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java b/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java index 735a3ea..7041658 100644 --- a/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java +++ b/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java @@ -1,15 +1,31 @@ package com.ddf.vodsystem.dto; +import jakarta.validation.constraints.*; import lombok.Data; @Data public class ClipOptions { + @NotBlank private String title; + private String description; + + @NotNull + @Positive private Float startPoint; + + @NotNull @Positive private Float duration; + + @DecimalMin("1.0") @DecimalMax("120.0") private Float fps; + + @Positive private Integer width; + + @Positive private Integer height; + + @Positive private Float fileSize; } diff --git a/src/main/java/com/ddf/vodsystem/security/JwtFilter.java b/src/main/java/com/ddf/vodsystem/security/JwtFilter.java index 1af18e2..e72ad72 100644 --- a/src/main/java/com/ddf/vodsystem/security/JwtFilter.java +++ b/src/main/java/com/ddf/vodsystem/security/JwtFilter.java @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -60,9 +61,12 @@ public class JwtFilter extends OncePerRequestFilter { } Optional authentication = getAuthentication(jwt); - authentication.ifPresent(value -> - SecurityContextHolder.getContext().setAuthentication(value) - ); + authentication.ifPresent(auth -> { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + }); + filterChain.doFilter(request, response); } diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index 4729183..88fa8ee 100644 --- a/src/main/java/com/ddf/vodsystem/services/ClipService.java +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -229,7 +229,7 @@ public class ClipService { try { newClipFile = directoryService.getUserClipsFile(user.getId(), fileName); thumbnailFile = directoryService.getUserThumbnailsFile(user.getId(), fileName + ".png"); - directoryService.cutFile(clipFile, newClipFile); + directoryService.copyFile(clipFile, newClipFile); } catch (IOException e) { throw new StorageException("Failed to move clip from temporary directory to output directory", e); } -- 2.49.1 From e9abdfb44a36f77967b5d54d9e158063dab683cc Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 22 Jun 2026 22:09:10 +0100 Subject: [PATCH 04/13] docs(bruno): new bruno files --- bruno/VoD-System/Clips/Delete Clip.bru | 16 ++++++++++ .../Clips/Downloads/Get Clip Media.bru | 16 ++++++++++ .../Clips/Downloads/Get Clip Thumbnail.bru | 15 ++++++++++ bruno/VoD-System/Clips/Downloads/folder.bru | 8 +++++ bruno/VoD-System/Clips/Get Clip.bru | 16 ++++++++++ bruno/VoD-System/Jobs/Download Video.bru | 15 ++++++++++ bruno/VoD-System/Jobs/Get Job.bru | 16 ++++++++++ bruno/VoD-System/Jobs/folder.bru | 8 +++++ bruno/VoD-System/Media/Clip Stream.bru | 17 +++++++++++ bruno/VoD-System/Media/Compress.bru | 22 ++++++++++++++ .../VoD-System/Media/Save Stream Section.bru | 18 +++++++++++ bruno/VoD-System/Media/folder.bru | 8 +++++ bruno/VoD-System/Users/Get Google Token.bru | 30 +++++++++++++++++++ bruno/VoD-System/Users/Get User.bru | 11 +++++++ bruno/VoD-System/Users/Login.bru | 21 +++++++++++++ bruno/VoD-System/Users/folder.bru | 8 +++++ 16 files changed, 245 insertions(+) create mode 100644 bruno/VoD-System/Clips/Delete Clip.bru create mode 100644 bruno/VoD-System/Clips/Downloads/Get Clip Media.bru create mode 100644 bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru create mode 100644 bruno/VoD-System/Clips/Downloads/folder.bru create mode 100644 bruno/VoD-System/Clips/Get Clip.bru create mode 100644 bruno/VoD-System/Jobs/Download Video.bru create mode 100644 bruno/VoD-System/Jobs/Get Job.bru create mode 100644 bruno/VoD-System/Jobs/folder.bru create mode 100644 bruno/VoD-System/Media/Clip Stream.bru create mode 100644 bruno/VoD-System/Media/Compress.bru create mode 100644 bruno/VoD-System/Media/Save Stream Section.bru create mode 100644 bruno/VoD-System/Media/folder.bru create mode 100644 bruno/VoD-System/Users/Get Google Token.bru create mode 100644 bruno/VoD-System/Users/Get User.bru create mode 100644 bruno/VoD-System/Users/Login.bru create mode 100644 bruno/VoD-System/Users/folder.bru diff --git a/bruno/VoD-System/Clips/Delete Clip.bru b/bruno/VoD-System/Clips/Delete Clip.bru new file mode 100644 index 0000000..e7583d6 --- /dev/null +++ b/bruno/VoD-System/Clips/Delete Clip.bru @@ -0,0 +1,16 @@ +meta { + name: Delete Clip + type: http + seq: 5 +} + +delete { + url: {{base_url}}/clips/6 + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Clips/Downloads/Get Clip Media.bru b/bruno/VoD-System/Clips/Downloads/Get Clip Media.bru new file mode 100644 index 0000000..6e5def3 --- /dev/null +++ b/bruno/VoD-System/Clips/Downloads/Get Clip Media.bru @@ -0,0 +1,16 @@ +meta { + name: Get Clip Media + type: http + seq: 1 +} + +get { + url: {{base_url}}/clips/5/media + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru b/bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru new file mode 100644 index 0000000..41f0f13 --- /dev/null +++ b/bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru @@ -0,0 +1,15 @@ +meta { + name: Get Clip Thumbnail + type: http + seq: 2 +} + +get { + url: {{base_url}}/clips/5/thumbnail + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/bruno/VoD-System/Clips/Downloads/folder.bru b/bruno/VoD-System/Clips/Downloads/folder.bru new file mode 100644 index 0000000..7b0de5c --- /dev/null +++ b/bruno/VoD-System/Clips/Downloads/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Downloads + seq: 4 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Clips/Get Clip.bru b/bruno/VoD-System/Clips/Get Clip.bru new file mode 100644 index 0000000..89a935e --- /dev/null +++ b/bruno/VoD-System/Clips/Get Clip.bru @@ -0,0 +1,16 @@ +meta { + name: Get Clip + type: http + seq: 1 +} + +get { + url: {{base_url}}/clips/5 + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Jobs/Download Video.bru b/bruno/VoD-System/Jobs/Download Video.bru new file mode 100644 index 0000000..2179683 --- /dev/null +++ b/bruno/VoD-System/Jobs/Download Video.bru @@ -0,0 +1,15 @@ +meta { + name: Download Video + type: http + seq: 2 +} + +get { + url: {{base_url}}/jobs/{{uuid}}/download + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/bruno/VoD-System/Jobs/Get Job.bru b/bruno/VoD-System/Jobs/Get Job.bru new file mode 100644 index 0000000..87ca912 --- /dev/null +++ b/bruno/VoD-System/Jobs/Get Job.bru @@ -0,0 +1,16 @@ +meta { + name: Get Job + type: http + seq: 1 +} + +get { + url: {{base_url}}/jobs/{{uuid}} + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Jobs/folder.bru b/bruno/VoD-System/Jobs/folder.bru new file mode 100644 index 0000000..85369b6 --- /dev/null +++ b/bruno/VoD-System/Jobs/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Jobs + seq: 7 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Media/Clip Stream.bru b/bruno/VoD-System/Media/Clip Stream.bru new file mode 100644 index 0000000..b9abbfa --- /dev/null +++ b/bruno/VoD-System/Media/Clip Stream.bru @@ -0,0 +1,17 @@ +meta { + name: Clip Stream + type: http + seq: 3 +} + +get { + url: {{base_url}}/media/clip + body: json + auth: inherit +} + +body:json { + { + "duration": 60 + } +} diff --git a/bruno/VoD-System/Media/Compress.bru b/bruno/VoD-System/Media/Compress.bru new file mode 100644 index 0000000..0a1e443 --- /dev/null +++ b/bruno/VoD-System/Media/Compress.bru @@ -0,0 +1,22 @@ +meta { + name: Compress + type: http + seq: 1 +} + +post { + url: {{base_url}}/media/compress + body: multipartForm + auth: inherit +} + +body:multipart-form { + file: @file(/Users/dylan/Downloads/Is Russia at a Breaking Point_.mp4) + title: hello + startPoint: 10 + duration: 30 +} + +script:post-response { + bru.setEnvVar("uuid", res.getBody().data.uuid); +} diff --git a/bruno/VoD-System/Media/Save Stream Section.bru b/bruno/VoD-System/Media/Save Stream Section.bru new file mode 100644 index 0000000..962e786 --- /dev/null +++ b/bruno/VoD-System/Media/Save Stream Section.bru @@ -0,0 +1,18 @@ +meta { + name: Save Stream Section + type: http + seq: 2 +} + +get { + url: {{base_url}}/media/save + body: json + auth: inherit +} + +body:json { + { + "startTime": "2026-06-21T17:27:00Z", + "endTime": "2026-06-21T17:28:00Z" + } +} diff --git a/bruno/VoD-System/Media/folder.bru b/bruno/VoD-System/Media/folder.bru new file mode 100644 index 0000000..2c755d6 --- /dev/null +++ b/bruno/VoD-System/Media/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Media + seq: 4 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Users/Get Google Token.bru b/bruno/VoD-System/Users/Get Google Token.bru new file mode 100644 index 0000000..d810c7e --- /dev/null +++ b/bruno/VoD-System/Users/Get Google Token.bru @@ -0,0 +1,30 @@ +meta { + name: Get Google Token + type: http + seq: 3 +} + +get { + url: + body: none + auth: oauth2 +} + +auth:oauth2 { + grant_type: authorization_code + callback_url: http://localhost:8080 + authorization_url: https://accounts.google.com/o/oauth2/v2/auth + access_token_url: https://oauth2.googleapis.com/token + refresh_token_url: + client_id: {{google_client_id}} + client_secret: {{google_secret}} + scope: profile email openid + state: + pkce: true + credentials_placement: body + credentials_id: credentials + token_placement: header + token_header_prefix: Bearer + auto_fetch_token: true + auto_refresh_token: false +} diff --git a/bruno/VoD-System/Users/Get User.bru b/bruno/VoD-System/Users/Get User.bru new file mode 100644 index 0000000..20c0ad5 --- /dev/null +++ b/bruno/VoD-System/Users/Get User.bru @@ -0,0 +1,11 @@ +meta { + name: Get User + type: http + seq: 1 +} + +get { + url: {{base_url}}/users/me + body: none + auth: inherit +} diff --git a/bruno/VoD-System/Users/Login.bru b/bruno/VoD-System/Users/Login.bru new file mode 100644 index 0000000..781ebba --- /dev/null +++ b/bruno/VoD-System/Users/Login.bru @@ -0,0 +1,21 @@ +meta { + name: Login + type: http + seq: 2 +} + +post { + url: {{base_url}}/users/login + body: json + auth: none +} + +body:json { + { + "token": "{{$oauth2.credentials.id_token}}" + } +} + +script:post-response { + bru.setEnvVar("token", res.getBody().data.token); +} diff --git a/bruno/VoD-System/Users/folder.bru b/bruno/VoD-System/Users/folder.bru new file mode 100644 index 0000000..6dec0b1 --- /dev/null +++ b/bruno/VoD-System/Users/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Users + seq: 5 +} + +auth { + mode: inherit +} -- 2.49.1 From c571e12099fb70278ba797953dfed5ea7fdec32d Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 22 Jun 2026 22:10:33 +0100 Subject: [PATCH 05/13] docs(bruno): remove old endpoints --- bruno/VoD-System/Clips/folder.bru | 2 +- bruno/VoD-System/Jobs/folder.bru | 2 +- bruno/VoD-System/Media/folder.bru | 2 +- bruno/VoD-System/Streams/folder.bru | 8 ++------ bruno/VoD-System/Upload.bru | 19 ------------------- bruno/VoD-System/Users/folder.bru | 2 +- 6 files changed, 6 insertions(+), 29 deletions(-) delete mode 100644 bruno/VoD-System/Upload.bru diff --git a/bruno/VoD-System/Clips/folder.bru b/bruno/VoD-System/Clips/folder.bru index cb80f7c..ad6dce9 100644 --- a/bruno/VoD-System/Clips/folder.bru +++ b/bruno/VoD-System/Clips/folder.bru @@ -1,6 +1,6 @@ meta { name: Clips - seq: 3 + seq: 1 } auth { diff --git a/bruno/VoD-System/Jobs/folder.bru b/bruno/VoD-System/Jobs/folder.bru index 85369b6..8828051 100644 --- a/bruno/VoD-System/Jobs/folder.bru +++ b/bruno/VoD-System/Jobs/folder.bru @@ -1,6 +1,6 @@ meta { name: Jobs - seq: 7 + seq: 5 } auth { diff --git a/bruno/VoD-System/Media/folder.bru b/bruno/VoD-System/Media/folder.bru index 2c755d6..3c64875 100644 --- a/bruno/VoD-System/Media/folder.bru +++ b/bruno/VoD-System/Media/folder.bru @@ -1,6 +1,6 @@ meta { name: Media - seq: 4 + seq: 2 } auth { diff --git a/bruno/VoD-System/Streams/folder.bru b/bruno/VoD-System/Streams/folder.bru index 51c98c0..6af7cf9 100644 --- a/bruno/VoD-System/Streams/folder.bru +++ b/bruno/VoD-System/Streams/folder.bru @@ -1,12 +1,8 @@ meta { name: Streams - seq: 6 + seq: 4 } auth { - mode: bearer -} - -auth:bearer { - token: {{token}} + mode: inherit } diff --git a/bruno/VoD-System/Upload.bru b/bruno/VoD-System/Upload.bru deleted file mode 100644 index ca92bbd..0000000 --- a/bruno/VoD-System/Upload.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: Upload - type: http - seq: 2 -} - -post { - url: {{base_url}}/upload - body: multipartForm - auth: inherit -} - -body:multipart-form { - file: @file(/Users/faoite/Downloads/Why Australia's Economy is Stalling.mp4) -} - -script:post-response { - bru.setEnvVar("uuid", res.body.data.uuid); -} diff --git a/bruno/VoD-System/Users/folder.bru b/bruno/VoD-System/Users/folder.bru index 6dec0b1..babc4ab 100644 --- a/bruno/VoD-System/Users/folder.bru +++ b/bruno/VoD-System/Users/folder.bru @@ -1,6 +1,6 @@ meta { name: Users - seq: 5 + seq: 3 } auth { -- 2.49.1 From 7d8a62d33181115534c34ad23866c0ce6198cb69 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 22 Jun 2026 22:34:18 +0100 Subject: [PATCH 06/13] docs(bruno): missing post response script --- bruno/VoD-System/Media/Clip Stream.bru | 8 ++++++-- bruno/VoD-System/Media/Save Stream Section.bru | 6 +++++- .../ddf/vodsystem/configuration/SecurityConfig.java | 1 + src/main/java/com/ddf/vodsystem/dto/JobStatus.java | 13 ------------- 4 files changed, 12 insertions(+), 16 deletions(-) delete mode 100644 src/main/java/com/ddf/vodsystem/dto/JobStatus.java diff --git a/bruno/VoD-System/Media/Clip Stream.bru b/bruno/VoD-System/Media/Clip Stream.bru index b9abbfa..1010702 100644 --- a/bruno/VoD-System/Media/Clip Stream.bru +++ b/bruno/VoD-System/Media/Clip Stream.bru @@ -4,7 +4,7 @@ meta { seq: 3 } -get { +post { url: {{base_url}}/media/clip body: json auth: inherit @@ -12,6 +12,10 @@ get { body:json { { - "duration": 60 + "duration": 60.0 } } + +script:post-response { + bru.setEnvVar("uuid", res.getBody().data.uuid); +} diff --git a/bruno/VoD-System/Media/Save Stream Section.bru b/bruno/VoD-System/Media/Save Stream Section.bru index 962e786..ef727ae 100644 --- a/bruno/VoD-System/Media/Save Stream Section.bru +++ b/bruno/VoD-System/Media/Save Stream Section.bru @@ -4,7 +4,7 @@ meta { seq: 2 } -get { +post { url: {{base_url}}/media/save body: json auth: inherit @@ -16,3 +16,7 @@ body:json { "endTime": "2026-06-21T17:28:00Z" } } + +script:post-response { + bru.setEnvVar("uuid", res.getBody().data.uuid); +} diff --git a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java index 6c79878..2e70119 100644 --- a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java +++ b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java @@ -33,6 +33,7 @@ public class SecurityConfig { .requestMatchers("/api/v1/media/**").authenticated() .requestMatchers("/api/v1/jobs/**").authenticated() .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated() + .requestMatchers("/api/v1/users/login").permitAll() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/ddf/vodsystem/dto/JobStatus.java b/src/main/java/com/ddf/vodsystem/dto/JobStatus.java deleted file mode 100644 index 397587b..0000000 --- a/src/main/java/com/ddf/vodsystem/dto/JobStatus.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ddf.vodsystem.dto; - -import lombok.Data; - -@Data -public class JobStatus { - private ProgressTracker process = new ProgressTracker(); - private ProgressTracker conversion = new ProgressTracker(); - private boolean failed = false; - private String failedReason; -} - - -- 2.49.1 From e36913d6719ec840ac7d8d7bbb463cf242dde15a Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 22 Jun 2026 22:42:13 +0100 Subject: [PATCH 07/13] fix(frontend): update frontend to match new endpoints --- frontend/src/components/video/VideoCard.tsx | 2 +- frontend/src/pages/ClipEdit.tsx | 34 ++--- frontend/src/utils/endpoints.ts | 153 +++++--------------- frontend/src/utils/types.ts | 18 +-- 4 files changed, 60 insertions(+), 147 deletions(-) diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index c4131c5..7cc2519 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -58,7 +58,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className

Video Thumbnail diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index 21bd7c8..3ce8c99 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -5,7 +5,7 @@ import PlaybackSlider from "./../components/video/PlaybackSlider"; import ClipRangeSlider from "./../components/video/ClipRangeSlider"; import ConfigBox from "../components/video/ConfigBox.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx"; -import { editFile, processFile, getProgress, uploadFile } from "../utils/endpoints" +import { compress, getJob } from "../utils/endpoints" import type { VideoMetadata } from "../utils/types.ts"; import Box from "../components/Box.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx"; @@ -61,33 +61,29 @@ const ClipEdit = () => { const sendData = async () => { if (!localFile) return; - let fileId: string; + let jobId: string; try { - fileId = await uploadFile(localFile); - setUploadedId(fileId); + jobId = await compress(localFile, outputMetadata); + setUploadedId(jobId); } catch (err: unknown) { - setError(`Failed to upload file: ${err instanceof Error ? err.message : 'Unknown error'}`); + setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`); return; } - editFile(fileId, outputMetadata) - .then(() => processFile(fileId) - .catch((err: Error) => setError(`Failed to process file: ${err.message}`))) - .catch((err: Error) => setError(`Failed to edit file: ${err.message}`)); - - const interval = setInterval(async () => await pollProgress(fileId, interval), 500); + const interval = setInterval(async () => await pollProgress(jobId, interval), 500); }; - const pollProgress = async (id: string, intervalId: number) => { - getProgress(id) - .then((progress) => { - setProgress(progress.process.progress); + const pollProgress = async (jobId: string, intervalId: number) => { + getJob(jobId) + .then((job) => { + setProgress(job.progress); - if (progress.process.complete) { + if (job.state === 'FAILED') { + setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`); + clearInterval(intervalId); + } else if (job.isComplete) { clearInterval(intervalId); setDownloadable(true); - } else { - setDownloadable(false); } }) .catch((err: Error) => { @@ -99,7 +95,7 @@ const ClipEdit = () => { const handleDownload = async () => { if (!uploadedId) return; - const response = await fetch(API_URL + `/api/v1/download/output/${uploadedId}`); + const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' }); if (!response.ok) { console.error('Download failed'); diff --git a/frontend/src/utils/endpoints.ts b/frontend/src/utils/endpoints.ts index 590a323..b711d07 100644 --- a/frontend/src/utils/endpoints.ts +++ b/frontend/src/utils/endpoints.ts @@ -1,4 +1,4 @@ -import type {VideoMetadata, APIResponse, User, Clip, ProgressResult } from "./types.ts"; +import type {VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts"; const API_URL = import.meta.env.VITE_API_URL; @@ -12,7 +12,7 @@ export class AuthError extends Error { * @return A promise that resolves to a JWT */ const login = async (GoogleToken: string): Promise => { - const response = await fetch(API_URL + '/api/v1/auth/login', { + const response = await fetch(API_URL + '/api/v1/users/login', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -35,7 +35,7 @@ const login = async (GoogleToken: string): Promise => { } const logout = async () => { - const response = await fetch(API_URL + '/api/v1/auth/logout', { + const response = await fetch(API_URL + '/api/v1/users/logout', { method: 'POST', credentials: 'include' }); @@ -52,132 +52,57 @@ const logout = async () => { } /** - * Uploads a file to the backend. - * @param file - The file to upload. + * Uploads a file and starts compression in one step. + * @param file - The file to compress. + * @param options - The clip/compression options. + * @returns The job UUID to poll for progress. */ -const uploadFile = async (file: File): Promise => { +const compress = async (file: File, options: VideoMetadata): Promise => { const formData = new FormData(); formData.append('file', file); + formData.append('title', options.title); + if (options.description) formData.append('description', options.description); + formData.append('startPoint', options.startPoint.toString()); + formData.append('duration', options.duration.toString()); + if (options.fps) formData.append('fps', options.fps.toString()); + if (options.width) formData.append('width', options.width.toString()); + if (options.height) formData.append('height', options.height.toString()); + if (options.fileSize) formData.append('fileSize', options.fileSize.toString()); - const response = await fetch(API_URL + '/api/v1/upload', { + const response = await fetch(API_URL + '/api/v1/media/compress', { method: 'POST', body: formData, + credentials: 'include', }); + if (!response.ok) { + throw new Error(`Compression failed: ${response.status}`); + } + const result: APIResponse = await response.json(); - if (result.status == "error") { - throw new Error(`Failed to upload file: ${result.message}`); + if (result.status === 'error') { + throw new Error(`Compression failed: ${result.message}`); } return result.data.uuid; }; /** - * Submits metadata changes to the backend. - * @param uuid - The UUID of the video file to edit. - * @param videoMetadata - The metadata to update. + * Fetches the status of a background job. + * @param uuid - The job UUID. */ -const editFile = async (uuid: string, videoMetadata: VideoMetadata) => { - const formData = new URLSearchParams(); - - for (const [key, value] of Object.entries(videoMetadata)) { - if (value !== undefined && value !== null) { - formData.append(key, value.toString()); - } - } - - const response = await fetch(API_URL + `/api/v1/edit/${uuid}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: formData.toString(), - }); +const getJob = async (uuid: string): Promise => { + const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' }); if (!response.ok) { - throw new Error(`Failed to edit file: ${response.status}`); + throw new Error(`Failed to fetch job: ${response.status}`); } const result: APIResponse = await response.json(); - if (result.status === "error") { - throw new Error(`Failed to edit file: ${result.message}`); - } - -}; - -/** - * Triggers file processing. - * @param uuid - The UUID of the video file to process. - */ -const processFile = async (uuid: string) => { - const response = await fetch(API_URL + `/api/v1/process/${uuid}`, {credentials: "include"}); - - if (!response.ok) { - throw new Error(`Failed to process file: ${response.status}`); - } - - const result: APIResponse = await response.json(); - - if (result.status === "error") { - throw new Error("Failed to process file: " + result.message); - } -}; - -const convertFile = async (uuid: string) => { - const response = await fetch(API_URL + `/api/v1/convert/${uuid}`); - - if (!response.ok) { - throw new Error(`Failed to convert file: ${response.status}`); - } - - const result: APIResponse = await response.json(); - - if (result.status === "error") { - throw new Error("Failed to convert file: " + result.message); - } -}; - -/** - * Fetches the processing progress percentage. - * @param uuid - The UUID of the video file. - */ -const getProgress = async (uuid: string): Promise => { - const response = await fetch(API_URL + `/api/v1/progress/${uuid}`); - - if (!response.ok) { - throw new Error(`Failed to fetch progress: ${response.status}`); - } - - const result = await response.json(); - - if (result.status === "error") { - throw new Error(`Failed to fetch progress: ${result.message}`); - } - - if (!result.data) { - throw new Error('Invalid progress data received'); - } - - return result.data; -}; - -/** - * Fetches original metadata from the backend. - * @param uuid - The UUID of the video file. - */ -const getMetadata = async (uuid: string): Promise => { - const response = await fetch(API_URL + `/api/v1/metadata/original/${uuid}`, {credentials: "include"}); - - if (!response.ok) { - throw new Error(`Failed to fetch metadata: ${response.status}`); - } - - const result = await response.json(); - - if (result.status === "error") { - throw new Error(`Failed to fetch metadata: ${result.message}`); + if (result.status === 'error') { + throw new Error(`Failed to fetch job: ${result.message}`); } return result.data; @@ -187,7 +112,7 @@ const getMetadata = async (uuid: string): Promise => { * Fetches the current user information. Returns null if not authenticated. */ const getUser = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/auth/user', {credentials: "include"}); + const response = await fetch(API_URL + '/api/v1/users/me', {credentials: "include"}); if (!response.ok) { return null; @@ -245,7 +170,7 @@ const getClipById = async (id: string): Promise => { }; const getVideoBlob = async(id: string): Promise => { - const response = await fetch(API_URL + `/api/v1/download/clip/${id}`, {credentials: "include",}); + const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, {credentials: "include",}); if (!response.ok) { if (response.status === 401 || response.status === 403) throw new AuthError(); @@ -303,22 +228,16 @@ const getStreamStatus = async (): Promise => { }; const isThumbnailAvailable = async (id: number): Promise => { - const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"}); + const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, {credentials: "include"}); return response.ok; - - } export { login, logout, getStreamStatus, - uploadFile, - editFile, - processFile, - convertFile, - getProgress, - getMetadata, + compress, + getJob, getUser, getClips, getClipById, diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 04cc9f6..a75eede 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -33,15 +33,13 @@ type Clip = { createdAt: string, } -type ProgressResult = { - process: { - progress: number, - complete: boolean - }; - conversion: { - progress: number, - complete: boolean - }; +type JobResponse = { + uuid: string, + progress: number, + isComplete: boolean, + state: 'READY' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED', + errorOutput: string | null, + createdAt: string, }; export type { @@ -49,5 +47,5 @@ export type { VideoMetadata, User, Clip, - ProgressResult + JobResponse } \ No newline at end of file -- 2.49.1 From 8b49a10a8c7a6b11ae62c129d2d294e8ee630343 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 22 Jun 2026 22:50:07 +0100 Subject: [PATCH 08/13] refactor(frontend): remove dead code --- frontend/src/components/Box.tsx | 22 ++- frontend/src/components/Dropdown.tsx | 47 ++--- frontend/src/components/Selector.tsx | 26 ++- frontend/src/components/Sidebar.tsx | 1 - .../src/components/buttons/SidebarButton.tsx | 6 +- .../src/components/video/ClipRangeSlider.tsx | 40 ++--- frontend/src/components/video/ConfigBox.tsx | 35 ++-- frontend/src/components/video/MetadataBox.tsx | 36 ++-- .../src/components/video/PlaybackSlider.tsx | 35 ++-- frontend/src/components/video/Playbar.tsx | 49 ++---- frontend/src/components/video/VideoCard.tsx | 9 +- frontend/src/pages/Home.tsx | 6 +- frontend/src/utils/endpoints.ts | 164 +++++------------- frontend/src/utils/utils.ts | 82 +++------ 14 files changed, 185 insertions(+), 373 deletions(-) diff --git a/frontend/src/components/Box.tsx b/frontend/src/components/Box.tsx index 55536fa..31a3872 100644 --- a/frontend/src/components/Box.tsx +++ b/frontend/src/components/Box.tsx @@ -1,16 +1,14 @@ import clsx from "clsx"; -type props = { - children: React.ReactNode, - className?: string -} +type Props = { + children: React.ReactNode; + className?: string; +}; -const Box = ({children, className}: props) => { - return ( -
- { children } -
- ) -} +const Box = ({ children, className }: Props) => ( +
+ {children} +
+); -export default Box; \ No newline at end of file +export default Box; diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx index 882d031..db384e0 100644 --- a/frontend/src/components/Dropdown.tsx +++ b/frontend/src/components/Dropdown.tsx @@ -1,54 +1,45 @@ -import React, {useEffect, useRef} from "react"; +import React, { useState, useEffect, useRef } from "react"; import clsx from "clsx"; type DropdownItemProps = { item: string; onClick: (item: string) => void; className?: string; -} +}; -const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => { - return ( -
  • onClick(item)} - > - {item} -
  • - ); -} +const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => ( +
  • onClick(item)} + > + {item} +
  • +); type DropdownProps = { label: React.ReactNode; children: React.ReactNode; className?: string; -} +}; const Dropdown = ({ label, children, className }: DropdownProps) => { - const [isOpen, setIsOpen] = React.useState(false); + const [isOpen, setIsOpen] = useState(false); const ref = useRef(null); - const toggleDropdown = () => { - setIsOpen(!isOpen); - }; - useEffect(() => { - function handleClickOutside(event: { target: any }) { - if (ref.current && !ref.current.contains(event.target)) { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { setIsOpen(false); } - } - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); }, []); return ( -
    +
    - - - +
    ); -} \ No newline at end of file +} diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index 7cc2519..084aafc 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -25,9 +25,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className const [confirmDelete, setConfirmDelete] = useState(false); const menuRef = useRef(null); - setTimeout(() => { - setTimeAgo(dateToTimeAgo(stringToDate(createdAt))); - }, 1000); + useEffect(() => { + const interval = setInterval(() => { + setTimeAgo(dateToTimeAgo(stringToDate(createdAt))); + }, 1000); + return () => clearInterval(interval); + }, [createdAt]); useEffect(() => { isThumbnailAvailable(id) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 73e7258..c2f704d 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -3,8 +3,8 @@ import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lu const features = [ { icon: Upload, - title: "Upload & convert", - description: "Drop in a video file. Non-native formats are converted automatically before editing.", + title: "Upload & compress", + description: "Drop in a video file, set your clip range and export settings, then compress.", }, { icon: Scissors, @@ -71,7 +71,7 @@ const Home = () => { {/* Format note */}

    - Supports MP4, MOV, WebM, and OGG. Non-native formats are converted on upload. + Supports MP4, MOV, WebM, and OGG.

    ); diff --git a/frontend/src/utils/endpoints.ts b/frontend/src/utils/endpoints.ts index b711d07..d75b09b 100644 --- a/frontend/src/utils/endpoints.ts +++ b/frontend/src/utils/endpoints.ts @@ -1,4 +1,4 @@ -import type {VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts"; +import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts"; const API_URL = import.meta.env.VITE_API_URL; @@ -6,57 +6,34 @@ export class AuthError extends Error { constructor() { super("Not authenticated"); this.name = "AuthError"; } } -/** - * Login function - * @param GoogleToken - The Google token received from the frontend. - * @return A promise that resolves to a JWT - */ -const login = async (GoogleToken: string): Promise => { +const login = async (googleToken: string): Promise => { const response = await fetch(API_URL + '/api/v1/users/login', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ token: GoogleToken }), - credentials: 'include' + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: googleToken }), + credentials: 'include', }); - if (!response.ok) { - throw new Error(`Login failed: ${response.status}`); - } + if (!response.ok) throw new Error(`Login failed: ${response.status}`); const result: APIResponse = await response.json(); - - if (result.status === "error") { - throw new Error(`Login failed: ${result.message}`); - } + if (result.status === 'error') throw new Error(`Login failed: ${result.message}`); return result.data.token; -} +}; -const logout = async () => { +const logout = async (): Promise => { const response = await fetch(API_URL + '/api/v1/users/logout', { method: 'POST', - credentials: 'include' + credentials: 'include', }); - if (!response.ok) { - throw new Error(`Logout failed: ${response.status}`); - } + if (!response.ok) throw new Error(`Logout failed: ${response.status}`); const result: APIResponse = await response.json(); + if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`); +}; - if (result.status === "error") { - throw new Error(`Logout failed: ${result.message}`); - } -} - -/** - * Uploads a file and starts compression in one step. - * @param file - The file to compress. - * @param options - The clip/compression options. - * @returns The job UUID to poll for progress. - */ const compress = async (file: File, options: VideoMetadata): Promise => { const formData = new FormData(); formData.append('file', file); @@ -75,114 +52,69 @@ const compress = async (file: File, options: VideoMetadata): Promise => credentials: 'include', }); - if (!response.ok) { - throw new Error(`Compression failed: ${response.status}`); - } + if (!response.ok) throw new Error(`Compression failed: ${response.status}`); const result: APIResponse = await response.json(); - - if (result.status === 'error') { - throw new Error(`Compression failed: ${result.message}`); - } + if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`); return result.data.uuid; }; -/** - * Fetches the status of a background job. - * @param uuid - The job UUID. - */ const getJob = async (uuid: string): Promise => { const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' }); - if (!response.ok) { - throw new Error(`Failed to fetch job: ${response.status}`); - } + if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`); const result: APIResponse = await response.json(); - - if (result.status === 'error') { - throw new Error(`Failed to fetch job: ${result.message}`); - } + if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`); return result.data; }; -/** - * Fetches the current user information. Returns null if not authenticated. - */ -const getUser = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/users/me', {credentials: "include"}); +const getUser = async (): Promise => { + const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' }); + if (!response.ok) return null; - if (!response.ok) { - return null; - } - - const result = await response.json(); - - if (result.status === "error") { - return null; - } - - console.log(result.data); + const result: APIResponse = await response.json(); + if (result.status === 'error') return null; return result.data; -} +}; -/** - * Fetches all clips for the current user. - */ const getClips = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include'}); + const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' }); if (!response.ok) { const errorResult: APIResponse = await response.json(); throw new Error(`Failed to fetch clips: ${errorResult.message}`); } - try { - const result: APIResponse = await response.json(); - return result.data; - } catch { - throw new Error('Failed to parse response'); - } -} + const result: APIResponse = await response.json(); + return result.data; +}; -/** - * Fetches a clip by its ID. - * @param id - */ const getClipById = async (id: string): Promise => { - const response = await fetch(API_URL + `/api/v1/clips/${id}`, {credentials: "include",}); + const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' }); if (!response.ok) { if (response.status === 401 || response.status === 403) throw new AuthError(); throw new Error(`Failed to fetch clip: ${response.status}`); } - try{ - const result: APIResponse = await response.json(); - return result.data; - } catch (error: unknown) { - throw new Error(`Failed to parse clip response: - ${error instanceof Error ? error.message : 'Unknown error'}`); - } + const result: APIResponse = await response.json(); + return result.data; }; -const getVideoBlob = async(id: string): Promise => { - const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, {credentials: "include",}); +const getVideoBlob = async (id: string): Promise => { + const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' }); if (!response.ok) { if (response.status === 401 || response.status === 403) throw new AuthError(); - throw new Error(`Failed to fetch video: ${id}: ${response.status}`) + throw new Error(`Failed to fetch video ${id}: ${response.status}`); } - try { - return response.blob(); - } catch { - throw new Error(`Failed to convert Clip Return Object to blob`); - } -} + return response.blob(); +}; const patchClip = async (id: number, data: { title?: string; description?: string }): Promise => { const response = await fetch(API_URL + `/api/v1/clips/${id}`, { @@ -192,14 +124,10 @@ const patchClip = async (id: number, data: { title?: string; description?: strin body: JSON.stringify(data), }); - if (!response.ok) { - throw new Error(`Failed to update clip: ${response.status}`); - } + if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`); const result: APIResponse = await response.json(); - if (result.status === 'error') { - throw new Error(`Failed to update clip: ${result.message}`); - } + if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`); return result.data; }; @@ -210,14 +138,10 @@ const deleteClip = async (id: number): Promise => { credentials: 'include', }); - if (!response.ok) { - throw new Error(`Failed to delete clip: ${response.status}`); - } + if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`); const result: APIResponse = await response.json(); - if (result.status === 'error') { - throw new Error(`Failed to delete clip: ${result.message}`); - } + if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`); }; const getStreamStatus = async (): Promise => { @@ -228,14 +152,13 @@ const getStreamStatus = async (): Promise => { }; const isThumbnailAvailable = async (id: number): Promise => { - const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, {credentials: "include"}); + const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' }); return response.ok; -} +}; export { login, logout, - getStreamStatus, compress, getJob, getUser, @@ -244,5 +167,6 @@ export { getVideoBlob, patchClip, deleteClip, - isThumbnailAvailable -}; \ No newline at end of file + getStreamStatus, + isThumbnailAvailable, +}; diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 79dd989..de79921 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -2,76 +2,34 @@ function formatTime(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); - - const padded = (n: number) => n.toString().padStart(2, '0'); - - if (h > 0) { - return `${h}:${padded(m)}:${padded(s)}`; - } else { - return `${m}:${padded(s)}`; - } + const pad = (n: number) => n.toString().padStart(2, '0'); + if (h > 0) return `${h}:${pad(m)}:${pad(s)}`; + return `${m}:${pad(s)}`; } function stringToDate(dateString: string): Date { - const validIso = dateString.substring(0, 23); - const date = new Date(validIso); - if (isNaN(date.getTime())) { - throw new Error("Invalid date string"); - } + const date = new Date(dateString.substring(0, 23)); + if (isNaN(date.getTime())) throw new Error("Invalid date string"); return date; } function dateToTimeAgo(date: Date): string { - const now = new Date(); - const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000); - - if (secondsAgo < 60) { - return `${secondsAgo} seconds ago`; - } else if (secondsAgo < 3600) { - const minutes = Math.floor(secondsAgo / 60); - - if (minutes === 1) { - return "1 minute ago"; + const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000); + const units: [number, string][] = [ + [31536000, 'year'], + [2592000, 'month'], + [86400, 'day'], + [3600, 'hour'], + [60, 'minute'], + ]; + for (const [threshold, unit] of units) { + if (secondsAgo >= threshold) { + const count = Math.floor(secondsAgo / threshold); + return `${count} ${unit}${count !== 1 ? 's' : ''} ago`; } - - return `${minutes} minutes ago`; - } else if (secondsAgo < 86400) { - const hours = Math.floor(secondsAgo / 3600); - - if (hours === 1) { - return "1 hour ago"; - } - - return `${hours} hours ago`; - } else if (secondsAgo < 2592000) { - const days = Math.floor(secondsAgo / 86400); - - if (days === 1) { - return "1 day ago"; - } - - return `${days} days ago`; - } else if (secondsAgo < 31536000) { - const months = Math.floor(secondsAgo / 2592000); - - if (months === 1) { - return "1 month ago"; - } - - return `${months} months ago`; - } else { - const years = Math.floor(secondsAgo / 31536000); - - if (years === 1) { - return "1 year ago"; - } - - return `${years} years ago`; } + const s = Math.max(0, secondsAgo); + return `${s} second${s !== 1 ? 's' : ''} ago`; } -export { - formatTime, - stringToDate, - dateToTimeAgo -} \ No newline at end of file +export { formatTime, stringToDate, dateToTimeAgo }; -- 2.49.1 From 5651aa4ce936bf1897dc06fade0131e824b8bb77 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Tue, 23 Jun 2026 20:53:24 +0100 Subject: [PATCH 09/13] refactor(directories): switch from File to Path objs --- docker-compose.yml | 2 - src/main/Dockerfile | 6 - src/main/java/com/ddf/vodsystem/dto/Job.java | 4 +- .../ddf/vodsystem/services/ClipService.java | 21 +-- .../vodsystem/services/DirectoryService.java | 157 ++++++++---------- .../ddf/vodsystem/services/MediaService.java | 49 +++--- .../services/media/CompressionService.java | 22 ++- .../services/media/MetadataService.java | 50 +----- .../services/media/StreamActionsService.java | 16 +- .../services/media/ThumbnailService.java | 8 +- .../resources/application-local.properties | 7 +- src/main/resources/application.properties | 11 +- 12 files changed, 143 insertions(+), 210 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 796f1cb..8c87eba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,6 @@ services: POSTGRES_DB: ${POSTGRES_DB} volumes: - ${POSTGRES_DATA}:/var/lib/postgresql/data - ports: - - "5432:5432" backend: build: diff --git a/src/main/Dockerfile b/src/main/Dockerfile index 328e231..3b0af77 100644 --- a/src/main/Dockerfile +++ b/src/main/Dockerfile @@ -15,12 +15,6 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -ENV POSTGRES_DATA=/app/db -ENV MEDIA_DATA=/app/data/media -ENV STREAM_DATA=/app/data/media/streams - -VOLUME ["app/db", "app/data/media/streams", "/app/data/media"] - WORKDIR /app COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 diff --git a/src/main/java/com/ddf/vodsystem/dto/Job.java b/src/main/java/com/ddf/vodsystem/dto/Job.java index 00a1d77..877b41d 100644 --- a/src/main/java/com/ddf/vodsystem/dto/Job.java +++ b/src/main/java/com/ddf/vodsystem/dto/Job.java @@ -2,7 +2,7 @@ package com.ddf.vodsystem.dto; import lombok.Data; -import java.io.File; +import java.nio.file.Path; import java.time.Instant; @Data @@ -12,7 +12,7 @@ public class Job { private JobState state = JobState.READY; private String errorOutput; - private File download; + private Path download; private Instant createdAt = Instant.now(); public Job(String uuid) { diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index 88fa8ee..0abe1ad 100644 --- a/src/main/java/com/ddf/vodsystem/services/ClipService.java +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -7,6 +7,7 @@ import com.ddf.vodsystem.entities.*; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -200,8 +201,8 @@ public class ClipService { */ public Clip saveClip(ClipOptions options, User user, - String videoPath, - String thumbnailPath) { + Path videoPath, + Path thumbnailPath) { Clip clip = new Clip(); clip.setUser(user); clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip"); @@ -212,23 +213,23 @@ public class ClipService { clip.setFps(options.getFps()); clip.setDuration(options.getDuration() - options.getStartPoint()); clip.setFileSize(options.getFileSize()); - clip.setVideoPath(videoPath); - clip.setThumbnailPath(thumbnailPath); + clip.setVideoPath(videoPath.toString()); + clip.setThumbnailPath(thumbnailPath.toString()); return clipRepository.save(clip); } public void persistClip(String title, String description, User user, - File clipFile, + Path clipFile, String fileName) { - File newClipFile; - File thumbnailFile; + Path newClipFile; + Path thumbnailFile; // Move temp file from temp dir to output dir try { - newClipFile = directoryService.getUserClipsFile(user.getId(), fileName); - thumbnailFile = directoryService.getUserThumbnailsFile(user.getId(), fileName + ".png"); + newClipFile = directoryService.getClipsDir(user.getId()).resolve(fileName); + thumbnailFile = directoryService.getThumbnailsDir(user.getId()).resolve(fileName + ".png"); directoryService.copyFile(clipFile, newClipFile); } catch (IOException e) { throw new StorageException("Failed to move clip from temporary directory to output directory", e); @@ -255,7 +256,7 @@ public class ClipService { clipMetadata.setTitle(title); clipMetadata.setDescription(description); - Clip clip = saveClip(clipMetadata, user, newClipFile.getAbsolutePath(), thumbnailFile.getAbsolutePath()); + Clip clip = saveClip(clipMetadata, user, newClipFile, thumbnailFile); logger.info("Clip created successfully with ID: {}", clip.getId()); } diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java index cc0bee6..0bbcc62 100644 --- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -10,7 +10,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; +import java.util.stream.Stream; import java.nio.file.StandardCopyOption; import org.slf4j.Logger; @@ -20,135 +20,108 @@ public class DirectoryService { private static final Logger logger = org.slf4j.LoggerFactory.getLogger(DirectoryService.class); - @Value("${storage.outputs}") - private String outputDir; + @Value("${media.users}") + private String usersDir; - @Value("${STREAM_DATA}") - private String streamDir; + @Value("${media.streams}") + private String streamsDir; - @Value("${storage.temp.inputs}") + @Value("${media.temp.inputs}") private String tempInputsDir; - @Value("${storage.temp.outputs}") + @Value("${media.temp.outputs}") private String tempOutputsDir; private static final long TEMP_DIR_TIME_LIMIT = 3 * 60 * 60 * (long) 1000; // 3 hours private static final long TEMP_DIR_CLEANUP_RATE = 30 * 60 * (long) 1000; // 30 minutes - public File getTempInputFile(String filename) { - String dir = tempInputsDir + File.separator + filename; - return new File(dir); + private static final String VODS_DIR_NAME = "vods"; + private static final String CLIPS_DIR_NAME = "clips"; + private static final String THUMBNAIL_DIR_NAME = "thumbnails"; + + public Path getTempInputDir() { + return Path.of(tempInputsDir); } - public File getTempOutputFile(String filename) { - String dir = tempOutputsDir + File.separator + filename; - return new File(dir); + public Path getTempOutputDir() { + return Path.of(tempOutputsDir); } - public File getUserFolder(Long userId) throws IOException { - if (userId == null) { - throw new IllegalArgumentException("User ID cannot be null or empty"); - } - - String dir = outputDir + File.separator + userId; - createDirectory(dir); - return new File(dir); + public Path getUserDir(Long userId) throws IOException { + Path userDir = Path.of(usersDir + File.separator + userId); + Files.createDirectories(userDir); + return userDir; } - public File getUserClipsFile(Long userId, String filename) throws IOException { - if (filename == null || filename.isEmpty()) { - throw new IllegalArgumentException("File name cannot be null or empty"); - } - - File userDir = getUserFolder(userId); - String fullPath = userDir.getAbsolutePath() + File.separator + "clips" + File.separator + filename; - File file = new File(fullPath); - - createDirectory(file.getParent()); - return file; + public Path getVodsDir(Long userId) throws IOException { + Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME); + Files.createDirectories(vodDir); + return vodDir; } - public File getVodFile(Long userId, String filename) throws IOException { - if (filename == null || filename.isEmpty()) { - throw new IllegalArgumentException("File name cannot be null or empty"); - } - - String userDir = getUserFolder(userId).getAbsolutePath(); - File file = new File(userDir + File.separator + "vods" + File.separator + filename); - createDirectory(file.getParent()); - - return file; + public Path getClipsDir(Long userId) throws IOException { + Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME); + Files.createDirectories(clipsDir); + return clipsDir; } - public File getStreamDir(String streamKey) throws IOException { - File file = new File(streamDir + File.separator + streamKey); - createDirectory(file.getAbsolutePath()); - return file; + public Path getThumbnailsDir(Long userId) throws IOException { + Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME); + Files.createDirectories(thumbnailsDir); + return thumbnailsDir; } - public File getUserThumbnailsFile(Long userId, String filename) throws IOException { - if (filename == null || filename.isEmpty()) { - throw new IllegalArgumentException("File name cannot be null or empty"); - } - - String userDir = getUserFolder(userId).getAbsolutePath(); - File file = new File(userDir + File.separator + "thumbnails" + File.separator + filename); - createDirectory(file.getParent()); - - return file; + public Path getStreamDir(String streamKey) throws IOException { + Path streamFolder = Path.of(streamsDir, streamKey); + Files.createDirectories(streamFolder); + return streamFolder; } - public void saveMultipartFile(File file, MultipartFile multipartFile) throws IOException { - createDirectory(file.getParent()); - Path filePath = Paths.get(file.getAbsolutePath()); - Files.copy(multipartFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException { + Files.createDirectories(path.getParent()); + Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING); } - public void copyFile(File source, File target) throws IOException { - Path sourcePath = Paths.get(source.getAbsolutePath()); - Path destPath = Paths.get(target.getAbsolutePath()); - - Files.createDirectories(destPath.getParent()); - Files.copy(sourcePath, destPath, StandardCopyOption.REPLACE_EXISTING); - logger.info("Copied file from {} to {}", sourcePath, destPath); - } - - public void cutFile(File source, File target) throws IOException { - copyFile(source, target); - Files.deleteIfExists(source.toPath()); - logger.info("Deleted source file: {}", source.getAbsolutePath()); - } - - private void createDirectory(String dir) throws IOException { - // Create the directory if it doesn't exist - Path outputPath = Paths.get(dir); - if (Files.notExists(outputPath)) { - Files.createDirectories(outputPath); - logger.info("Created directory: {}", outputPath); - } + public void copyFile(Path source, Path target) throws IOException { + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + logger.info("Copied file from {} to {}", source, target); } private void cleanUpDirectory(String dir) throws IOException { - File file = new File(dir); - File[] files = file.listFiles(); + Path dirPath = Path.of(dir); - if (files == null) { + if (Files.notExists(dirPath)) { logger.warn("No files found in directory: {}", dir); return; } - for (File f : files){ - if (f.isFile() && f.lastModified() < (System.currentTimeMillis() - TEMP_DIR_TIME_LIMIT)) { - Files.delete(f.toPath()); - } + try (Stream files = Files.list(dirPath)) { + files.filter(Files::isRegularFile) + .filter(p -> { + try { + return Files.getLastModifiedTime(p).toMillis() + < System.currentTimeMillis() - TEMP_DIR_TIME_LIMIT; + } catch (IOException e) { + logger.warn("Could not read last modified time for {}", p); + return false; + } + }) + .forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + logger.warn("Could not delete file {}", p); + } + }); } } @PostConstruct public void createDirectoriesIfNotExist() throws IOException { - createDirectory(tempInputsDir); - createDirectory(tempOutputsDir); - createDirectory(outputDir); + Files.createDirectories(Path.of(tempInputsDir)); + Files.createDirectories(Path.of(tempOutputsDir)); + Files.createDirectories(Path.of(usersDir)); } @Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE) diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java index 610137d..8a791a9 100644 --- a/src/main/java/com/ddf/vodsystem/services/MediaService.java +++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java @@ -10,12 +10,14 @@ 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.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; +import java.util.stream.Stream; @Service public class MediaService { @@ -49,8 +51,8 @@ public class MediaService { Optional user = userService.getLoggedInUser(); String filename = job.getUuid() + ".mp4"; - File tempInputFile = directoryService.getTempInputFile(filename); - File tempOutputFile = directoryService.getTempOutputFile(filename); + Path tempInputFile = directoryService.getTempInputDir().resolve(filename); + Path tempOutputFile = directoryService.getTempOutputDir().resolve(filename); directoryService.saveMultipartFile(tempInputFile, file); job.setState(JobState.PROCESSING); @@ -107,8 +109,8 @@ public class MediaService { .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); Job job = jobRegistryService.generateJob(); - File streamDirectory = directoryService.getStreamDir(user.getStreamKey()); - List fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime); + Path streamDirectory = directoryService.getStreamDir(user.getStreamKey()); + List fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime); if (fileSegments.isEmpty()) { throw new IllegalArgumentException("No stream segments found in the given time range"); @@ -119,7 +121,7 @@ public class MediaService { float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f; job.setState(JobState.PROCESSING); - File outputFile = directoryService.getVodFile(user.getId(), UUID.randomUUID() + ".mp4"); + Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4"); streamActionsService.saveSection( fileSegments, trimOffset, @@ -167,8 +169,8 @@ public class MediaService { .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); Job job = jobRegistryService.generateJob(); - File streamDirectory = directoryService.getStreamDir(user.getStreamKey()); - List fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime); + Path streamDirectory = directoryService.getStreamDir(user.getStreamKey()); + List fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime); if (fileSegments.isEmpty()) { throw new IllegalArgumentException("No stream segments found in the given time range"); @@ -177,7 +179,7 @@ public class MediaService { long firstSegmentMs = parseTimestampMs(fileSegments.getFirst()); float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); - File outputFile = directoryService.getTempOutputFile(UUID.randomUUID() + ".mp4"); + Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4"); streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile, job.getProgressTracker()) .thenRun(() -> { job.setDownload(outputFile); @@ -188,7 +190,7 @@ public class MediaService { "", user, outputFile, - outputFile.getName() + outputFile.getFileName().toString() ); } ).exceptionally(ex -> { @@ -201,25 +203,24 @@ public class MediaService { 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(); - + private List getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException { 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()); + try (Stream files = Files.list(streamDirectory)) { + return files + .filter(p -> p.getFileName().toString().endsWith(".ts")) + .filter(p -> { + long segMs = parseTimestampMs(p); + 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", ""); + private long parseTimestampMs(Path path) { + String name = path.getFileName().toString().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/media/CompressionService.java b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java index 425c165..13edcfb 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java @@ -8,8 +8,8 @@ import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -26,14 +26,18 @@ public class CompressionService { private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); @Async("ffmpegTaskExecutor") - public CompletableFuture compress(File inputFile, - File outputFile, + public CompletableFuture compress(Path inputFile, + Path outputFile, ClipOptions clipOptions, ProgressTracker progress ) throws IOException, InterruptedException { - logger.info("Compressing video from {} to {}", inputFile.getAbsolutePath(), outputFile.getAbsolutePath()); + logger.info("Compressing video from {} to {}", inputFile.toAbsolutePath(), outputFile.toAbsolutePath()); - List command = buildCommand(inputFile, outputFile, clipOptions); + List command = buildCommand( + inputFile, + outputFile, + clipOptions + ); CommandOutput result = CommandRunner.run(command, line -> setProgress(line, progress, clipOptions.getDuration())); progress.markComplete(); @@ -93,14 +97,14 @@ public class CompressionService { return command; } - private List buildInputs(File inputFile, Float startPoint, Float length) { + private List buildInputs(Path inputFile, Float startPoint, Float length) { List command = new ArrayList<>(); command.add("-ss"); command.add(startPoint.toString()); command.add("-i"); - command.add(inputFile.getAbsolutePath()); + command.add(inputFile.toAbsolutePath().toString()); command.add("-t"); command.add(Float.toString(length)); @@ -108,7 +112,7 @@ public class CompressionService { return command; } - private List buildCommand(File inputFile, File outputFile, ClipOptions clipOptions) { + private List buildCommand(Path inputFile, Path outputFile, ClipOptions clipOptions) { List command = new ArrayList<>(); command.add("ffmpeg"); command.add("-progress"); @@ -126,7 +130,7 @@ public class CompressionService { } // Output file - command.add(outputFile.getAbsolutePath()); + command.add(outputFile.toAbsolutePath().toString()); return command; } } diff --git a/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java b/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java index ea9290b..85f3330 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java @@ -10,8 +10,8 @@ import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; @@ -21,8 +21,8 @@ public class MetadataService { private static final Logger logger = LoggerFactory.getLogger(MetadataService.class); @Async("ffmpegTaskExecutor") - public Future getVideoMetadata(File file) { - logger.info("Getting metadata for file {}", file.getAbsolutePath()); + public Future getVideoMetadata(Path file) { + logger.info("Getting metadata for file {}", file); List command = List.of( "ffprobe", @@ -30,7 +30,7 @@ public class MetadataService { "-print_format", "json", "-show_format", "-select_streams", "v:0", "-show_entries", "stream=duration,width,height,r_frame_rate:format=size,duration", - "-i", file.getAbsolutePath() + "-i", file.toAbsolutePath().toString() ); ObjectMapper mapper = new ObjectMapper(); @@ -51,48 +51,6 @@ public class MetadataService { } } - public void validateMetadata(ClipOptions inputFileMetadata, ClipOptions outputFileMetadata) { - Float start = outputFileMetadata.getStartPoint(); - Float duration = outputFileMetadata.getDuration(); - Float fileSize = outputFileMetadata.getFileSize(); - Integer width = outputFileMetadata.getWidth(); - Integer height = outputFileMetadata.getHeight(); - Float fps = outputFileMetadata.getFps(); - - if (start == null) { - outputFileMetadata.setStartPoint(0f); - } - - if (duration == null) { - outputFileMetadata.setDuration(inputFileMetadata.getDuration()); - } - - if (start != null && start < 0) { - throw new IllegalArgumentException("Start point cannot be negative"); - } - - if (duration != null && duration < 0) { - throw new IllegalArgumentException("Duration cannot be negative"); - } - - if (fileSize != null && fileSize < 100) { - throw new IllegalArgumentException("File size cannot be less than 100kb"); - } - - if (width != null && width < 1) { - throw new IllegalArgumentException("Width cannot be less than 1"); - } - - if (height != null && height < 1) { - throw new IllegalArgumentException("Height cannot be less than 1"); - } - - if (fps != null && fps < 1) { - throw new IllegalArgumentException("FPS cannot be less than 1"); - } - } - - private ClipOptions parseVideoMetadata(JsonNode node) { ClipOptions metadata = new ClipOptions(); metadata.setStartPoint(0f); 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 900649a..cc196e6 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -6,7 +6,7 @@ import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.File; +import java.nio.file.Path; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -47,16 +47,16 @@ public class StreamActionsService { * failure */ @Async("ffmpegTaskExecutor") - public CompletableFuture saveSection( - List segments, + public CompletableFuture saveSection( + List segments, float trimOffset, float duration, - File outputFile, + Path outputFile, ProgressTracker progressTracker ) { try { String concatInput = segments.stream() - .map(File::getAbsolutePath) + .map(p -> p.toAbsolutePath().toString()) .collect(Collectors.joining("|")); List command = List.of( @@ -66,16 +66,16 @@ public class StreamActionsService { "-ss", String.valueOf(trimOffset), "-t", String.valueOf(duration), "-c", "copy", - outputFile.getAbsolutePath() + outputFile.toAbsolutePath().toString() ); logger.info("Saving section with ({} segments) to '{}'", - segments.size(), outputFile.getAbsolutePath()); + segments.size(), outputFile); CommandRunner.run(command, line -> CommandRunner.setProgress(line, progressTracker, duration)); return CompletableFuture.completedFuture(outputFile); } catch (Exception e) { - logger.error("Failed to save section to '{}'", outputFile.getAbsolutePath(), e); + logger.error("Failed to save section to '{}'", outputFile, e); return CompletableFuture.failedFuture(e); } } diff --git a/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java b/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java index 1ee0da7..7e11508 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java @@ -5,8 +5,8 @@ import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.List; @Service @@ -14,15 +14,15 @@ public class ThumbnailService { private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class); @Async("ffmpegTaskExecutor") - public void createThumbnail(File inputFile, File outputFile, Float timeInVideo) throws IOException, InterruptedException { + public void createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) throws IOException, InterruptedException { logger.info("Creating thumbnail at {} seconds", timeInVideo); List command = List.of( "ffmpeg", "-ss", timeInVideo.toString(), - "-i", inputFile.getAbsolutePath(), + "-i", inputFile.toAbsolutePath().toString(), "-frames:v", "1", - outputFile.getAbsolutePath() + outputFile.toAbsolutePath().toString() ); CommandRunner.run(command); diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 7b02d27..7cd357c 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -1 +1,6 @@ -spring.docker.compose.file=docker-compose.dev.yml \ No newline at end of file +spring.docker.compose.file=docker-compose.dev.yml + +media.temp.inputs=./video/inputs +media.temp.outputs=./videos/outputs +media.users=${MEDIA_DATA} +media.streams=${STREAM_DATA} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 38aa1bd..65f2509 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,9 +3,6 @@ spring.application.name=vodSystem # VODs spring.servlet.multipart.max-file-size=2GB spring.servlet.multipart.max-request-size=2GB -storage.temp.inputs=videos/inputs/ -storage.temp.outputs=videos/outputs/ -storage.outputs=${MEDIA_DATA} # Database spring.datasource.url=${POSTGRES_URL} @@ -21,15 +18,17 @@ spring.sql.init.schema-locations=classpath:db/schema.sql spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.client.registration.google.scope=profile,email - jwt.secret.key=${JWT_SECRET_KEY} jwt.expiration=3600000 # Frontend frontend.url=${FRONTEND_URL} -# Stream HLS -storage.stream=${STREAM_DATA} +# Paths +media.temp.inputs=/tmp/inputs/ +media.temp.outputs=/tmp/outputs/ +media.users=/app/media/ +media.streams=/app/stream/ ## Server Configuration server.servlet.session.timeout=30m \ No newline at end of file -- 2.49.1 From 8b299dc4fac18d9448bec32c911c4aee97377ac7 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Tue, 23 Jun 2026 21:31:45 +0100 Subject: [PATCH 10/13] fix(directories): inputs absolute paths into db --- docker-compose.yml | 4 ++-- .../com/ddf/vodsystem/services/ClipService.java | 13 +++++++------ .../ddf/vodsystem/services/DirectoryService.java | 12 ++++++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8c87eba..7c498dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,8 @@ services: env_file: - .env volumes: - - ${MEDIA_DATA}:/app/data/media - - ${STREAM_DATA}:/stream + - ${MEDIA_DATA}:/app/media + - ${STREAM_DATA}:/app/stream ports: - "8080:8080" restart: unless-stopped diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index 0abe1ad..c99014f 100644 --- a/src/main/java/com/ddf/vodsystem/services/ClipService.java +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -160,9 +160,9 @@ public class ClipService { } String path = clip.getVideoPath(); - File file = new File(path); + Path file = directoryService.resolvePath(path); - if (!file.exists()) { + if (!Files.exists(file)) { throw new JobNotFound("Clip file not found"); } @@ -183,8 +183,9 @@ public class ClipService { } String path = clip.getThumbnailPath(); - File file = new File(path); - if (!file.exists()) { + Path file = directoryService.resolvePath(path); + + if (!Files.exists(file)) { throw new JobNotFound("Thumbnail file not found"); } @@ -213,8 +214,8 @@ public class ClipService { clip.setFps(options.getFps()); clip.setDuration(options.getDuration() - options.getStartPoint()); clip.setFileSize(options.getFileSize()); - clip.setVideoPath(videoPath.toString()); - clip.setThumbnailPath(thumbnailPath.toString()); + clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString()); + clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString()); return clipRepository.save(clip); } diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java index 0bbcc62..92cd20a 100644 --- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -77,6 +77,18 @@ public class DirectoryService { return streamFolder; } + public Path relativisePath(Path path) { + Path media = Path.of(usersDir).toAbsolutePath(); + Path newPath = path.toAbsolutePath(); + return media.relativize(newPath); + } + + public Path resolvePath(String path) { + Path media = Path.of(usersDir).toAbsolutePath(); + Path newPath = Path.of(path); + return media.resolve(newPath); + } + public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException { Files.createDirectories(path.getParent()); Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING); -- 2.49.1 From 755a18fc3a6e2c43310df17b34599553291b313a Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Tue, 23 Jun 2026 21:39:53 +0100 Subject: [PATCH 11/13] chore: update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e37d9bb..dcbc3ba 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ build/ ### Generated Files ### videos/ +video/ outputs/ generated -- 2.49.1 From 42294703ae5595dd4eb5af18f79aea082576e8d6 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 25 Jun 2026 20:21:48 +0100 Subject: [PATCH 12/13] chore(backend): remove LocalDateTime usage, replaced with Instant --- .../ddf/vodsystem/controllers/dto/ClipResponse.java | 4 ++-- src/main/java/com/ddf/vodsystem/entities/Clip.java | 4 ++-- src/main/java/com/ddf/vodsystem/entities/Stream.java | 8 ++++---- src/main/java/com/ddf/vodsystem/entities/User.java | 4 ++-- .../ddf/vodsystem/repositories/StreamRepository.java | 4 ++-- .../java/com/ddf/vodsystem/services/ClipService.java | 4 ++-- .../com/ddf/vodsystem/services/StreamService.java | 12 ++++++------ .../java/com/ddf/vodsystem/services/UserService.java | 4 ++-- .../vodsystem/services/media/CompressionService.java | 2 +- .../services/media/StreamActionsService.java | 1 + src/main/resources/application-local.properties | 2 +- src/main/resources/application.properties | 5 ++++- src/main/resources/db/schema.sql | 4 ++-- 13 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java index d2f26de..da2bb92 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java @@ -1,6 +1,6 @@ package com.ddf.vodsystem.controllers.dto; -import java.time.LocalDateTime; +import java.time.Instant; public record ClipResponse( Long id, @@ -8,5 +8,5 @@ public record ClipResponse( String title, String description, Float duration, - LocalDateTime createdAt + Instant createdAt ) {} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/entities/Clip.java b/src/main/java/com/ddf/vodsystem/entities/Clip.java index 924b0b5..eafa28e 100644 --- a/src/main/java/com/ddf/vodsystem/entities/Clip.java +++ b/src/main/java/com/ddf/vodsystem/entities/Clip.java @@ -5,7 +5,7 @@ import jakarta.persistence.*; import lombok.Data; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "clips") @@ -28,7 +28,7 @@ public class Clip { private String description; @Column(name = "created_at") - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "width", nullable = false) private Integer width; diff --git a/src/main/java/com/ddf/vodsystem/entities/Stream.java b/src/main/java/com/ddf/vodsystem/entities/Stream.java index 325b9c3..e91805b 100644 --- a/src/main/java/com/ddf/vodsystem/entities/Stream.java +++ b/src/main/java/com/ddf/vodsystem/entities/Stream.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; import lombok.Data; import com.fasterxml.jackson.annotation.JsonIgnore; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "streams") @@ -21,11 +21,11 @@ public class Stream { private User user; @Column(name = "start_time") - private LocalDateTime startDate; + private Instant startDate; @Column(name = "end_time") - private LocalDateTime endDate; + private Instant endDate; @Column(name = "last_seen") - private LocalDateTime lastSeen; + private Instant lastSeen; } diff --git a/src/main/java/com/ddf/vodsystem/entities/User.java b/src/main/java/com/ddf/vodsystem/entities/User.java index 1d7bf69..d486403 100644 --- a/src/main/java/com/ddf/vodsystem/entities/User.java +++ b/src/main/java/com/ddf/vodsystem/entities/User.java @@ -3,7 +3,7 @@ package com.ddf.vodsystem.entities; import jakarta.persistence.*; import lombok.Data; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "users") @@ -33,7 +33,7 @@ public class User { private Integer role; // 0: user, 1: admin @Column(name = "created_at") - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "stream_key") private String streamKey; diff --git a/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java index e1ce754..e577e36 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java @@ -5,7 +5,7 @@ import com.ddf.vodsystem.entities.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -13,5 +13,5 @@ import java.util.Optional; public interface StreamRepository extends JpaRepository { Optional findByUserAndEndDateIsNull(User user); List findByUser(User user); - List findByEndDateIsNullAndLastSeenBefore(LocalDateTime cutoff); + List findByEndDateIsNullAndLastSeenBefore(Instant cutoff); } diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index c99014f..83b1f93 100644 --- a/src/main/java/com/ddf/vodsystem/services/ClipService.java +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -8,7 +8,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; @@ -208,7 +208,7 @@ public class ClipService { clip.setUser(user); clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip"); clip.setDescription(options.getDescription() != null ? options.getDescription() : ""); - clip.setCreatedAt(LocalDateTime.now()); + clip.setCreatedAt(Instant.now()); clip.setWidth(options.getWidth()); clip.setHeight(options.getHeight()); clip.setFps(options.getFps()); diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index ca2f39a..4603511 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -12,7 +12,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.*; @Service @@ -37,7 +37,7 @@ public class StreamService { throw new AlreadyStreaming("User " + user.getUsername() + " is already streaming."); }); - LocalDateTime now = LocalDateTime.now(); + Instant now = Instant.now(); Stream stream = new Stream(); stream.setUser(user); stream.setStartDate(now); @@ -52,7 +52,7 @@ public class StreamService { .orElseThrow(() -> new IllegalStateException( "No active stream found for user " + user.getUsername())); - stream.setEndDate(LocalDateTime.now()); + stream.setEndDate(Instant.now()); streamRepository.saveAndFlush(stream); } @@ -63,7 +63,7 @@ public class StreamService { .orElseThrow(() -> new IllegalStateException( "No active stream found for user " + user.getUsername())); - stream.setLastSeen(LocalDateTime.now()); + stream.setLastSeen(Instant.now()); streamRepository.saveAndFlush(stream); } @@ -97,11 +97,11 @@ public class StreamService { @Scheduled(fixedDelay = 30_000) @Transactional public void endStaleStreams() { - LocalDateTime cutoff = LocalDateTime.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS); + Instant cutoff = Instant.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS); List stale = streamRepository.findByEndDateIsNullAndLastSeenBefore(cutoff); if (stale.isEmpty()) return; - LocalDateTime now = LocalDateTime.now(); + Instant now = Instant.now(); for (Stream stream : stale) { stream.setEndDate(now); logger.warn("Stream {} for user {} ended due to heartbeat timeout", stream.getId(), stream.getUser().getUsername()); diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index 51beeaf..ae5d28c 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -17,7 +17,7 @@ import org.springframework.stereotype.Service; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.SecureRandom; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Collections; import java.util.HexFormat; import java.util.Optional; @@ -77,7 +77,7 @@ public class UserService { if (existingUser.isEmpty()) { user.setRole(0); - user.setCreatedAt(LocalDateTime.now()); + user.setCreatedAt(Instant.now()); user.setStreamKey(generateStreamKey()); return userRepository.saveAndFlush(user); } diff --git a/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java index 13edcfb..883bf3c 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java @@ -38,7 +38,7 @@ public class CompressionService { outputFile, clipOptions ); - CommandOutput result = CommandRunner.run(command, line -> setProgress(line, progress, clipOptions.getDuration())); + CommandOutput result = CommandRunner.run(command, line -> CommandRunner.setProgress(line, progress, clipOptions.getDuration())); progress.markComplete(); return CompletableFuture.completedFuture(result); 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 cc196e6..201cc22 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -72,6 +72,7 @@ public class StreamActionsService { logger.info("Saving section with ({} segments) to '{}'", segments.size(), outputFile); CommandRunner.run(command, line -> CommandRunner.setProgress(line, progressTracker, duration)); + progressTracker.markComplete(); return CompletableFuture.completedFuture(outputFile); } catch (Exception e) { diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 7cd357c..ec2e735 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -1,6 +1,6 @@ spring.docker.compose.file=docker-compose.dev.yml -media.temp.inputs=./video/inputs +media.temp.inputs=./videos/inputs media.temp.outputs=./videos/outputs media.users=${MEDIA_DATA} media.streams=${STREAM_DATA} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 65f2509..9701c07 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -31,4 +31,7 @@ media.users=/app/media/ media.streams=/app/stream/ ## Server Configuration -server.servlet.session.timeout=30m \ No newline at end of file +server.servlet.session.timeout=30m + +# Serialize Instant as ISO-8601 UTC string (not epoch seconds) +spring.jackson.serialization.write-dates-as-timestamps=false \ No newline at end of file diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index d77fd70..2cc5bab 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS users ( name VARCHAR(100) NOT NULL, profile_picture_url VARCHAR(255), role INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT NOW(), stream_key VARCHAR(64) NOT NULL UNIQUE ); @@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS clips ( user_id BIGINT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT NOW(), width INTEGER NOT NULL, height INTEGER NOT NULL, fps FLOAT NOT NULL, -- 2.49.1 From 0e13e44febca16ebf5393c61cb61735f627581b6 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 25 Jun 2026 20:22:44 +0100 Subject: [PATCH 13/13] fix(tailwind): top-level gitignore cause tailwind css issues 20:22 Scoped the bare video entry in the root .gitignore to its intended path. Tailwind V4 respects all .gitignore files in the project when scanning for utility classes, so the bare entry was silently preventing components/video/* from being scanned and its classes from appearing in the computed CSS output. The bug was absent in Docker as the root .gitignore was never copied into the container. --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dcbc3ba..98511d4 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,7 @@ build/ .vscode/ ### Generated Files ### -videos/ -video/ +/videos outputs/ generated -- 2.49.1