perf(api): add HTTP range requests on media sources #9

Merged
dylan merged 9 commits from perf/incremental-video-loading into master 2026-07-25 11:06:40 +01:00
7 changed files with 72 additions and 54 deletions
Showing only changes of commit 09cc841dfd - Show all commits

View File

@@ -6,10 +6,7 @@ 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;
@@ -81,40 +78,23 @@ public class ClipController {
);
}
@GetMapping("/{id}/media")
public ResponseEntity<?> downloadClip(@PathVariable Long id, @RequestHeader HttpHeaders headers) throws IOException {
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadClip(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
List<HttpRange> 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))
.header(HttpHeaders.CONTENT_DISPOSITION,
String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource)
.orElse(MediaType.APPLICATION_OCTET_STREAM))
.contentLength(resource.contentLength())
.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<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = clipService.downloadThumbnail(id);

View File

@@ -102,6 +102,13 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(ClipMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Clip media is currently unavailable", null);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
}
@ExceptionHandler(VodNotFound.class)
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
logger.error("VodNotFound: {}", ex.getMessage());
@@ -109,6 +116,13 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(VodMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Vod media is currently unavailable", null);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
}
@ExceptionHandler(NotStreaming.class)
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
logger.error("NotStreaming: {}", ex.getMessage());

View File

@@ -6,10 +6,7 @@ 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;
@@ -58,40 +55,23 @@ public class VodController {
"Vod with ID " + id + " has been deleted"));
}
@GetMapping("/{id}/media")
public ResponseEntity<?> downloadVod(@PathVariable Long id, @RequestHeader HttpHeaders headers) throws IOException {
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadVod(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
List<HttpRange> 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))
.header(HttpHeaders.CONTENT_DISPOSITION,
String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource)
.orElse(MediaType.APPLICATION_OCTET_STREAM))
.contentLength(resource.contentLength())
.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<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = vodService.downloadThumbnail(id);

View File

@@ -0,0 +1,18 @@
package com.ddf.vodsystem.exceptions;
public class ClipMediaUnavailable extends RuntimeException {
private final Long clipId;
public ClipMediaUnavailable(Long clipId, String message) {
this(clipId, message, null);
}
public ClipMediaUnavailable(Long clipId, String message, Throwable cause) {
super(message, cause);
this.clipId = clipId;
}
public Long getClipId() {
return clipId;
}
}

View File

@@ -0,0 +1,18 @@
package com.ddf.vodsystem.exceptions;
public class VodMediaUnavailable extends RuntimeException {
private final Long vodId;
public VodMediaUnavailable(Long vodId, String message) {
this(vodId, message, null);
}
public VodMediaUnavailable(Long vodId, String message, Throwable cause) {
super(message, cause);
this.vodId = vodId;
}
public Long getVodId() {
return vodId;
}
}

View File

@@ -156,6 +156,10 @@ public class ClipService {
throw new ClipNotFound("Clip file not found");
}
if (!Files.isReadable(file)) {
throw new ClipMediaUnavailable(id, "Clip file is not readable");
}
return new FileSystemResource(file);
}

View File

@@ -139,6 +139,10 @@ public class VodService {
throw new VodNotFound("Vod file not found for ID: " + id);
}
if (!Files.isReadable(file)) {
throw new VodMediaUnavailable(id, "Vod file is not readable");
}
return new FileSystemResource(file);
}