docs(docstrings): add docstrings to public method interfaces (#25)
All checks were successful
CI / Maven unit tests (push) Successful in 18s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

- Resolves #18

Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
2026-08-24 16:02:04 +01:00
parent 38942776df
commit ec9a982fe9
31 changed files with 688 additions and 0 deletions

View File

@@ -24,6 +24,11 @@ public class ClipController {
this.clipService = clipService;
}
/**
* Lists all clips belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's clips as {@link ClipResponse} DTOs
*/
@GetMapping("")
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
List<Clip> clips = clipService.getClipsByUser();
@@ -39,6 +44,12 @@ public class ClipController {
);
}
/**
* Retrieves a single clip owned by the authenticated user.
*
* @param id the ID of the clip to retrieve
* @return {@code 200 OK} wrapping the clip as a {@link ClipResponse} DTO
*/
@GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Clip clip = clipService.getClipById(id);
@@ -52,6 +63,13 @@ public class ClipController {
);
}
/**
* Applies a partial update to a clip's metadata; null fields are left unchanged.
*
* @param id the ID of the clip to update
* @param updateFields the title and/or description to overwrite
* @return {@code 200 OK} wrapping the updated clip as a {@link ClipResponse} DTO
*/
@PatchMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
@RequestBody ClipUpdateRequest updateFields) {
@@ -63,6 +81,12 @@ public class ClipController {
);
}
/**
* Deletes a clip and its associated files.
*
* @param id the ID of the clip to delete
* @return {@code 200 OK} with a confirmation message
*/
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
clipService.deleteClip(id);
@@ -76,6 +100,13 @@ public class ClipController {
);
}
/**
* Streams the clip's video file inline, supporting HTTP range requests.
*
* @param id the ID of the clip to download
* @return {@code 200 OK} with the video resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's content length cannot be read
*/
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadClip(id);
@@ -93,6 +124,13 @@ public class ClipController {
.body(resource);
}
/**
* Streams the clip's thumbnail image with a public cache policy.
*
* @param id the ID of the clip whose thumbnail to download
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's last-modified time cannot be read
*/
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadThumbnail(id);

View File

@@ -18,6 +18,12 @@ public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private static final String ERROR = "error";
/**
* Handles malformed multipart requests, returning {@code 400 Bad Request}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler({ MultipartException.class })
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
logger.error("MultipartException: {}", ex.getMessage());
@@ -25,6 +31,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
/**
* Handles requests missing a required file part, returning {@code 400 Bad Request}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler({ MissingServletRequestPartException.class })
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
logger.error("MissingServletRequestPartException: {}", ex.getMessage());
@@ -32,6 +44,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
/**
* Handles unsupported request content types, returning {@code 415 Unsupported Media Type}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
@@ -39,6 +57,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response);
}
/**
* Handles invalid arguments, returning {@code 400 Bad Request} with the exception message.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
logger.error("IllegalArgumentException: {}", ex.getMessage());
@@ -46,6 +70,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
/**
* Handles lookups for a non-existent job, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(JobNotFound.class)
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
logger.error("JobNotFound: {}", ex.getMessage());
@@ -53,6 +83,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
/**
* Handles requests for a job that is still running, returning {@code 202 Accepted}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(JobNotFinished.class)
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
logger.error("JobNotFinished: {}", ex.getMessage());
@@ -60,6 +96,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
}
/**
* Handles ffmpeg processing failures, returning {@code 500 Internal Server Error}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(FFMPEGException.class)
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
logger.error("FFMPEGException: {}", ex.getMessage());
@@ -67,6 +109,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
/**
* Handles unauthenticated or unauthorized access, returning {@code 401 Unauthorized}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(NotAuthenticated.class)
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
logger.error("NotAuthenticated: {}", ex.getMessage());
@@ -74,6 +122,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
/**
* Handles an unrecognised stream key, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(KeyNotFound.class)
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
logger.error("KeyNotFound: {}", ex.getMessage());
@@ -81,6 +135,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
/**
* Handles an attempt to start a stream while one is already active, returning {@code 409 Conflict}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(AlreadyStreaming.class)
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
logger.error("AlreadyStreaming: {}", ex.getMessage());
@@ -88,6 +148,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
/**
* Handles file storage failures, returning {@code 500 Internal Server Error}.
*
* @param e the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(StorageException.class)
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
logger.error("Storage operation failed", e);
@@ -95,6 +161,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
/**
* Handles lookups for a non-existent clip, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(ClipNotFound.class)
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
logger.error("ClipNotFound: {}", ex.getMessage());
@@ -102,6 +174,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
/**
* Handles a clip whose media file exists but is not readable, returning {@code 503 Service Unavailable}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(ClipMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
@@ -109,6 +187,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
}
/**
* Handles lookups for a non-existent VoD, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(VodNotFound.class)
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
logger.error("VodNotFound: {}", ex.getMessage());
@@ -116,6 +200,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
/**
* Handles a VoD whose media file exists but is not readable, returning {@code 503 Service Unavailable}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(VodMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
@@ -123,6 +213,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
}
/**
* Handles actions that require an active stream when none exists, returning {@code 409 Conflict}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(NotStreaming.class)
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
logger.error("NotStreaming: {}", ex.getMessage());
@@ -130,6 +226,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
/**
* Handles lookups for a non-existent marker, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(MarkerNotFound.class)
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
logger.error("MarkerNotFound: {}", ex.getMessage());

View File

@@ -28,6 +28,13 @@ public class JobController {
this.jobRegistryService = jobRegistryService;
}
/**
* Retrieves the status and progress of an asynchronous job.
*
* @param uuid the UUID of the job to look up
* @return {@code 200 OK} wrapping the job's state as a {@link JobResponse} DTO
* @throws com.ddf.vodsystem.exceptions.JobNotFound if no job with {@code uuid} exists
*/
@GetMapping("/{uuid}")
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);
@@ -47,6 +54,15 @@ public class JobController {
));
}
/**
* Downloads the output file produced by a completed job.
*
* @param uuid the UUID of the job whose output to download
* @return {@code 200 OK} with the output file as a downloadable resource
* @throws com.ddf.vodsystem.exceptions.JobNotFound if no job with {@code uuid} exists
* @throws NotReadyException if the job has not yet produced a downloadable file
* @throws ClipNotFound if the job's output file no longer exists on disk
*/
@GetMapping("/{uuid}/download")
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);

