docs(docstrings): add docstrings to public method interfaces (#25)
- Resolves #18 Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
@@ -10,6 +10,11 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
@EnableAsync
|
@EnableAsync
|
||||||
public class VodSystemApplication {
|
public class VodSystemApplication {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application entry point; boots the Spring context.
|
||||||
|
*
|
||||||
|
* @param args command-line arguments passed to Spring Boot
|
||||||
|
*/
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(VodSystemApplication.class, args);
|
SpringApplication.run(VodSystemApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import java.util.concurrent.Executor;
|
|||||||
public class AsyncConfig implements AsyncConfigurer {
|
public class AsyncConfig implements AsyncConfigurer {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
|
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the bounded thread pool that runs ffmpeg-backed {@code @Async} media tasks.
|
||||||
|
*
|
||||||
|
* @return the configured {@link Executor} registered as {@code ffmpegTaskExecutor}
|
||||||
|
*/
|
||||||
@Bean(name = "ffmpegTaskExecutor")
|
@Bean(name = "ffmpegTaskExecutor")
|
||||||
public Executor taskExecutor() {
|
public Executor taskExecutor() {
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
@@ -25,6 +30,11 @@ public class AsyncConfig implements AsyncConfigurer {
|
|||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies the handler that logs uncaught exceptions from void-returning {@code @Async} methods.
|
||||||
|
*
|
||||||
|
* @return the async uncaught-exception handler
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
||||||
return (ex, method, params) ->
|
return (ex, method, params) ->
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ public class CorsConfig {
|
|||||||
@Value("${frontend.url}")
|
@Value("${frontend.url}")
|
||||||
private String frontendUrl;
|
private String frontendUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures CORS for the API, allowing credentialed requests from the configured frontend origin.
|
||||||
|
*
|
||||||
|
* @return a {@link WebMvcConfigurer} that registers the CORS mappings for {@code /api/v1/**}
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public WebMvcConfigurer corsConfigurer() {
|
public WebMvcConfigurer corsConfigurer() {
|
||||||
return new WebMvcConfigurer() {
|
return new WebMvcConfigurer() {
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ public class SecurityConfig {
|
|||||||
this.jwtFilter = jwtFilter;
|
this.jwtFilter = jwtFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
|
||||||
|
* installed, and per-endpoint authorization rules.
|
||||||
|
*
|
||||||
|
* @param http the HTTP security builder to configure
|
||||||
|
* @return the built {@link SecurityFilterChain}
|
||||||
|
* @throws Exception if the security configuration cannot be built
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ public class ClipController {
|
|||||||
this.clipService = clipService;
|
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("")
|
@GetMapping("")
|
||||||
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
|
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
|
||||||
List<Clip> clips = clipService.getClipsByUser();
|
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}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
|
||||||
Clip clip = clipService.getClipById(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}")
|
@PatchMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
|
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
|
||||||
@RequestBody ClipUpdateRequest updateFields) {
|
@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}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
|
||||||
clipService.deleteClip(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)
|
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||||
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
|
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = clipService.downloadClip(id);
|
Resource resource = clipService.downloadClip(id);
|
||||||
@@ -93,6 +124,13 @@ public class ClipController {
|
|||||||
.body(resource);
|
.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")
|
@GetMapping("/{id}/thumbnail")
|
||||||
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
|
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = clipService.downloadThumbnail(id);
|
Resource resource = clipService.downloadThumbnail(id);
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ public class GlobalExceptionHandler {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
private static final String ERROR = "error";
|
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 })
|
@ExceptionHandler({ MultipartException.class })
|
||||||
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
|
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
|
||||||
logger.error("MultipartException: {}", ex.getMessage());
|
logger.error("MultipartException: {}", ex.getMessage());
|
||||||
@@ -25,6 +31,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
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 })
|
@ExceptionHandler({ MissingServletRequestPartException.class })
|
||||||
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
|
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
|
||||||
logger.error("MissingServletRequestPartException: {}", ex.getMessage());
|
logger.error("MissingServletRequestPartException: {}", ex.getMessage());
|
||||||
@@ -32,6 +44,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
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 })
|
@ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
|
||||||
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
|
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
|
||||||
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
|
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
|
||||||
@@ -39,6 +57,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response);
|
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)
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
|
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||||
logger.error("IllegalArgumentException: {}", ex.getMessage());
|
logger.error("IllegalArgumentException: {}", ex.getMessage());
|
||||||
@@ -46,6 +70,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
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)
|
@ExceptionHandler(JobNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
|
||||||
logger.error("JobNotFound: {}", ex.getMessage());
|
logger.error("JobNotFound: {}", ex.getMessage());
|
||||||
@@ -53,6 +83,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
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)
|
@ExceptionHandler(JobNotFinished.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
|
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
|
||||||
logger.error("JobNotFinished: {}", ex.getMessage());
|
logger.error("JobNotFinished: {}", ex.getMessage());
|
||||||
@@ -60,6 +96,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
|
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)
|
@ExceptionHandler(FFMPEGException.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
|
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
|
||||||
logger.error("FFMPEGException: {}", ex.getMessage());
|
logger.error("FFMPEGException: {}", ex.getMessage());
|
||||||
@@ -67,6 +109,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
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)
|
@ExceptionHandler(NotAuthenticated.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
|
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
|
||||||
logger.error("NotAuthenticated: {}", ex.getMessage());
|
logger.error("NotAuthenticated: {}", ex.getMessage());
|
||||||
@@ -74,6 +122,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
|
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)
|
@ExceptionHandler(KeyNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
|
||||||
logger.error("KeyNotFound: {}", ex.getMessage());
|
logger.error("KeyNotFound: {}", ex.getMessage());
|
||||||
@@ -81,6 +135,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
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)
|
@ExceptionHandler(AlreadyStreaming.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
|
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
|
||||||
logger.error("AlreadyStreaming: {}", ex.getMessage());
|
logger.error("AlreadyStreaming: {}", ex.getMessage());
|
||||||
@@ -88,6 +148,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
|
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)
|
@ExceptionHandler(StorageException.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
|
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
|
||||||
logger.error("Storage operation failed", e);
|
logger.error("Storage operation failed", e);
|
||||||
@@ -95,6 +161,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
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)
|
@ExceptionHandler(ClipNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
|
||||||
logger.error("ClipNotFound: {}", ex.getMessage());
|
logger.error("ClipNotFound: {}", ex.getMessage());
|
||||||
@@ -102,6 +174,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
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)
|
@ExceptionHandler(ClipMediaUnavailable.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
|
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
|
||||||
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
|
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
|
||||||
@@ -109,6 +187,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
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)
|
@ExceptionHandler(VodNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
|
||||||
logger.error("VodNotFound: {}", ex.getMessage());
|
logger.error("VodNotFound: {}", ex.getMessage());
|
||||||
@@ -116,6 +200,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
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)
|
@ExceptionHandler(VodMediaUnavailable.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
|
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
|
||||||
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
|
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
|
||||||
@@ -123,6 +213,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
|
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)
|
@ExceptionHandler(NotStreaming.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
|
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
|
||||||
logger.error("NotStreaming: {}", ex.getMessage());
|
logger.error("NotStreaming: {}", ex.getMessage());
|
||||||
@@ -130,6 +226,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
|
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)
|
@ExceptionHandler(MarkerNotFound.class)
|
||||||
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
|
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
|
||||||
logger.error("MarkerNotFound: {}", ex.getMessage());
|
logger.error("MarkerNotFound: {}", ex.getMessage());
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public class JobController {
|
|||||||
this.jobRegistryService = jobRegistryService;
|
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}")
|
@GetMapping("/{uuid}")
|
||||||
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
|
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
|
||||||
Job job = jobRegistryService.getJob(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")
|
@GetMapping("/{uuid}/download")
|
||||||
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
|
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
|
||||||
Job job = jobRegistryService.getJob(uuid);
|
Job job = jobRegistryService.getJob(uuid);
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ public class MarkerController {
|
|||||||
this.markerService = markerService;
|
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("")
|
@GetMapping("")
|
||||||
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
|
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
|
||||||
List<MarkerResponse> markers = markerService.getUserMarkers().stream()
|
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}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
|
||||||
Marker marker = markerService.getMarkerById(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("")
|
@PostMapping("")
|
||||||
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
|
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
|
||||||
Marker marker = markerService.create(request.message());
|
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}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
|
||||||
markerService.deleteMarker(id);
|
markerService.deleteMarker(id);
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ public class MediaController {
|
|||||||
this.mediaService = mediaService;
|
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)
|
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
||||||
@RequestParam("file")MultipartFile file,
|
@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")
|
@PostMapping("/save")
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> save(
|
public ResponseEntity<APIResponse<UUIDResponse>> save(
|
||||||
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
|
@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")
|
@PostMapping("/save/markers")
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
|
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
|
||||||
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException {
|
@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")
|
@PostMapping("/clip")
|
||||||
public ResponseEntity<APIResponse<UUIDResponse>> clip(
|
public ResponseEntity<APIResponse<UUIDResponse>> clip(
|
||||||
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
|
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ public class StreamController {
|
|||||||
this.streamService = streamService;
|
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
|
// Called by nginx on_publish
|
||||||
@PostMapping("/start")
|
@PostMapping("/start")
|
||||||
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
|
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));
|
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
|
// Called by nginx on_publish_done
|
||||||
@PostMapping("/stop")
|
@PostMapping("/stop")
|
||||||
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
|
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));
|
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
|
// Called by nginx on_update
|
||||||
@PostMapping("/heartbeat")
|
@PostMapping("/heartbeat")
|
||||||
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
|
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));
|
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}")
|
@GetMapping("/history/{userId}")
|
||||||
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
|
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
|
||||||
List<Stream> history = streamService.getStreamHistory(userId);
|
List<Stream> history = streamService.getStreamHistory(userId);
|
||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
|
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")
|
@GetMapping("/current")
|
||||||
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
|
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
|
||||||
Optional<Stream> stream = streamService.getActiveStream();
|
Optional<Stream> stream = streamService.getActiveStream();
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ public class UserController {
|
|||||||
this.userService = userService;
|
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")
|
@GetMapping("/me")
|
||||||
public ResponseEntity<APIResponse<User>> user() {
|
public ResponseEntity<APIResponse<User>> user() {
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
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")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
||||||
HttpServletRequest request,
|
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")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
|
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ public class VodController {
|
|||||||
this.vodService = vodService;
|
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("")
|
@GetMapping("")
|
||||||
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
|
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
|
||||||
List<VodResponse> vods = vodService.getUserVods().stream()
|
List<VodResponse> vods = vodService.getUserVods().stream()
|
||||||
@@ -33,12 +38,25 @@ public class VodController {
|
|||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
|
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}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
|
||||||
VodResponse vod = convertToDTO(vodService.getVodById(id));
|
VodResponse vod = convertToDTO(vodService.getVodById(id));
|
||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
|
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}")
|
@PatchMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
|
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
|
||||||
@RequestBody VodUpdateRequest updateFields) {
|
@RequestBody VodUpdateRequest updateFields) {
|
||||||
@@ -46,6 +64,12 @@ public class VodController {
|
|||||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
|
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}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
|
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
|
||||||
vodService.deleteVod(id);
|
vodService.deleteVod(id);
|
||||||
@@ -53,6 +77,13 @@ public class VodController {
|
|||||||
"Vod with ID " + id + " has been deleted"));
|
"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)
|
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
|
||||||
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
|
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = vodService.downloadVod(id);
|
Resource resource = vodService.downloadVod(id);
|
||||||
@@ -70,6 +101,13 @@ public class VodController {
|
|||||||
.body(resource);
|
.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")
|
@GetMapping("/{id}/thumbnail")
|
||||||
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
|
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
|
||||||
Resource resource = vodService.downloadThumbnail(id);
|
Resource resource = vodService.downloadThumbnail(id);
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ public class CommandOutput {
|
|||||||
private List<String> output = new ArrayList<>();
|
private List<String> output = new ArrayList<>();
|
||||||
private int exitCode;
|
private int exitCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a line to the captured command output.
|
||||||
|
*
|
||||||
|
* @param line the output line to append
|
||||||
|
*/
|
||||||
public void addLine(String line) {
|
public void addLine(String line) {
|
||||||
output.add(line);
|
output.add(line);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,30 @@ public class ProgressTracker {
|
|||||||
private float progress = 0.0f;
|
private float progress = 0.0f;
|
||||||
private boolean isComplete = false;
|
private boolean isComplete = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current progress.
|
||||||
|
*
|
||||||
|
* @return the progress as a fraction in {@code [0, 1]}
|
||||||
|
*/
|
||||||
public synchronized float getProgress() {
|
public synchronized float getProgress() {
|
||||||
return progress;
|
return progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the tracked task has been marked complete.
|
||||||
|
*
|
||||||
|
* @return {@code true} if complete
|
||||||
|
*/
|
||||||
public synchronized boolean isComplete() {
|
public synchronized boolean isComplete() {
|
||||||
return isComplete;
|
return isComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the current progress.
|
||||||
|
*
|
||||||
|
* @param newProgress the progress as a fraction in {@code [0, 1]}
|
||||||
|
* @throws IllegalArgumentException if {@code newProgress} is outside {@code [0, 1]}
|
||||||
|
*/
|
||||||
public synchronized void setProgress(float newProgress) {
|
public synchronized void setProgress(float newProgress) {
|
||||||
if (newProgress < 0 || newProgress > 1) {
|
if (newProgress < 0 || newProgress > 1) {
|
||||||
throw new IllegalArgumentException("Progress must be between 0 and 1");
|
throw new IllegalArgumentException("Progress must be between 0 and 1");
|
||||||
@@ -19,10 +35,16 @@ public class ProgressTracker {
|
|||||||
this.progress = newProgress;
|
this.progress = newProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the tracked task as complete.
|
||||||
|
*/
|
||||||
public synchronized void markComplete() {
|
public synchronized void markComplete() {
|
||||||
this.isComplete = true;
|
this.isComplete = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets progress to zero and clears the complete flag.
|
||||||
|
*/
|
||||||
public synchronized void reset() {
|
public synchronized void reset() {
|
||||||
this.progress = 0.0f;
|
this.progress = 0.0f;
|
||||||
this.isComplete = false;
|
this.isComplete = false;
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ public class ClipMediaUnavailable extends RuntimeException {
|
|||||||
this.clipId = clipId;
|
this.clipId = clipId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ID of the clip whose media was unavailable.
|
||||||
|
*
|
||||||
|
* @return the affected clip's ID
|
||||||
|
*/
|
||||||
public Long getClipId() {
|
public Long getClipId() {
|
||||||
return clipId;
|
return clipId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ public class VodMediaUnavailable extends RuntimeException {
|
|||||||
this.vodId = vodId;
|
this.vodId = vodId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ID of the VoD whose media was unavailable.
|
||||||
|
*
|
||||||
|
* @return the affected VoD's ID
|
||||||
|
*/
|
||||||
public Long getVodId() {
|
public Long getVodId() {
|
||||||
return vodId;
|
return vodId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import java.util.List;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface ClipRepository extends JpaRepository<Clip, Long> {
|
public interface ClipRepository extends JpaRepository<Clip, Long> {
|
||||||
|
/**
|
||||||
|
* Finds all clips owned by the given user.
|
||||||
|
*
|
||||||
|
* @param user the owning user
|
||||||
|
* @return the user's clips
|
||||||
|
*/
|
||||||
@Query("SELECT c FROM Clip c WHERE c.user = ?1")
|
@Query("SELECT c FROM Clip c WHERE c.user = ?1")
|
||||||
List<Clip> findByUser(User user);
|
List<Clip> findByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ import java.util.List;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface MarkerRepository extends JpaRepository<Marker, Long> {
|
public interface MarkerRepository extends JpaRepository<Marker, Long> {
|
||||||
|
/**
|
||||||
|
* Finds all markers owned by the given user.
|
||||||
|
*
|
||||||
|
* @param user the owning user
|
||||||
|
* @return the user's markers
|
||||||
|
*/
|
||||||
@Query("SELECT m FROM Marker m WHERE m.user = ?1")
|
@Query("SELECT m FROM Marker m WHERE m.user = ?1")
|
||||||
List<Marker> findByUser(User user);
|
List<Marker> findByUser(User user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all markers timestamped at or before the given instant.
|
||||||
|
*
|
||||||
|
* @param time the cutoff instant (inclusive)
|
||||||
|
* @return markers at or before {@code time}
|
||||||
|
*/
|
||||||
@Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1")
|
@Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1")
|
||||||
List<Marker> findAllBefore(Instant time);
|
List<Marker> findAllBefore(Instant time);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,27 @@ import java.util.Optional;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface StreamRepository extends JpaRepository<Stream, Long> {
|
public interface StreamRepository extends JpaRepository<Stream, Long> {
|
||||||
|
/**
|
||||||
|
* Finds the user's active (not-yet-ended) stream, if any.
|
||||||
|
*
|
||||||
|
* @param user the owning user
|
||||||
|
* @return the active stream, or empty if none
|
||||||
|
*/
|
||||||
Optional<Stream> findByUserAndEndDateIsNull(User user);
|
Optional<Stream> findByUserAndEndDateIsNull(User user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all streams belonging to the given user.
|
||||||
|
*
|
||||||
|
* @param user the owning user
|
||||||
|
* @return the user's streams
|
||||||
|
*/
|
||||||
List<Stream> findByUser(User user);
|
List<Stream> findByUser(User user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds active streams whose last heartbeat predates the given cutoff, i.e. stale streams.
|
||||||
|
*
|
||||||
|
* @param cutoff the last-seen cutoff; streams last seen before this are returned
|
||||||
|
* @return the stale active streams
|
||||||
|
*/
|
||||||
List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff);
|
List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,21 @@ import java.util.Optional;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
/**
|
||||||
|
* Finds a user by their Google account ID.
|
||||||
|
*
|
||||||
|
* @param googleId the Google subject identifier
|
||||||
|
* @return the matching user, or empty if none
|
||||||
|
*/
|
||||||
@Query("SELECT u FROM User u WHERE u.googleId = ?1")
|
@Query("SELECT u FROM User u WHERE u.googleId = ?1")
|
||||||
Optional<User> findByGoogleId(String googleId);
|
Optional<User> findByGoogleId(String googleId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a user by their stream key.
|
||||||
|
*
|
||||||
|
* @param streamKey the stream key to match
|
||||||
|
* @return the matching user, or empty if none
|
||||||
|
*/
|
||||||
@Query("SELECT u FROM User u WHERE u.streamKey = ?1")
|
@Query("SELECT u FROM User u WHERE u.streamKey = ?1")
|
||||||
Optional<User> findByStreamKey(String streamKey);
|
Optional<User> findByStreamKey(String streamKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import java.util.List;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface VodRepository extends JpaRepository<Vod, Long>
|
public interface VodRepository extends JpaRepository<Vod, Long>
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Finds all VoDs owned by the given user.
|
||||||
|
*
|
||||||
|
* @param user the owning user
|
||||||
|
* @return the user's VoDs
|
||||||
|
*/
|
||||||
@Query("SELECT v FROM Vod v WHERE v.user = ?1")
|
@Query("SELECT v FROM Vod v WHERE v.user = ?1")
|
||||||
List<Vod> findByUser(User user);
|
List<Vod> findByUser(User user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ public class JwtService {
|
|||||||
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
|
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a signed JWT carrying the given user ID, valid for the configured expiry.
|
||||||
|
*
|
||||||
|
* @param userId the user ID to embed as a claim
|
||||||
|
* @return the signed JWT string
|
||||||
|
*/
|
||||||
public String generateToken(Long userId) {
|
public String generateToken(Long userId) {
|
||||||
return JWT.create()
|
return JWT.create()
|
||||||
.withClaim(USER_ID_CLAIM, userId)
|
.withClaim(USER_ID_CLAIM, userId)
|
||||||
@@ -34,6 +40,12 @@ public class JwtService {
|
|||||||
.sign(algorithm);
|
.sign(algorithm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a JWT's signature, issuer, and expiry, returning the embedded user ID.
|
||||||
|
*
|
||||||
|
* @param token the JWT to validate
|
||||||
|
* @return the user ID claim if the token is valid and unexpired, otherwise {@code null}
|
||||||
|
*/
|
||||||
public Long validateTokenAndGetUserId(String token) {
|
public Long validateTokenAndGetUserId(String token) {
|
||||||
try {
|
try {
|
||||||
JWTVerifier verifier = JWT.require(algorithm)
|
JWTVerifier verifier = JWT.require(algorithm)
|
||||||
|
|||||||
@@ -40,61 +40,132 @@ public class DirectoryService {
|
|||||||
private static final String CLIPS_DIR_NAME = "clips";
|
private static final String CLIPS_DIR_NAME = "clips";
|
||||||
private static final String THUMBNAIL_DIR_NAME = "thumbnails";
|
private static final String THUMBNAIL_DIR_NAME = "thumbnails";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the directory for temporary upload inputs.
|
||||||
|
*
|
||||||
|
* @return the configured temporary input directory path
|
||||||
|
*/
|
||||||
public Path getTempInputDir() {
|
public Path getTempInputDir() {
|
||||||
return Path.of(tempInputsDir);
|
return Path.of(tempInputsDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the directory for temporary processing outputs.
|
||||||
|
*
|
||||||
|
* @return the configured temporary output directory path
|
||||||
|
*/
|
||||||
public Path getTempOutputDir() {
|
public Path getTempOutputDir() {
|
||||||
return Path.of(tempOutputsDir);
|
return Path.of(tempOutputsDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the given user's root media directory, creating it if necessary.
|
||||||
|
*
|
||||||
|
* @param userId the ID of the user
|
||||||
|
* @return the user's media directory path
|
||||||
|
* @throws IOException if the directory cannot be created
|
||||||
|
*/
|
||||||
public Path getUserDir(Long userId) throws IOException {
|
public Path getUserDir(Long userId) throws IOException {
|
||||||
Path userDir = Path.of(usersDir + File.separator + userId);
|
Path userDir = Path.of(usersDir + File.separator + userId);
|
||||||
Files.createDirectories(userDir);
|
Files.createDirectories(userDir);
|
||||||
return userDir;
|
return userDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the given user's VoDs directory, creating it if necessary.
|
||||||
|
*
|
||||||
|
* @param userId the ID of the user
|
||||||
|
* @return the user's VoDs directory path
|
||||||
|
* @throws IOException if the directory cannot be created
|
||||||
|
*/
|
||||||
public Path getVodsDir(Long userId) throws IOException {
|
public Path getVodsDir(Long userId) throws IOException {
|
||||||
Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME);
|
Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME);
|
||||||
Files.createDirectories(vodDir);
|
Files.createDirectories(vodDir);
|
||||||
return vodDir;
|
return vodDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the given user's clips directory, creating it if necessary.
|
||||||
|
*
|
||||||
|
* @param userId the ID of the user
|
||||||
|
* @return the user's clips directory path
|
||||||
|
* @throws IOException if the directory cannot be created
|
||||||
|
*/
|
||||||
public Path getClipsDir(Long userId) throws IOException {
|
public Path getClipsDir(Long userId) throws IOException {
|
||||||
Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME);
|
Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME);
|
||||||
Files.createDirectories(clipsDir);
|
Files.createDirectories(clipsDir);
|
||||||
return clipsDir;
|
return clipsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the given user's thumbnails directory, creating it if necessary.
|
||||||
|
*
|
||||||
|
* @param userId the ID of the user
|
||||||
|
* @return the user's thumbnails directory path
|
||||||
|
* @throws IOException if the directory cannot be created
|
||||||
|
*/
|
||||||
public Path getThumbnailsDir(Long userId) throws IOException {
|
public Path getThumbnailsDir(Long userId) throws IOException {
|
||||||
Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME);
|
Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME);
|
||||||
Files.createDirectories(thumbnailsDir);
|
Files.createDirectories(thumbnailsDir);
|
||||||
return thumbnailsDir;
|
return thumbnailsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the directory holding a stream's recorded HLS segments, creating it if necessary.
|
||||||
|
*
|
||||||
|
* @param streamKey the stream key naming the segment directory
|
||||||
|
* @return the stream's segment directory path
|
||||||
|
* @throws IOException if the directory cannot be created
|
||||||
|
*/
|
||||||
public Path getStreamDir(String streamKey) throws IOException {
|
public Path getStreamDir(String streamKey) throws IOException {
|
||||||
Path streamFolder = Path.of(streamsDir, streamKey);
|
Path streamFolder = Path.of(streamsDir, streamKey);
|
||||||
Files.createDirectories(streamFolder);
|
Files.createDirectories(streamFolder);
|
||||||
return streamFolder;
|
return streamFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an absolute path into one relative to the users media root, for storage in the database.
|
||||||
|
*
|
||||||
|
* @param path the absolute path to relativise
|
||||||
|
* @return the path relative to the users media directory
|
||||||
|
*/
|
||||||
public Path relativisePath(Path path) {
|
public Path relativisePath(Path path) {
|
||||||
Path media = Path.of(usersDir).toAbsolutePath();
|
Path media = Path.of(usersDir).toAbsolutePath();
|
||||||
Path newPath = path.toAbsolutePath();
|
Path newPath = path.toAbsolutePath();
|
||||||
return media.relativize(newPath);
|
return media.relativize(newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a stored relative path back into an absolute path under the users media root.
|
||||||
|
*
|
||||||
|
* @param path the relative path, as stored in the database
|
||||||
|
* @return the absolute path on disk
|
||||||
|
*/
|
||||||
public Path resolvePath(String path) {
|
public Path resolvePath(String path) {
|
||||||
Path media = Path.of(usersDir).toAbsolutePath();
|
Path media = Path.of(usersDir).toAbsolutePath();
|
||||||
Path newPath = Path.of(path);
|
Path newPath = Path.of(path);
|
||||||
return media.resolve(newPath);
|
return media.resolve(newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes an uploaded file to the given path, creating parent directories and overwriting any existing file.
|
||||||
|
*
|
||||||
|
* @param path the destination path
|
||||||
|
* @param multipartFile the uploaded file to save
|
||||||
|
* @throws IOException if writing the file fails
|
||||||
|
*/
|
||||||
public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException {
|
public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException {
|
||||||
Files.createDirectories(path.getParent());
|
Files.createDirectories(path.getParent());
|
||||||
Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a file, creating parent directories and overwriting any existing target.
|
||||||
|
*
|
||||||
|
* @param source the file to copy from
|
||||||
|
* @param target the destination path
|
||||||
|
* @throws IOException if the copy fails
|
||||||
|
*/
|
||||||
public void copyFile(Path source, Path target) throws IOException {
|
public void copyFile(Path source, Path target) throws IOException {
|
||||||
Files.createDirectories(target.getParent());
|
Files.createDirectories(target.getParent());
|
||||||
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
@@ -146,6 +217,12 @@ public class DirectoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the temporary and users media directories on startup and verifies they are usable.
|
||||||
|
*
|
||||||
|
* @throws IOException if a directory cannot be created
|
||||||
|
* @throws IllegalStateException if any required directory is not a readable, writable directory
|
||||||
|
*/
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void createDirectoriesIfNotExist() throws IOException {
|
public void createDirectoriesIfNotExist() throws IOException {
|
||||||
Files.createDirectories(Path.of(tempInputsDir));
|
Files.createDirectories(Path.of(tempInputsDir));
|
||||||
@@ -155,6 +232,13 @@ public class DirectoryService {
|
|||||||
checkDirPermissions();
|
checkDirPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled cleanup that deletes stale files from the temporary input and output directories.
|
||||||
|
* <p>
|
||||||
|
* Runs every 30 minutes; files older than 3 hours are removed.
|
||||||
|
*
|
||||||
|
* @throws IOException if listing a temporary directory fails
|
||||||
|
*/
|
||||||
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
|
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
|
||||||
public void cleanTempDirectories() throws IOException {
|
public void cleanTempDirectories() throws IOException {
|
||||||
cleanUpDirectory(tempInputsDir);
|
cleanUpDirectory(tempInputsDir);
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ public class MarkerService {
|
|||||||
this.markerRepository = markerRepository;
|
this.markerRepository = markerRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the marker with the given ID, verifying ownership by the current user.
|
||||||
|
*
|
||||||
|
* @param id the ID of the marker to retrieve
|
||||||
|
* @return the matching {@link Marker}
|
||||||
|
* @throws MarkerNotFound if no marker with {@code id} exists
|
||||||
|
* @throws NotAuthenticated if the current user does not own the marker
|
||||||
|
*/
|
||||||
public Marker getMarkerById(Long id) {
|
public Marker getMarkerById(Long id) {
|
||||||
Optional<Marker> marker = markerRepository.findById(id);
|
Optional<Marker> marker = markerRepository.findById(id);
|
||||||
|
|
||||||
@@ -47,6 +55,12 @@ public class MarkerService {
|
|||||||
return marker.get();
|
return marker.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all markers belonging to the currently authenticated user.
|
||||||
|
*
|
||||||
|
* @return list of the current user's markers
|
||||||
|
* @throws NotAuthenticated if no user session is present
|
||||||
|
*/
|
||||||
public List<Marker> getUserMarkers() {
|
public List<Marker> getUserMarkers() {
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
Optional<User> user = userService.getLoggedInUser();
|
||||||
|
|
||||||
@@ -57,6 +71,13 @@ public class MarkerService {
|
|||||||
return markerRepository.findByUser(user.get());
|
return markerRepository.findByUser(user.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a marker timestamped now on the current user's active stream.
|
||||||
|
*
|
||||||
|
* @param message the marker's descriptive message
|
||||||
|
* @return the persisted {@link Marker}
|
||||||
|
* @throws NotStreaming if the user has no active stream to mark
|
||||||
|
*/
|
||||||
public Marker create(String message) {
|
public Marker create(String message) {
|
||||||
Optional<Stream> stream = streamService.getActiveStream();
|
Optional<Stream> stream = streamService.getActiveStream();
|
||||||
|
|
||||||
@@ -72,11 +93,23 @@ public class MarkerService {
|
|||||||
return markerRepository.saveAndFlush(marker);
|
return markerRepository.saveAndFlush(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a marker owned by the current user.
|
||||||
|
*
|
||||||
|
* @param id the ID of the marker to delete
|
||||||
|
* @throws MarkerNotFound if no marker with {@code id} exists
|
||||||
|
* @throws NotAuthenticated if the current user does not own the marker
|
||||||
|
*/
|
||||||
public void deleteMarker(Long id) {
|
public void deleteMarker(Long id) {
|
||||||
Marker marker = getMarkerById(id);
|
Marker marker = getMarkerById(id);
|
||||||
markerRepository.delete(marker);
|
markerRepository.delete(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled cleanup that deletes markers older than the configured maximum stream length.
|
||||||
|
* <p>
|
||||||
|
* Runs periodically (every 6 minutes) to purge markers left behind by streams that have ended.
|
||||||
|
*/
|
||||||
@Scheduled(fixedDelay = 360_000)
|
@Scheduled(fixedDelay = 360_000)
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteOldMarkers() {
|
public void deleteOldMarkers() {
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ public class StreamService {
|
|||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a new stream for the user identified by the given stream key.
|
||||||
|
*
|
||||||
|
* @param streamKey the stream key resolving to the broadcasting user
|
||||||
|
* @return the newly persisted {@link Stream}
|
||||||
|
* @throws KeyNotFound if no user owns {@code streamKey}
|
||||||
|
* @throws AlreadyStreaming if the user already has an active stream
|
||||||
|
*/
|
||||||
public Stream startStream(String streamKey) {
|
public Stream startStream(String streamKey) {
|
||||||
User user = resolveUser(streamKey);
|
User user = resolveUser(streamKey);
|
||||||
|
|
||||||
@@ -45,6 +53,13 @@ public class StreamService {
|
|||||||
return streamRepository.saveAndFlush(stream);
|
return streamRepository.saveAndFlush(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the active stream for the user identified by the given stream key.
|
||||||
|
*
|
||||||
|
* @param streamKey the stream key resolving to the broadcasting user
|
||||||
|
* @throws KeyNotFound if no user owns {@code streamKey}
|
||||||
|
* @throws IllegalStateException if the user has no active stream
|
||||||
|
*/
|
||||||
public void endStream(String streamKey) {
|
public void endStream(String streamKey) {
|
||||||
User user = resolveUser(streamKey);
|
User user = resolveUser(streamKey);
|
||||||
|
|
||||||
@@ -56,6 +71,13 @@ public class StreamService {
|
|||||||
streamRepository.saveAndFlush(stream);
|
streamRepository.saveAndFlush(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a liveness heartbeat on the active stream, refreshing its last-seen time.
|
||||||
|
*
|
||||||
|
* @param streamKey the stream key resolving to the broadcasting user
|
||||||
|
* @throws KeyNotFound if no user owns {@code streamKey}
|
||||||
|
* @throws IllegalStateException if the user has no active stream
|
||||||
|
*/
|
||||||
public void heartbeatStream(String streamKey) {
|
public void heartbeatStream(String streamKey) {
|
||||||
User user = resolveUser(streamKey);
|
User user = resolveUser(streamKey);
|
||||||
|
|
||||||
@@ -67,6 +89,12 @@ public class StreamService {
|
|||||||
streamRepository.saveAndFlush(stream);
|
streamRepository.saveAndFlush(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current user's active (not-yet-ended) stream, if any.
|
||||||
|
*
|
||||||
|
* @return the active {@link Stream}, or empty if the user is not currently streaming
|
||||||
|
* @throws NotAuthenticated if no user session is present
|
||||||
|
*/
|
||||||
public Optional<Stream> getActiveStream() {
|
public Optional<Stream> getActiveStream() {
|
||||||
Optional<User> user = userService.getLoggedInUser();
|
Optional<User> user = userService.getLoggedInUser();
|
||||||
|
|
||||||
@@ -77,6 +105,14 @@ public class StreamService {
|
|||||||
return streamRepository.findByUserAndEndDateIsNull(user.get());
|
return streamRepository.findByUserAndEndDateIsNull(user.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the full stream history for the given user.
|
||||||
|
*
|
||||||
|
* @param userId the ID of the user whose history to retrieve
|
||||||
|
* @return the user's streams, past and present
|
||||||
|
* @throws IllegalArgumentException if no user with {@code userId} exists
|
||||||
|
* @throws NotAuthenticated if no user is authenticated, or the caller is not that user
|
||||||
|
*/
|
||||||
public List<Stream> getStreamHistory(Long userId) {
|
public List<Stream> getStreamHistory(Long userId) {
|
||||||
User streamUser = userService.getUserById(userId)
|
User streamUser = userService.getUserById(userId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||||
@@ -94,6 +130,12 @@ public class StreamService {
|
|||||||
return streamRepository.findByUser(streamUser);
|
return streamRepository.findByUser(streamUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled sweep that ends streams which have missed heartbeats past the timeout.
|
||||||
|
* <p>
|
||||||
|
* Runs every 30 seconds; any active stream whose last heartbeat is older than
|
||||||
|
* {@value #HEARTBEAT_TIMEOUT_SECONDS} seconds is marked ended.
|
||||||
|
*/
|
||||||
@Scheduled(fixedDelay = 30_000)
|
@Scheduled(fixedDelay = 30_000)
|
||||||
@Transactional
|
@Transactional
|
||||||
public void endStaleStreams() {
|
public void endStaleStreams() {
|
||||||
|
|||||||
@@ -44,10 +44,21 @@ public class UserService {
|
|||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a user by their database ID.
|
||||||
|
*
|
||||||
|
* @param userId the ID of the user to find
|
||||||
|
* @return the matching {@link User}, or empty if none exists
|
||||||
|
*/
|
||||||
public Optional<User> getUserById(Long userId) {
|
public Optional<User> getUserById(Long userId) {
|
||||||
return userRepository.findById(userId);
|
return userRepository.findById(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user backing the current Spring Security authentication, if authenticated.
|
||||||
|
*
|
||||||
|
* @return the authenticated {@link User}, or empty if no user is authenticated
|
||||||
|
*/
|
||||||
public Optional<User> getLoggedInUser() {
|
public Optional<User> getLoggedInUser() {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) {
|
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) {
|
||||||
@@ -56,6 +67,13 @@ public class UserService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
|
||||||
|
*
|
||||||
|
* @param idToken the Google ID token to verify
|
||||||
|
* @return a signed JWT for the authenticated user
|
||||||
|
* @throws NotAuthenticated if the token is invalid or its subject is missing
|
||||||
|
*/
|
||||||
public String login(String idToken) {
|
public String login(String idToken) {
|
||||||
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
||||||
String googleId = googleIdToken.getPayload().getSubject();
|
String googleId = googleIdToken.getPayload().getSubject();
|
||||||
@@ -70,6 +88,12 @@ public class UserService {
|
|||||||
return jwtService.generateToken(user.getId());
|
return jwtService.generateToken(user.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a user by their stream key.
|
||||||
|
*
|
||||||
|
* @param streamKey the stream key to match
|
||||||
|
* @return the matching {@link User}, or empty if none exists
|
||||||
|
*/
|
||||||
public Optional<User> getUserByStreamKey(String streamKey) {
|
public Optional<User> getUserByStreamKey(String streamKey) {
|
||||||
return userRepository.findByStreamKey(streamKey);
|
return userRepository.findByStreamKey(streamKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ import java.util.regex.Pattern;
|
|||||||
public class CommandRunner {
|
public class CommandRunner {
|
||||||
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
|
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an external command, capturing its merged stdout/stderr and streaming each line to a callback.
|
||||||
|
*
|
||||||
|
* @param command the command and its arguments
|
||||||
|
* @param onOutput invoked for each output line as it is read, or {@code null} to skip streaming
|
||||||
|
* @return the captured output and exit code
|
||||||
|
* @throws IOException if the process fails to start or exits with a non-zero code
|
||||||
|
* @throws InterruptedException if the current thread is interrupted while awaiting the process
|
||||||
|
*/
|
||||||
public CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
|
public CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
|
||||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||||
processBuilder.redirectErrorStream(true);
|
processBuilder.redirectErrorStream(true);
|
||||||
@@ -43,10 +52,27 @@ public class CommandRunner {
|
|||||||
return commandOutput;
|
return commandOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an external command, capturing its output without streaming.
|
||||||
|
*
|
||||||
|
* @param command the command and its arguments
|
||||||
|
* @return the captured output and exit code
|
||||||
|
* @throws IOException if the process fails to start or exits with a non-zero code
|
||||||
|
* @throws InterruptedException if the current thread is interrupted while awaiting the process
|
||||||
|
*/
|
||||||
public CommandOutput run(List<String> command) throws IOException, InterruptedException {
|
public CommandOutput run(List<String> command) throws IOException, InterruptedException {
|
||||||
return run(command, null);
|
return run(command, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an ffmpeg progress line and updates the tracker with the fraction completed.
|
||||||
|
* <p>
|
||||||
|
* Lines without an {@code out_time_ms} field are ignored.
|
||||||
|
*
|
||||||
|
* @param line a single line of ffmpeg {@code -progress} output
|
||||||
|
* @param progress the tracker to update
|
||||||
|
* @param length the total media length in seconds, used as the progress denominator
|
||||||
|
*/
|
||||||
public void setProgress(String line, ProgressTracker progress, float length) {
|
public void setProgress(String line, ProgressTracker progress, float length) {
|
||||||
Matcher matcher = timePattern.matcher(line);
|
Matcher matcher = timePattern.matcher(line);
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ public class CompressionService {
|
|||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compresses (and optionally trims/rescales) a video with ffmpeg on a background executor.
|
||||||
|
* <p>
|
||||||
|
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
|
||||||
|
*
|
||||||
|
* @param inputFile the source video file
|
||||||
|
* @param outputFile the destination for the compressed output
|
||||||
|
* @param clipOptions the trim window, target resolution/fps, and size driving the ffmpeg command
|
||||||
|
* @param progress the tracker updated as encoding progresses
|
||||||
|
* @return a future completing with the ffmpeg output, or exceptionally on failure
|
||||||
|
*/
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> compress(Path inputFile,
|
public CompletableFuture<CommandOutput> compress(Path inputFile,
|
||||||
Path outputFile,
|
Path outputFile,
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ public class MetadataService {
|
|||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probes a video file with ffprobe and returns its metadata (duration, dimensions, fps, size).
|
||||||
|
* <p>
|
||||||
|
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
|
||||||
|
*
|
||||||
|
* @param file the video file to probe
|
||||||
|
* @return a future completing with the parsed {@link ClipOptions}, or exceptionally on failure
|
||||||
|
*/
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<ClipOptions> getVideoMetadata(Path file) {
|
public CompletableFuture<ClipOptions> getVideoMetadata(Path file) {
|
||||||
logger.info("Getting metadata for file {}", file);
|
logger.info("Getting metadata for file {}", file);
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ public class RemuxService {
|
|||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-encodes a video to H.264/AAC in an MP4 container with ffmpeg on a background executor.
|
||||||
|
* <p>
|
||||||
|
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
|
||||||
|
*
|
||||||
|
* @param inputFile the source video file
|
||||||
|
* @param outputFile the destination for the remuxed MP4
|
||||||
|
* @param remuxProgress the tracker updated as encoding progresses
|
||||||
|
* @param length the media length in seconds, used to compute progress
|
||||||
|
* @return a future completing with the ffmpeg output, or exceptionally on failure
|
||||||
|
*/
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> remux(File inputFile,
|
public CompletableFuture<CommandOutput> remux(File inputFile,
|
||||||
File outputFile,
|
File outputFile,
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ public class ThumbnailService {
|
|||||||
this.commandRunner = commandRunner;
|
this.commandRunner = commandRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a single frame from a video as a thumbnail image with ffmpeg on a background executor.
|
||||||
|
* <p>
|
||||||
|
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
|
||||||
|
*
|
||||||
|
* @param inputFile the source video file
|
||||||
|
* @param outputFile the destination for the thumbnail image
|
||||||
|
* @param timeInVideo the timestamp in seconds at which to capture the frame
|
||||||
|
* @return a future completing with the ffmpeg output, or exceptionally on failure
|
||||||
|
*/
|
||||||
@Async("ffmpegTaskExecutor")
|
@Async("ffmpegTaskExecutor")
|
||||||
public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) {
|
public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) {
|
||||||
logger.info("Creating thumbnail at {} seconds", timeInVideo);
|
logger.info("Creating thumbnail at {} seconds", timeInVideo);
|
||||||
|
|||||||
Reference in New Issue
Block a user