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
|
||||
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) {
|
||||
SpringApplication.run(VodSystemApplication.class, args);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import java.util.concurrent.Executor;
|
||||
public class AsyncConfig implements AsyncConfigurer {
|
||||
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")
|
||||
public Executor taskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
@@ -25,6 +30,11 @@ public class AsyncConfig implements AsyncConfigurer {
|
||||
return executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the handler that logs uncaught exceptions from void-returning {@code @Async} methods.
|
||||
*
|
||||
* @return the async uncaught-exception handler
|
||||
*/
|
||||
@Override
|
||||
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
||||
return (ex, method, params) ->
|
||||
|
||||
@@ -12,6 +12,11 @@ public class CorsConfig {
|
||||
@Value("${frontend.url}")
|
||||
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
|
||||
public WebMvcConfigurer corsConfigurer() {
|
||||
return new WebMvcConfigurer() {
|
||||
|
||||
@@ -22,6 +22,14 @@ public class SecurityConfig {
|
||||
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
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,11 @@ public class CommandOutput {
|
||||
private List<String> output = new ArrayList<>();
|
||||
private int exitCode;
|
||||
|
||||
/**
|
||||
* Appends a line to the captured command output.
|
||||
*
|
||||
* @param line the output line to append
|
||||
*/
|
||||
public void addLine(String line) {
|
||||
output.add(line);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,30 @@ public class ProgressTracker {
|
||||
private float progress = 0.0f;
|
||||
private boolean isComplete = false;
|
||||
|
||||
/**
|
||||
* Returns the current progress.
|
||||
*
|
||||
* @return the progress as a fraction in {@code [0, 1]}
|
||||
*/
|
||||
public synchronized float getProgress() {
|
||||
return progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the tracked task has been marked complete.
|
||||
*
|
||||
* @return {@code true} if complete
|
||||
*/
|
||||
public synchronized boolean 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) {
|
||||
if (newProgress < 0 || newProgress > 1) {
|
||||
throw new IllegalArgumentException("Progress must be between 0 and 1");
|
||||
@@ -19,10 +35,16 @@ public class ProgressTracker {
|
||||
this.progress = newProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the tracked task as complete.
|
||||
*/
|
||||
public synchronized void markComplete() {
|
||||
this.isComplete = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets progress to zero and clears the complete flag.
|
||||
*/
|
||||
public synchronized void reset() {
|
||||
this.progress = 0.0f;
|
||||
this.isComplete = false;
|
||||
|
||||
@@ -12,6 +12,11 @@ public class ClipMediaUnavailable extends RuntimeException {
|
||||
this.clipId = clipId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID of the clip whose media was unavailable.
|
||||
*
|
||||
* @return the affected clip's ID
|
||||
*/
|
||||
public Long getClipId() {
|
||||
return clipId;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ public class VodMediaUnavailable extends RuntimeException {
|
||||
this.vodId = vodId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID of the VoD whose media was unavailable.
|
||||
*
|
||||
* @return the affected VoD's ID
|
||||
*/
|
||||
public Long getVodId() {
|
||||
return vodId;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ import java.util.List;
|
||||
|
||||
@Repository
|
||||
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")
|
||||
List<Clip> findByUser(User user);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,21 @@ import java.util.List;
|
||||
|
||||
@Repository
|
||||
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")
|
||||
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")
|
||||
List<Marker> findAllBefore(Instant time);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,27 @@ import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
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);
|
||||
|
||||
/**
|
||||
* Finds all streams belonging to the given user.
|
||||
*
|
||||
* @param user the owning user
|
||||
* @return the user's streams
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,21 @@ import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
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")
|
||||
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")
|
||||
Optional<User> findByStreamKey(String streamKey);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ import java.util.List;
|
||||
@Repository
|
||||
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")
|
||||
List<Vod> findByUser(User user);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ public class JwtService {
|
||||
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) {
|
||||
return JWT.create()
|
||||
.withClaim(USER_ID_CLAIM, userId)
|
||||
@@ -34,6 +40,12 @@ public class JwtService {
|
||||
.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) {
|
||||
try {
|
||||
JWTVerifier verifier = JWT.require(algorithm)
|
||||
|
||||
@@ -40,61 +40,132 @@ public class DirectoryService {
|
||||
private static final String CLIPS_DIR_NAME = "clips";
|
||||
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() {
|
||||
return Path.of(tempInputsDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the directory for temporary processing outputs.
|
||||
*
|
||||
* @return the configured temporary output directory path
|
||||
*/
|
||||
public Path getTempOutputDir() {
|
||||
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 {
|
||||
Path userDir = Path.of(usersDir + File.separator + userId);
|
||||
Files.createDirectories(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 {
|
||||
Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME);
|
||||
Files.createDirectories(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 {
|
||||
Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME);
|
||||
Files.createDirectories(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 {
|
||||
Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME);
|
||||
Files.createDirectories(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 {
|
||||
Path streamFolder = Path.of(streamsDir, streamKey);
|
||||
Files.createDirectories(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) {
|
||||
Path media = Path.of(usersDir).toAbsolutePath();
|
||||
Path newPath = path.toAbsolutePath();
|
||||
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) {
|
||||
Path media = Path.of(usersDir).toAbsolutePath();
|
||||
Path newPath = Path.of(path);
|
||||
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 {
|
||||
Files.createDirectories(path.getParent());
|
||||
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 {
|
||||
Files.createDirectories(target.getParent());
|
||||
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
|
||||
public void createDirectoriesIfNotExist() throws IOException {
|
||||
Files.createDirectories(Path.of(tempInputsDir));
|
||||
@@ -155,6 +232,13 @@ public class DirectoryService {
|
||||
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)
|
||||
public void cleanTempDirectories() throws IOException {
|
||||
cleanUpDirectory(tempInputsDir);
|
||||
|
||||
@@ -32,6 +32,14 @@ public class MarkerService {
|
||||
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) {
|
||||
Optional<Marker> marker = markerRepository.findById(id);
|
||||
|
||||
@@ -47,6 +55,12 @@ public class MarkerService {
|
||||
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() {
|
||||
Optional<User> user = userService.getLoggedInUser();
|
||||
|
||||
@@ -57,6 +71,13 @@ public class MarkerService {
|
||||
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) {
|
||||
Optional<Stream> stream = streamService.getActiveStream();
|
||||
|
||||
@@ -72,11 +93,23 @@ public class MarkerService {
|
||||
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) {
|
||||
Marker marker = getMarkerById(id);
|
||||
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)
|
||||
@Transactional
|
||||
public void deleteOldMarkers() {
|
||||
|
||||
@@ -30,6 +30,14 @@ public class StreamService {
|
||||
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) {
|
||||
User user = resolveUser(streamKey);
|
||||
|
||||
@@ -45,6 +53,13 @@ public class StreamService {
|
||||
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) {
|
||||
User user = resolveUser(streamKey);
|
||||
|
||||
@@ -56,6 +71,13 @@ public class StreamService {
|
||||
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) {
|
||||
User user = resolveUser(streamKey);
|
||||
|
||||
@@ -67,6 +89,12 @@ public class StreamService {
|
||||
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() {
|
||||
Optional<User> user = userService.getLoggedInUser();
|
||||
|
||||
@@ -77,6 +105,14 @@ public class StreamService {
|
||||
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) {
|
||||
User streamUser = userService.getUserById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||
@@ -94,6 +130,12 @@ public class StreamService {
|
||||
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)
|
||||
@Transactional
|
||||
public void endStaleStreams() {
|
||||
|
||||
@@ -44,10 +44,21 @@ public class UserService {
|
||||
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) {
|
||||
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() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) {
|
||||
@@ -56,6 +67,13 @@ public class UserService {
|
||||
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) {
|
||||
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
||||
String googleId = googleIdToken.getPayload().getSubject();
|
||||
@@ -70,6 +88,12 @@ public class UserService {
|
||||
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) {
|
||||
return userRepository.findByStreamKey(streamKey);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ import java.util.regex.Pattern;
|
||||
public class CommandRunner {
|
||||
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 {
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||
processBuilder.redirectErrorStream(true);
|
||||
@@ -43,10 +52,27 @@ public class CommandRunner {
|
||||
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 {
|
||||
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) {
|
||||
Matcher matcher = timePattern.matcher(line);
|
||||
if (matcher.find()) {
|
||||
|
||||
@@ -27,6 +27,17 @@ public class CompressionService {
|
||||
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")
|
||||
public CompletableFuture<CommandOutput> compress(Path inputFile,
|
||||
Path outputFile,
|
||||
|
||||
@@ -26,6 +26,14 @@ public class MetadataService {
|
||||
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")
|
||||
public CompletableFuture<ClipOptions> getVideoMetadata(Path file) {
|
||||
logger.info("Getting metadata for file {}", file);
|
||||
|
||||
@@ -21,6 +21,17 @@ public class RemuxService {
|
||||
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")
|
||||
public CompletableFuture<CommandOutput> remux(File inputFile,
|
||||
File outputFile,
|
||||
|
||||
@@ -20,6 +20,16 @@ public class ThumbnailService {
|
||||
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")
|
||||
public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) {
|
||||
logger.info("Creating thumbnail at {} seconds", timeInVideo);
|
||||
|
||||
Reference in New Issue
Block a user