View File

@@ -22,6 +22,11 @@ public class MarkerController {
this.markerService = markerService;
}
/**
* Lists all markers belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's markers as {@link MarkerResponse} DTOs
*/
@GetMapping("")
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
List<MarkerResponse> markers = markerService.getUserMarkers().stream()
@@ -33,6 +38,12 @@ public class MarkerController {
);
}
/**
* Retrieves a single marker owned by the authenticated user.
*
* @param id the ID of the marker to retrieve
* @return {@code 200 OK} wrapping the marker as a {@link MarkerResponse} DTO
*/
@GetMapping("/{id}")
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
Marker marker = markerService.getMarkerById(id);
@@ -42,6 +53,12 @@ public class MarkerController {
);
}
/**
* Creates a marker at the current moment on the user's active stream.
*
* @param request the marker payload carrying the marker message
* @return {@code 200 OK} wrapping the created marker as a {@link MarkerResponse} DTO
*/
@PostMapping("")
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
Marker marker = markerService.create(request.message());
@@ -51,6 +68,12 @@ public class MarkerController {
);
}
/**
* Deletes a marker owned by the authenticated user.
*
* @param id the ID of the marker to delete
* @return {@code 200 OK} with a confirmation message
*/
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
markerService.deleteMarker(id);

View File

