clip = clipRepository.findById(id);
@@ -82,6 +83,17 @@ public class ClipService {
return clip;
}
+ /**
+ * Applies a partial update to an existing clip's metadata.
+ *
+ * Only non-null fields in {@code newFields} are written; null fields are left unchanged.
+ *
+ * @param id the ID of the clip to update
+ * @param newFields fields to overwrite; any null field is ignored
+ * @return the updated clip as persisted in the database
+ * @throws ClipNotFound if no clip with {@code id} exists
+ * @throws NotAuthenticated if the current user does not own the clip
+ */
public Clip updateClip(Long id, ClipUpdateRequest newFields) {
Optional possibleClip = clipRepository.findById(id);
@@ -107,11 +119,12 @@ public class ClipService {
}
/**
- * Deletes a clip by its ID, ensuring the user is authenticated to perform the deletion.
+ * Deletes a clip and its associated files from disk.
*
- * @param id the ID of the clip to delete.
- * @return true if the clip was successfully deleted, false if it was not found.
- * @throws NotAuthenticated if the user is not authorized to delete the clip.
+ * @param id the ID of the clip to delete
+ * @return {@code true} on success; always throws rather than returning {@code false}
+ * @throws ClipNotFound if no clip with {@code id} exists
+ * @throws NotAuthenticated if the current user does not own the clip
*/
public boolean deleteClip(Long id) {
Optional possibleClip = getClipById(id);
@@ -133,10 +146,11 @@ public class ClipService {
}
/**
- * Checks if the currently logged-in user is authenticated to access the specified clip.
+ * Returns whether the current user owns the given clip.
*
- * @param clip the clip to check access for.
- * @return true if the user is authenticated for the clip, false otherwise.
+ * @param clip the clip to check ownership of
+ * @return {@code true} if the current user's ID matches the clip's owner; {@code false} if
+ * {@code clip} is null or no user session exists
*/
public boolean isAuthenticatedForClip(Clip clip) {
Optional user = userService.getLoggedInUser();
@@ -146,6 +160,14 @@ public class ClipService {
return user.get().getId().equals(clip.getUser().getId());
}
+ /**
+ * Returns a streamable resource for the clip's video file.
+ *
+ * @param id the ID of the clip to download
+ * @return a {@link Resource} pointing to the clip's video file on disk
+ * @throws ClipNotFound if no clip with {@code id} exists, or the video file is missing on disk
+ * @throws NotAuthenticated if the current user does not own the clip
+ */
public Resource downloadClip(Long id) {
Optional possibleClip = getClipById(id);
@@ -169,6 +191,14 @@ public class ClipService {
return new FileSystemResource(file);
}
+ /**
+ * Returns a streamable resource for the clip's thumbnail image.
+ *
+ * @param id the ID of the clip whose thumbnail to download
+ * @return a {@link Resource} pointing to the thumbnail file on disk
+ * @throws ClipNotFound if no clip with {@code id} exists, or the thumbnail file is missing on disk
+ * @throws NotAuthenticated if the current user does not own the clip
+ */
public Resource downloadThumbnail(Long id) {
Optional possibleClip = getClipById(id);
@@ -193,32 +223,21 @@ public class ClipService {
}
/**
- * Persists a clip to the database
- * @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description
- * @param user User to save the clip to
- * @param videoPath Path of the clip
- * @param thumbnailPath Path of the thumbnail
- * @return Clip object saved to the database
+ * Copies a processed clip file into permanent storage, generates its thumbnail, and saves
+ * the clip record to the database.
+ *
+ * Thumbnail generation failure is non-fatal — the error is logged and the clip is saved
+ * without a thumbnail rather than aborting.
+ *
+ * @param title title to assign to the clip
+ * @param description description to assign to the clip
+ * @param user owner of the clip
+ * @param clipFile path to the processed video file (typically in the temp output directory)
+ * @param fileName filename used for both the permanent video file and the thumbnail
+ * (thumbnail is stored as {@code fileName + ".png"})
+ * @throws StorageException if copying the file to the clips directory fails
+ * @throws FFMPEGException if reading video metadata from the copied file fails
*/
- public Clip saveClip(ClipOptions options,
- User user,
- Path videoPath,
- Path thumbnailPath) {
- Clip clip = new Clip();
- clip.setUser(user);
- clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip");
- clip.setDescription(options.getDescription() != null ? options.getDescription() : "");
- clip.setCreatedAt(Instant.now());
- clip.setWidth(options.getWidth());
- clip.setHeight(options.getHeight());
- clip.setFps(options.getFps());
- clip.setDuration(options.getDuration() - options.getStartPoint());
- clip.setFileSize(options.getFileSize());
- clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
- clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
- return clipRepository.save(clip);
- }
-
public void persistClip(String title,
String description,
User user,
@@ -261,6 +280,25 @@ public class ClipService {
logger.info("Clip created successfully with ID: {}", clip.getId());
}
+ private Clip saveClip(ClipOptions metadata,
+ User user,
+ Path videoPath,
+ Path thumbnailPath) {
+ Clip clip = new Clip();
+ clip.setUser(user);
+ clip.setTitle(metadata.getTitle() != null ? metadata.getTitle() : "Untitled Clip");
+ clip.setDescription(metadata.getDescription() != null ? metadata.getDescription() : "");
+ clip.setCreatedAt(Instant.now());
+ clip.setWidth(metadata.getWidth());
+ clip.setHeight(metadata.getHeight());
+ clip.setFps(metadata.getFps());
+ clip.setDuration(metadata.getDuration() - metadata.getStartPoint());
+ clip.setFileSize(metadata.getFileSize());
+ clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
+ clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
+ return clipRepository.save(clip);
+ }
+
private void deleteClipFiles(Clip clip) {
File clipFile = new File(clip.getVideoPath());
File thumbnailFile = new File(clip.getThumbnailPath());
diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java
index db7ffb0..e466230 100644
--- a/src/main/java/com/ddf/vodsystem/services/MediaService.java
+++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java
@@ -16,6 +16,7 @@ import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@@ -30,20 +31,36 @@ public class MediaService {
private static final float CLIP_MAX_LENGTH = 180;
private static final int HLS_SEGMENT_LENGTH = 3;
private final StreamActionsService streamActionsService;
+ private final VodService vodService;
public MediaService(JobRegistryService jobRegistryService,
DirectoryService directoryService,
CompressionService compressionService,
UserService userService,
- ClipService clipService, StreamActionsService streamActionsService) {
+ ClipService clipService, StreamActionsService streamActionsService, VodService vodService) {
this.jobRegistryService = jobRegistryService;
this.directoryService = directoryService;
this.compressionService = compressionService;
this.userService = userService;
this.clipService = clipService;
this.streamActionsService = streamActionsService;
+ this.vodService = vodService;
}
+ /**
+ * Submits an asynchronous compression job for the uploaded video file.
+ *
+ * Saves the upload to a temporary input path, then runs the compression pipeline
+ * in the background according to {@code clipOptions}. If a user session exists at
+ * call time, the output is also persisted as a clip when the job succeeds.
+ *
+ * @param file the uploaded video file to compress
+ * @param clipOptions trim window, resolution, fps, and metadata for the output clip
+ * @return the {@link Job} tracking the compression; state transitions to
+ * {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
+ * @throws IOException if saving the upload to the temporary directory fails
+ * @throws InterruptedException if the current thread is interrupted while setting up the job
+ */
public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException {
Job job = jobRegistryService.generateJob();
@@ -86,20 +103,29 @@ public class MediaService {
}
/**
- * Saves a clip of the currently authenticated user's stream between two points in time.
+ * Saves a section of the currently authenticated user's live stream between two absolute timestamps.
*
- * Locates the recorded {@code .ts} segments overlapping the requested window, computes the
- * trim offset into the first (partially-overlapping) segment and the total clip duration,
- * then delegates the actual extraction to {@link StreamActionsService#saveSection}.
+ * Locates the recorded {@code .ts} HLS segments that overlap {@code [startTime, endTime)}, computes
+ * the trim offset into the first partially-overlapping segment, then delegates extraction and muxing
+ * to {@link StreamActionsService#saveSection}. On success, the output is persisted as a VoD via
+ * {@link VodService#persist}.
*
- * @param startTime the inclusive start of the section to save; must be before {@code endTime}
- * @param endTime the exclusive end of the section to save
+ * @param startTime inclusive start of the section to save; must be before {@code endTime}
+ * @param endTime exclusive end of the section to save
+ * @param title title for the saved VoD; defaults to the current timestamp if {@code null}
+ * @param description description for the saved VoD; defaults to an empty string if {@code null}
+ * @return the {@link Job} tracking the save; state transitions to
+ * {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
* or if no stream segments exist in the given range
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
- public Job saveSection(Instant startTime, Instant endTime) throws IOException {
+ public Job saveSection(
+ Instant startTime,
+ Instant endTime,
+ String title,
+ String description) throws IOException {
if (startTime.isAfter(endTime)) {
throw new IllegalArgumentException("Start time must be before end time");
}
@@ -108,97 +134,94 @@ public class MediaService {
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
Job job = jobRegistryService.generateJob();
- Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
- List fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
-
- if (fileSegments.isEmpty()) {
- throw new IllegalArgumentException("No stream segments found in the given time range");
- }
-
- long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
- float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
+ SegmentContext ctx = resolveSegments(user, startTime, endTime);
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
-
- job.setState(JobState.PROCESSING);
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
- streamActionsService.saveSection(
- fileSegments,
- trimOffset,
- duration,
- outputFile,
- job.getProgressTracker()
- ).thenRun(() -> {
- job.setDownload(outputFile);
- job.setState(JobState.SUCCEEDED);
- logger.info("Stream save of job {} succeeded", job.getUuid());
- }).exceptionally(ex -> {
- job.setState(JobState.FAILED);
- job.setErrorOutput(ex.getMessage());
- logger.error("Stream section save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
- return null;
- });
- return job;
+ return dispatchSaveJob(job, ctx, duration, outputFile, () ->
+ vodService.persist(
+ title == null ? Instant.now().toString() : title,
+ description == null ? "" : description,
+ user,
+ outputFile,
+ outputFile.getFileName().toString()
+ )
+ );
}
/**
- * Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
+ * Saves the last {@code duration} seconds of the currently authenticated user's live stream as a clip.
*
- * Computes a time window ending at the current instant and beginning {@code duration} seconds
- * earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
+ * Computes a window of {@code [now - duration, now)} with millisecond precision, locates the
+ * overlapping HLS {@code .ts} segments, and delegates extraction and muxing to
+ * {@link StreamActionsService#saveSection}. On success, the output is persisted as a clip via
+ * {@link ClipService#persistClip}.
*
- * @param duration the length of the clip in seconds, measured back from now; must be greater
- * than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
- * are supported and resolved to the millisecond.
- * @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
+ * @param duration length of the clip in seconds measured back from now; must be in
+ * {@code (0, CLIP_MAX_LENGTH]}. Fractional values are supported.
+ * @param title title for the saved clip; defaults to the current timestamp if {@code null}
+ * @param description description for the saved clip; defaults to an empty string if {@code null}
+ * @return the {@link Job} tracking the save; state transitions to
+ * {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously
+ * @throws IllegalArgumentException if {@code duration} is not in {@code (0, CLIP_MAX_LENGTH]},
* or if no stream segments exist in the computed window
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
- public Job clip(float duration) throws IOException {
+ public Job clip(float duration, String title, String description) throws IOException {
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
}
- // minusSeconds() is possible, but only does integer seconds, not float
Instant endTime = Instant.now();
+ // minusSeconds() only does integer seconds, not float
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
Job job = jobRegistryService.generateJob();
- Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
- List fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
+ SegmentContext ctx = resolveSegments(user, startTime, endTime);
+ Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
- if (fileSegments.isEmpty()) {
+ return dispatchSaveJob(job, ctx, duration, outputFile, () ->
+ clipService.persistClip(
+ title == null ? Instant.now().toString() : title,
+ description == null ? "" : description,
+ user,
+ outputFile,
+ outputFile.getFileName().toString()
+ )
+ );
+ }
+
+ private record SegmentContext(List segments, float trimOffset) {}
+
+ private SegmentContext resolveSegments(User user, Instant startTime, Instant endTime) throws IOException {
+ Path streamDir = directoryService.getStreamDir(user.getStreamKey());
+ List segments = getSegmentsInRange(streamDir, startTime, endTime);
+ if (segments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
+ long firstMs = parseTimestampMs(segments.getFirst());
+ float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f);
+ return new SegmentContext(segments, trimOffset);
+ }
- long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
- float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
-
- Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
- streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile, job.getProgressTracker())
+ private Job dispatchSaveJob(Job job, SegmentContext ctx, float duration, Path outputFile, Runnable onSuccess) {
+ job.setState(JobState.PROCESSING);
+ streamActionsService.saveSection(ctx.segments(), ctx.trimOffset(), duration, outputFile, job.getProgressTracker())
.thenRun(() -> {
job.setDownload(outputFile);
job.setState(JobState.SUCCEEDED);
- logger.info("Stream clip of job {} succeeded", job.getUuid());
- clipService.persistClip(
- Instant.now().toString(),
- "",
- user,
- outputFile,
- outputFile.getFileName().toString()
- );
- }
- ).exceptionally(ex -> {
+ logger.info("Stream save job {} succeeded", job.getUuid());
+ onSuccess.run();
+ }).exceptionally(ex -> {
job.setState(JobState.FAILED);
job.setErrorOutput(ex.getMessage());
- logger.error("Clip save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
+ logger.error("Stream save job {} failed: {}", job.getUuid(), ex.getMessage());
return null;
});
-
return job;
}
@@ -214,7 +237,7 @@ public class MediaService {
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
})
.sorted(Comparator.comparingLong(this::parseTimestampMs))
- .toList();
+ .collect(Collectors.toList());
}
}
diff --git a/src/main/java/com/ddf/vodsystem/services/VodService.java b/src/main/java/com/ddf/vodsystem/services/VodService.java
new file mode 100644
index 0000000..fe40096
--- /dev/null
+++ b/src/main/java/com/ddf/vodsystem/services/VodService.java
@@ -0,0 +1,263 @@
+package com.ddf.vodsystem.services;
+
+import com.ddf.vodsystem.controllers.dto.VodUpdateRequest;
+import com.ddf.vodsystem.dto.ClipOptions;
+import com.ddf.vodsystem.entities.User;
+import com.ddf.vodsystem.entities.Vod;
+import com.ddf.vodsystem.exceptions.*;
+import com.ddf.vodsystem.repositories.VodRepository;
+import com.ddf.vodsystem.services.media.MetadataService;
+import com.ddf.vodsystem.services.media.ThumbnailService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+
+@Service
+public class VodService {
+ private static final Logger logger = LoggerFactory.getLogger(VodService.class);
+
+ private final VodRepository vodRepository;
+ private final DirectoryService directoryService;
+ private final MetadataService metadataService;
+ private final ThumbnailService thumbnailService;
+ private final UserService userService;
+
+ public VodService(VodRepository vodRepository,
+ DirectoryService directoryService,
+ MetadataService metadataService,
+ ThumbnailService thumbnailService,
+ UserService userService) {
+ this.vodRepository = vodRepository;
+ this.directoryService = directoryService;
+ this.metadataService = metadataService;
+ this.thumbnailService = thumbnailService;
+ this.userService = userService;
+ }
+
+ /**
+ * Returns the VoD with the given ID, verifying ownership by the current user.
+ *
+ * @param id the ID of the VoD to retrieve
+ * @return the matching {@link Vod} entity
+ * @throws VodNotFound if no VoD with {@code id} exists
+ * @throws NotAuthenticated if the current user does not own the VoD
+ */
+ public Vod getVodById(Long id) {
+ Optional vod = vodRepository.findById(id);
+
+ if (vod.isEmpty()) {
+ throw new VodNotFound("Vod ID " + id + " does not exist");
+ }
+
+ if (!isAuthenticatedForVod(vod.get())) {
+ throw new NotAuthenticated("User not authenticated");
+ }
+
+ return vod.get();
+ }
+
+ /**
+ * Returns all VoDs belonging to the currently authenticated user.
+ *
+ * @return list of VoDs owned by the current user, in repository order
+ * @throws NotAuthenticated if no user session is present
+ */
+ public List getUserVods() {
+ Optional user = userService.getLoggedInUser();
+
+ if (user.isEmpty()) {
+ throw new NotAuthenticated("User must be logged in to retrieve vods");
+ }
+
+ return vodRepository.findByUser(user.get());
+ }
+
+ /**
+ * Applies a partial update to an existing VoD's metadata.
+ *
+ * Only non-null fields in {@code newFields} are written; null fields are left unchanged.
+ *
+ * @param id the ID of the VoD to update
+ * @param newFields fields to overwrite; any null field is ignored
+ * @return the updated VoD as persisted in the database
+ * @throws VodNotFound if no VoD with {@code id} exists
+ * @throws NotAuthenticated if the current user does not own the VoD
+ */
+ public Vod updateVod(Long id, VodUpdateRequest newFields) {
+ Vod vod = getVodById(id);
+
+ if (newFields.title() != null) {
+ vod.setTitle(newFields.title());
+ }
+
+ if (newFields.description() != null) {
+ vod.setDescription(newFields.description());
+ }
+
+ return vodRepository.saveAndFlush(vod);
+ }
+
+ /**
+ * Deletes a VoD and its associated files from disk.
+ *
+ * @param id the ID of the VoD to delete
+ * @return {@code true} on success; always throws rather than returning {@code false}
+ * @throws VodNotFound if no VoD with {@code id} exists
+ * @throws NotAuthenticated if the current user does not own the VoD
+ */
+ public boolean deleteVod(Long id) {
+ Vod vod = getVodById(id);
+
+ deleteVodFiles(vod);
+ vodRepository.delete(vod);
+
+ logger.info("Vod with ID {} deleted successfully", id);
+ return true;
+ }
+
+ /**
+ * Returns a streamable resource for the VoD's video file.
+ *
+ * @param id the ID of the VoD to download
+ * @return a {@link Resource} pointing to the VoD's video file on disk
+ * @throws VodNotFound if no VoD with {@code id} exists, or the video file is missing on disk
+ * @throws NotAuthenticated if the current user does not own the VoD
+ */
+ public Resource downloadVod(Long id) {
+ Vod vod = getVodById(id);
+ Path file = directoryService.resolvePath(vod.getVideoPath());
+
+ if (!Files.exists(file)) {
+ throw new VodNotFound("Vod file not found for ID: " + id);
+ }
+
+ return new FileSystemResource(file);
+ }
+
+ /**
+ * Returns a streamable resource for the VoD's thumbnail image.
+ *
+ * @param id the ID of the VoD whose thumbnail to download
+ * @return a {@link Resource} pointing to the thumbnail file on disk
+ * @throws VodNotFound if no VoD with {@code id} exists, or the thumbnail file is missing on disk
+ * @throws NotAuthenticated if the current user does not own the VoD
+ */
+ public Resource downloadThumbnail(Long id) {
+ Vod vod = getVodById(id);
+ Path file = directoryService.resolvePath(vod.getThumbnailPath());
+
+ if (!Files.exists(file)) {
+ throw new VodNotFound("Thumbnail file not found for vod ID: " + id);
+ }
+
+ return new FileSystemResource(file);
+ }
+
+ /**
+ * Copies a processed VoD file into permanent storage, generates its thumbnail, and saves
+ * the VoD record to the database.
+ *
+ * Thumbnail generation failure is non-fatal — the error is logged and the VoD is saved
+ * without a thumbnail rather than aborting.
+ *
+ * @param title title to assign to the VoD
+ * @param description description to assign to the VoD
+ * @param user owner of the VoD
+ * @param vodFile path to the processed video file to copy into permanent storage
+ * @param fileName filename used for both the permanent video file and the thumbnail
+ * (thumbnail is stored as {@code fileName + ".png"})
+ * @throws StorageException if copying the file to the VoDs directory fails
+ * @throws FFMPEGException if reading video metadata from the copied file fails
+ */
+ public void persist(String title,
+ String description,
+ User user,
+ Path vodFile,
+ String fileName) {
+ Path newVodFile;
+ Path thumbnailFile;
+
+ try {
+ newVodFile = directoryService.getVodsDir(user.getId()).resolve(fileName);
+ thumbnailFile = directoryService.getThumbnailsDir(user.getId()).resolve(fileName + ".png");
+ directoryService.copyFile(vodFile, newVodFile);
+ } catch (IOException e) {
+ throw new StorageException("Failed to move vod from temporary directory to output directory", e);
+ }
+
+ ClipOptions vodMetadata;
+ try {
+ vodMetadata = metadataService.getVideoMetadata(newVodFile).get();
+ } catch (InterruptedException | ExecutionException e) {
+ Thread.currentThread().interrupt();
+ throw new FFMPEGException("Error retrieving video metadata for vod: " + e.getMessage());
+ }
+
+ try {
+ thumbnailService.createThumbnail(newVodFile, thumbnailFile, 0.0f);
+ } catch (InterruptedException e) {
+ logger.error("Thumbnail generation interrupted for user: {}", user.getId(), e);
+ Thread.currentThread().interrupt();
+ } catch (IOException e) {
+ logger.error("Error generating thumbnail for user: {}", user.getId(), e);
+ }
+
+ vodMetadata.setTitle(title);
+ vodMetadata.setDescription(description);
+
+ Vod vod = saveToDb(vodMetadata, user, newVodFile, thumbnailFile);
+ logger.info("Vod created successfully with ID: {}", vod.getId());
+ }
+
+ private Vod saveToDb(ClipOptions options,
+ User user,
+ Path videoPath,
+ Path thumbnailPath) {
+ Vod vod = new Vod();
+ vod.setUser(user);
+ vod.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled VoD");
+ vod.setDescription(options.getDescription() != null ? options.getDescription() : "");
+ vod.setCreatedAt(Instant.now());
+ vod.setWidth(options.getWidth());
+ vod.setHeight(options.getHeight());
+ vod.setFps(options.getFps());
+ vod.setDuration(options.getDuration());
+ vod.setFileSize(options.getFileSize());
+ vod.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
+ vod.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
+ return vodRepository.save(vod);
+ }
+
+ private void deleteVodFiles(Vod vod) {
+ File vodFile = new File(vod.getVideoPath());
+ File thumbnailFile = new File(vod.getThumbnailPath());
+
+ try {
+ Files.deleteIfExists(vodFile.toPath());
+ Files.deleteIfExists(thumbnailFile.toPath());
+ } catch (IOException e) {
+ logger.error("Could not delete vod files for vod ID: {}", vod.getId());
+ }
+ }
+
+ private boolean isAuthenticatedForVod(Vod vod) {
+ Optional user = userService.getLoggedInUser();
+
+ if (vod == null || user.isEmpty()) {
+ return false;
+ }
+
+ return user.get().getId().equals(vod.getUser().getId());
+ }
+}
diff --git a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java
index a3ba110..201cc22 100644
--- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java
+++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java
@@ -6,7 +6,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
-import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -76,13 +75,9 @@ public class StreamActionsService {
progressTracker.markComplete();
return CompletableFuture.completedFuture(outputFile);
- } catch (IOException e) {
+ } catch (Exception e) {
logger.error("Failed to save section to '{}'", outputFile, e);
return CompletableFuture.failedFuture(e);
- } catch (InterruptedException e) {
- logger.error("Failed to save section");
- Thread.currentThread().interrupt();
- return CompletableFuture.failedFuture(e);
}
}
}
\ No newline at end of file
diff --git a/src/main/resources/db/data.sql b/src/main/resources/db/data.sql
deleted file mode 100644
index 8b831b3..0000000
--- a/src/main/resources/db/data.sql
+++ /dev/null
@@ -1,25 +0,0 @@
-INSERT INTO users ( google_id, username, email, name)
-VALUES
- ('google-uid-001', 'alice', 'alice@example.com', 'Alice Example'),
- ( 'google-uid-002', 'bob', 'bob@example.com', 'Bob Example'),
- ('google-uid-003', 'carol', 'carol@example.com', 'Carol Example'),
- ( 'google-uid-004', 'wizard42', 'gandalf@middle.earth', 'Gandalf the Grey'),
- ( 'google-uid-005', 'catnap', 'whiskers@meowmail.com', 'Sir Whiskers McFluff'),
- ( 'google-uid-006', 'robotron', 'bender@futurama.tv', 'Bender Rodriguez'),
- ('google-uid-007', 'unicorn', 'sparkle@rainbow.com', 'Princess Sparklehoof'),
- ( 'google-uid-008', 'pirate', 'blackbeard@seas.com', 'Edward Teach'),
- ( 'google-uid-009', 'detective', 'holmes@bakerstreet.uk', 'Sherlock Holmes'),
- ( 'google-uid-010', 'timey', 'docbrown@delorean.net', 'Dr. Emmett Brown');
-
-INSERT INTO clips (user_id, title, description, width, height, fps, duration, file_size, video_path)
-VALUES
- (4, 'Fireworks Over Hobbiton', 'A magical display of fireworks by Gandalf.', 1920, 1080, 30, 120, 104857600, '/videos/fireworks_hobbiton.mp4'),
- (5, 'Catnap Chronicles', 'Sir Whiskers McFluff naps in 12 different positions.', 1280, 720, 24, 60, 52428800, '/videos/catnap_chronicles.mp4'),
- (6, 'Bite My Shiny Metal...', 'Bender shows off his new upgrades.', 1920, 1080, 60, 45, 73400320, '/videos/bender_upgrades.mp4'),
- (7, 'Rainbow Dash', 'Princess Sparklehoof gallops across a double rainbow.', 1920, 1080, 30, 90, 67108864, '/videos/rainbow_dash.mp4'),
- ( 8, 'Pirate Karaoke Night', 'Blackbeard sings sea shanties with his crew.', 1280, 720, 25, 180, 157286400, '/videos/pirate_karaoke.mp4'),
- ( 9, 'The Case of the Missing Sandwich', 'Sherlock Holmes investigates a lunchtime mystery.', 1920, 1080, 30, 75, 50331648, '/videos/missing_sandwich.mp4'),
- ( 10, '88 Miles Per Hour', 'Doc Brown demonstrates time travel with style.', 1920, 1080, 60, 30, 41943040, '/videos/88mph.mp4'),
- ( 1, 'Alice in Videoland', 'Alice explores a surreal digital wonderland.', 1280, 720, 30, 150, 94371840, '/videos/alice_videoland.mp4'),
- ( 2, 'Bob''s Building Bonanza', 'Bob constructs a house out of cheese.', 1920, 1080, 24, 200, 209715200, '/videos/bob_cheesehouse.mp4'),
- ( 3, 'Carol''s Coding Catastrophe', 'Carol debugs a spaghetti codebase.', 1280, 720, 30, 100, 73400320, '/videos/carol_coding.mp4');
\ No newline at end of file
diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql
index 2cc5bab..efbb646 100644
--- a/src/main/resources/db/schema.sql
+++ b/src/main/resources/db/schema.sql
@@ -38,4 +38,20 @@ CREATE TABLE IF NOT EXISTS clips (
video_path VARCHAR(255) NOT NULL,
thumbnail_path VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS vods (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ width INTEGER NOT NULL,
+ height INTEGER NOT NULL,
+ fps FLOAT NOT NULL,
+ duration FLOAT NOT NULL,
+ file_size FLOAT NOT NULL,
+ video_path VARCHAR(255) NOT NULL,
+ thumbnail_path VARCHAR(255) NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
\ No newline at end of file