From a87987d9ce5c72ee89a7b55a5c7d5fd563d5d89d Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 13 Jul 2026 23:17:35 +0100 Subject: [PATCH 1/9] perf(media): add range request for media endpoints --- .../vodsystem/controllers/ClipController.java | 26 ++++++++++++++++++- .../vodsystem/controllers/VodController.java | 26 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java index f4a25bb..68a3c70 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java @@ -6,12 +6,16 @@ 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.core.io.support.ResourceRegion; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRange; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.io.IOException; import java.util.List; @RestController @@ -78,19 +82,39 @@ public class ClipController { } @GetMapping("/{id}/media") - public ResponseEntity downloadClip(@PathVariable Long id) { + public ResponseEntity downloadClip(@PathVariable Long id, @RequestHeader HttpHeaders headers) throws IOException { Resource resource = clipService.downloadClip(id); if (resource == null || !resource.exists()) { return ResponseEntity.notFound().build(); } + List ranges = headers.getRange(); + + if (!ranges.isEmpty()) { + ResourceRegion region = createResourceRegion(resource, ranges.getFirst()); + + return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) + .header(HttpHeaders.ACCEPT_RANGES, "bytes") + .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) + .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) + .body(region); + } + return ResponseEntity.ok() + .header(HttpHeaders.ACCEPT_RANGES, "bytes") .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) .body(resource); } + private ResourceRegion createResourceRegion(Resource resource, HttpRange range) throws IOException { + long contentLength = resource.contentLength(); + long start = range.getRangeStart(contentLength); + long end = range.getRangeEnd(contentLength); + return new ResourceRegion(resource, start, end - start + 1); + } + @GetMapping("/{id}/thumbnail") public ResponseEntity downloadThumbnail(@PathVariable Long id) { Resource resource = clipService.downloadThumbnail(id); diff --git a/src/main/java/com/ddf/vodsystem/controllers/VodController.java b/src/main/java/com/ddf/vodsystem/controllers/VodController.java index aefa55a..460ffbd 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/VodController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/VodController.java @@ -6,12 +6,16 @@ import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.entities.Vod; import com.ddf.vodsystem.services.VodService; import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourceRegion; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRange; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.io.IOException; import java.util.List; @RestController @@ -55,19 +59,39 @@ public class VodController { } @GetMapping("/{id}/media") - public ResponseEntity downloadVod(@PathVariable Long id) { + public ResponseEntity downloadVod(@PathVariable Long id, @RequestHeader HttpHeaders headers) throws IOException { Resource resource = vodService.downloadVod(id); if (resource == null || !resource.exists()) { return ResponseEntity.notFound().build(); } + List ranges = headers.getRange(); + + if (!ranges.isEmpty()) { + ResourceRegion region = createResourceRegion(resource, ranges.getFirst()); + + return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) + .header(HttpHeaders.ACCEPT_RANGES, "bytes") + .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) + .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) + .body(region); + } + return ResponseEntity.ok() + .header(HttpHeaders.ACCEPT_RANGES, "bytes") .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) .body(resource); } + private ResourceRegion createResourceRegion(Resource resource, HttpRange range) throws IOException { + long contentLength = resource.contentLength(); + long start = range.getRangeStart(contentLength); + long end = range.getRangeEnd(contentLength); + return new ResourceRegion(resource, start, end - start + 1); + } + @GetMapping("/{id}/thumbnail") public ResponseEntity downloadThumbnail(@PathVariable Long id) { Resource resource = vodService.downloadThumbnail(id); -- 2.49.1 From 76b30214ae7e330642e47b002ed856c892200ac6 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 16 Jul 2026 21:30:58 +0100 Subject: [PATCH 2/9] fix(dirs): add defensive ownership check --- .../vodsystem/services/DirectoryService.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java index 92cd20a..537b9aa 100644 --- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -10,6 +10,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.stream.Stream; import java.nio.file.StandardCopyOption; @@ -129,11 +130,29 @@ public class DirectoryService { } } + private void checkDirPermissions() { + List dirs = List.of( + Path.of(tempInputsDir), + Path.of(tempOutputsDir), + Path.of(usersDir) + ); + + List bad = dirs.stream() + .filter(p -> !Files.isDirectory(p) || !Files.isReadable(p) || !Files.isWritable(p)) + .toList(); + + if (!bad.isEmpty()) { + throw new IllegalStateException("Unusable directories: " + bad); + } + } + @PostConstruct public void createDirectoriesIfNotExist() throws IOException { Files.createDirectories(Path.of(tempInputsDir)); Files.createDirectories(Path.of(tempOutputsDir)); Files.createDirectories(Path.of(usersDir)); + + checkDirPermissions(); } @Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE) -- 2.49.1 From 40c7a6bae59494f6cbec25e932292cdfe55353df Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 16 Jul 2026 22:17:36 +0100 Subject: [PATCH 3/9] fix(api): remove detailed exception message from API response --- .../java/com/ddf/vodsystem/controllers/MediaController.java | 2 +- src/main/java/com/ddf/vodsystem/services/MediaService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/MediaController.java b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java index 77ff18e..f1591c0 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/MediaController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java @@ -30,7 +30,7 @@ public class MediaController { public ResponseEntity> compress( @RequestParam("file")MultipartFile file, @Valid @ModelAttribute ClipOptions options - ) throws IOException, InterruptedException { + ) throws IOException { Job job = mediaService.compress(file, options); return ResponseEntity.ok(new APIResponse<>( SUCCESS, diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java index 2c6b43b..56f3ba1 100644 --- a/src/main/java/com/ddf/vodsystem/services/MediaService.java +++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java @@ -93,7 +93,7 @@ public class MediaService { ); }).exceptionally(ex -> { job.setState(JobState.FAILED); - job.setErrorOutput(ex.getMessage()); + job.setErrorOutput("Compression job failed"); logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage()); return null; }); -- 2.49.1 From 1930c556ef6507a641b19af45ea7ae400d0c6ec9 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 23 Jul 2026 19:40:00 +0100 Subject: [PATCH 4/9] docs(deployment): add note about rootless docker --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7f7e408..897ba0b 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ docker compose -f docker-compose.dev.yml up Then run the backend and frontend separately in your usual workflow. +- **Important**: Rootless docker should be used to avoid permission conflicts between the RTMP container and the Spring Boot host + --- ## API Reference -- 2.49.1 From a003f773c3dd913beba42a58996f6e70e5a4aaf0 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 23 Jul 2026 21:09:17 +0100 Subject: [PATCH 5/9] refactor(video): combine vod-player & video-player in frontend --- frontend/src/App.tsx | 9 +- .../{VideoPlayer.tsx => MediaPlayer.tsx} | 45 ++- frontend/src/pages/VodPlayer.tsx | 311 ------------------ 3 files changed, 34 insertions(+), 331 deletions(-) rename frontend/src/pages/{VideoPlayer.tsx => MediaPlayer.tsx} (93%) delete mode 100644 frontend/src/pages/VodPlayer.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34ae6cc..da08c32 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,8 +7,9 @@ import Home from './pages/Home'; import {useEffect} from "react"; import MyClips from './pages/MyClips'; import MyVods from './pages/MyVods'; -import VideoPlayer from "./pages/VideoPlayer.tsx"; -import VodPlayer from "./pages/VodPlayer.tsx"; +import MediaPlayer from "./pages/MediaPlayer.tsx"; +import { getVodById, getVodBlob } from "./utils/api/vods.ts"; +import { getClipById, getVideoBlob } from "./utils/api/clips.ts"; import EditClip from "./pages/EditClip.tsx"; import EditVod from "./pages/EditVod.tsx"; import Profile from "./pages/Profile.tsx"; @@ -31,8 +32,8 @@ function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/VideoPlayer.tsx b/frontend/src/pages/MediaPlayer.tsx similarity index 93% rename from frontend/src/pages/VideoPlayer.tsx rename to frontend/src/pages/MediaPlayer.tsx index 436edb4..ff31ced 100644 --- a/frontend/src/pages/VideoPlayer.tsx +++ b/frontend/src/pages/MediaPlayer.tsx @@ -1,13 +1,26 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react"; -import type { Clip } from "../utils/types"; import { AuthError } from "../utils/api/client.ts"; -import { getClipById, getVideoBlob } from "../utils/api/clips.ts"; import Box from "../components/Box.tsx"; import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts"; -const VideoPlayer = () => { +// Both VoDs and clips share the same on-screen shape. +type MediaItem = { + title: string, + createdAt: string, +}; + +type MediaPlayerProps = { + /** Human-readable name for the media, e.g. "VoD" or "clip". Used in messages. */ + noun: string, + /** Fetches the raw video blob for the given id. */ + fetchBlob: (id: string) => Promise, + /** Fetches the media metadata for the given id. */ + fetchDetails: (id: string) => Promise, +}; + +const MediaPlayer = ({ noun, fetchBlob, fetchDetails }: MediaPlayerProps) => { const { id } = useParams(); const navigate = useNavigate(); @@ -20,7 +33,7 @@ const VideoPlayer = () => { const [videoUrl, setVideoUrl] = useState(undefined); const [error, setError] = useState(null); const [notAuthenticated, setNotAuthenticated] = useState(false); - const [clip, setClip] = useState(null); + const [item, setItem] = useState(null); const [timeAgo, setTimeAgo] = useState(""); // Playback @@ -41,27 +54,27 @@ const VideoPlayer = () => { // ── Data loading ──────────────────────────────────────────────────────── useEffect(() => { - if (!id) { setError("Clip ID is required."); return; } + if (!id) { setError(`${noun} ID is required.`); return; } - getVideoBlob(id) + fetchBlob(id) .then((blob) => setVideoUrl(URL.createObjectURL(blob))) .catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); }); - getClipById(id) - .then(setClip) + fetchDetails(id) + .then(setItem) .catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); - else setError("Failed to load clip details."); + else setError(`Failed to load ${noun.toLowerCase()} details.`); }); - }, [id]); + }, [id, noun, fetchBlob, fetchDetails]); useEffect(() => { - if (!clip?.createdAt) return; - const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt))); + if (!item?.createdAt) return; + const update = () => setTimeAgo(dateToTimeAgo(stringToDate(item.createdAt))); update(); const interval = setInterval(update, 1000); return () => clearInterval(interval); - }, [clip]); + }, [item]); // ── Controls visibility ───────────────────────────────────────────────── const scheduleHide = useCallback(() => { @@ -192,7 +205,7 @@ const VideoPlayer = () => {

Not authenticated

-

You don't have access to this clip.

+

You don't have access to this {noun.toLowerCase()}.

- - ); - } - - return ( -
- - -
isPlaying && setControlsVisible(false)} - onClick={togglePlay} - style={{ cursor: controlsVisible ? 'default' : 'none' }} - > -