@@ -26,6 +26,14 @@ public class MediaController {
this.mediaService = mediaService;
}
/**
* Starts an asynchronous job to compress an uploaded video file.
*
* @param file the uploaded video to compress
* @param options trim window, resolution, fps, and target size for the output
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if saving the upload to temporary storage fails
*/
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponse<UUIDResponse>> compress(
@RequestParam("file")MultipartFile file,
@@ -40,6 +48,13 @@ public class MediaController {
}
/**
* Starts an asynchronous job to save a section of the user's stream between two timestamps as a VoD.
*
* @param saveSectionRequest the start/end timestamps and optional title/description
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if reading the stream segments fails
*/
@PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
@@ -55,6 +70,13 @@ public class MediaController {
));
}
/**
* Starts an asynchronous job to save the section of the user's stream between two markers as a VoD.
*
* @param saveSectionByMarkerRequest the start/end marker IDs and optional title/description
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if reading the stream segments fails
*/
@PostMapping("/save/markers")
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException {
@@ -70,6 +92,13 @@ public class MediaController {
));
}
/**
* Starts an asynchronous job to save the last few seconds of the user's stream as a clip.
*
* @param clipSectionRequest the clip duration in seconds and optional title/description
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if reading the stream segments fails
*/
@PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {

View File

@@ -24,6 +24,14 @@ public class StreamController {
this.streamService = streamService;
}
/**
* Starts a new stream for the user owning the given stream key.
* <p>
* Called by nginx via its {@code on_publish} RTMP callback when a broadcast begins.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} wrapping the newly started {@link Stream}
*/
// Called by nginx on_publish
@PostMapping("/start")
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
@@ -32,6 +40,14 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream));
}
/**
* Ends the active stream for the user owning the given stream key.
* <p>
* Called by nginx via its {@code on_publish_done} RTMP callback when a broadcast stops.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} confirming the stream was ended
*/
// Called by nginx on_publish_done
@PostMapping("/stop")
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
@@ -40,6 +56,14 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null));
}
/**
* Records a liveness heartbeat for the active stream, keeping it from being reaped as stale.
* <p>
* Called periodically by nginx via its {@code on_update} RTMP callback.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} confirming the heartbeat was recorded
*/
// Called by nginx on_update
@PostMapping("/heartbeat")
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
@@ -47,12 +71,24 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Heartbeat recorded", null));
}
/**
* Retrieves the stream history for the given user.
*
* @param userId the ID of the user whose stream history to retrieve; must be the authenticated user
* @return {@code 200 OK} wrapping the user's past and current {@link Stream}s
*/
@GetMapping("/history/{userId}")
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
List<Stream> history = streamService.getStreamHistory(userId);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
}
/**
* Reports whether the authenticated user is currently streaming.
*
* @return {@code 200 OK} wrapping a {@link StreamResponse} indicating the live status and, if live,
* the active stream's ID
*/
@GetMapping("/current")
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
Optional<Stream> stream = streamService.getActiveStream();

View File

@@ -27,6 +27,12 @@ public class UserController {
this.userService = userService;
}
/**
* Returns the currently authenticated user's profile.
*
* @return {@code 200 OK} wrapping the authenticated {@link User}
* @throws NotAuthenticated if no valid session is present
*/
@GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser();
@@ -40,6 +46,17 @@ public class UserController {
);
}
/**
* Authenticates a user from a Google ID token and issues a session JWT.
* <p>
* The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
* returned in the response body.
*
* @param token the payload carrying the Google ID token to verify
* @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
* @param response the response the session cookie is written to
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
*/
@PostMapping("/login")
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletRequest request,
@@ -61,6 +78,13 @@ public class UserController {
);
}
/**
* Logs the user out by clearing the session cookie.
*
* @param response the response the expired cookie is written to
* @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags
* @return {@code 200 OK} confirming logout
*/
@PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
HttpServletRequest request) {

View File

@@ -24,6 +24,11 @@ public class VodController {
this.vodService = vodService;
}
/**
* Lists all VoDs belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's VoDs as {@link VodResponse} DTOs
*/
@GetMapping("")
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
List<VodResponse> vods = vodService.getUserVods().stream()
@@ -33,12 +38,25 @@ public class VodController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
}
/**
* Retrieves a single VoD owned by the authenticated user.
*
* @param id the ID of the VoD to retrieve
* @return {@code 200 OK} wrapping the VoD as a {@link VodResponse} DTO
*/
@GetMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
VodResponse vod = convertToDTO(vodService.getVodById(id));
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
}
/**
* Applies a partial update to a VoD's metadata; null fields are left unchanged.
*
* @param id the ID of the VoD to update
* @param updateFields the title and/or description to overwrite
* @return {@code 200 OK} wrapping the updated VoD as a {@link VodResponse} DTO
*/
@PatchMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
@RequestBody VodUpdateRequest updateFields) {
@@ -46,6 +64,12 @@ public class VodController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
}
/**
* Deletes a VoD and its associated files.
*
* @param id the ID of the VoD to delete
* @return {@code 200 OK} with a confirmation message
*/
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
vodService.deleteVod(id);
@@ -53,6 +77,13 @@ public class VodController {
"Vod with ID " + id + " has been deleted"));
}
/**
* Streams the VoD's video file inline, supporting HTTP range requests.
*
* @param id the ID of the VoD to download
* @return {@code 200 OK} with the video resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's content length cannot be read
*/
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadVod(id);
@@ -70,6 +101,13 @@ public class VodController {
.body(resource);
}
/**
* Streams the VoD's thumbnail image with a public cache policy.
*
* @param id the ID of the VoD whose thumbnail to download
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's last-modified time cannot be read
*/
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadThumbnail(id);