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

- Resolves #18

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

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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);
}

View File

@@ -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()) {

View File

@@ -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,

View File

@@ -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);

View 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,

View File

@@ -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);