files = Files.list(dirPath)) {
+ files.filter(Files::isRegularFile)
+ .filter(p -> {
+ try {
+ return Files.getLastModifiedTime(p).toMillis()
+ < System.currentTimeMillis() - TEMP_DIR_TIME_LIMIT;
+ } catch (IOException e) {
+ logger.warn("Could not read last modified time for {}", p);
+ return false;
+ }
+ })
+ .forEach(p -> {
+ try {
+ Files.delete(p);
+ } catch (IOException e) {
+ logger.warn("Could not delete file {}", p);
+ }
+ });
}
}
@PostConstruct
public void createDirectoriesIfNotExist() throws IOException {
- createDirectory(tempInputsDir);
- createDirectory(tempOutputsDir);
- createDirectory(outputDir);
+ Files.createDirectories(Path.of(tempInputsDir));
+ Files.createDirectories(Path.of(tempOutputsDir));
+ Files.createDirectories(Path.of(usersDir));
}
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
diff --git a/src/main/java/com/ddf/vodsystem/services/DownloadService.java b/src/main/java/com/ddf/vodsystem/services/DownloadService.java
deleted file mode 100644
index 6373a32..0000000
--- a/src/main/java/com/ddf/vodsystem/services/DownloadService.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.ddf.vodsystem.services;
-
-import com.ddf.vodsystem.entities.Clip;
-import com.ddf.vodsystem.exceptions.JobNotFinished;
-import com.ddf.vodsystem.exceptions.JobNotFound;
-import com.ddf.vodsystem.dto.Job;
-import com.ddf.vodsystem.exceptions.NotAuthenticated;
-import com.ddf.vodsystem.repositories.ClipRepository;
-import org.springframework.core.io.FileSystemResource;
-import org.springframework.core.io.Resource;
-import org.springframework.stereotype.Service;
-
-import java.io.File;
-
-@Service
-public class DownloadService {
-
- private final JobRegistryService jobRegistryService;
- private final ClipRepository clipRepository;
- private final ClipService clipService;
-
- public DownloadService(JobRegistryService jobRegistryService,
- ClipRepository clipRepository,
- ClipService clipService) {
- this.jobRegistryService = jobRegistryService;
- this.clipRepository = clipRepository;
- this.clipService = clipService;
- }
-
- public Resource downloadInput(String uuid) {
- Job job = jobRegistryService.getJob(uuid);
-
- File file = job.getInputFile();
- return new FileSystemResource(file);
- }
-
- public Resource downloadOutput(String uuid) {
- Job job = jobRegistryService.getJob(uuid);
-
- if (!job.getStatus().getProcess().isComplete()) {
- throw new JobNotFinished("Job is not finished");
- }
-
- File file = job.getOutputFile();
- return new FileSystemResource(file);
- }
-
- public Resource downloadClip(Clip clip) {
- if (!clipService.isAuthenticatedForClip(clip)) {
- throw new NotAuthenticated("Not authenticated for this clip");
- }
-
- String path = clip.getVideoPath();
- File file = new File(path);
-
- if (!file.exists()) {
- throw new JobNotFound("Clip file not found");
- }
-
- return new FileSystemResource(file);
- }
-
- public Resource downloadClip(Long id) {
- Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id));
- return downloadClip(clip);
- }
-
- public Resource downloadThumbnail(Clip clip) {
- if (!clipService.isAuthenticatedForClip(clip)) {
- throw new NotAuthenticated("Not authenticated for this clip thumbnail");
- }
-
- String path = clip.getThumbnailPath();
- File file = new File(path);
- if (!file.exists()) {
- throw new JobNotFound("Thumbnail file not found");
- }
-
- return new FileSystemResource(file);
- }
-
- public Resource downloadThumbnail(Long id) {
- Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id));
- return downloadThumbnail(clip);
- }
-}
diff --git a/src/main/java/com/ddf/vodsystem/services/EditService.java b/src/main/java/com/ddf/vodsystem/services/EditService.java
deleted file mode 100644
index 62d7559..0000000
--- a/src/main/java/com/ddf/vodsystem/services/EditService.java
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.ddf.vodsystem.services;
-
-import com.ddf.vodsystem.dto.JobStatus;
-import com.ddf.vodsystem.dto.ClipOptions;
-import com.ddf.vodsystem.dto.Job;
-import com.ddf.vodsystem.services.media.MetadataService;
-import org.springframework.stereotype.Service;
-
-@Service
-public class EditService {
- private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(EditService.class);
- private final MetadataService metadataService;
- private final JobRegistryService jobRegistryService;
- private final JobOrchestrationService jobOrchestrationService;
-
- public EditService(JobOrchestrationService jobOrchestrationService,
- MetadataService metadataService,
- JobRegistryService jobRegistryService) {
- this.metadataService = metadataService;
- this.jobRegistryService = jobRegistryService;
- this.jobOrchestrationService = jobOrchestrationService;
-
- }
-
- public void edit(String uuid, ClipOptions clipOptions) {
- Job job = jobRegistryService.getJob(uuid);
- metadataService.validateMetadata(job.getInputClipOptions(), clipOptions);
- job.setOutputClipOptions(clipOptions);
-
- logger.info("Job {} - Updated clip config to {}", job.getUuid(), clipOptions);
- }
-
- public void process(String uuid) {
- Job job = jobRegistryService.getJob(uuid);
- jobOrchestrationService.processJob(job);
-
- logger.info("Job {} - Started processing", uuid);
- }
-
- public void convert(String uuid) {
- Job job = jobRegistryService.getJob(uuid);
- jobOrchestrationService.convertJob(job);
-
- logger.info("Job {} - Started converting", uuid);
- }
-
- public JobStatus getStatus(String uuid) {
- Job job = jobRegistryService.getJob(uuid);
- return job.getStatus();
- }
-}
diff --git a/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java b/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java
deleted file mode 100644
index 09e577b..0000000
--- a/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java
+++ /dev/null
@@ -1,156 +0,0 @@
-package com.ddf.vodsystem.services;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.util.Optional;
-
-import com.ddf.vodsystem.dto.Job;
-import com.ddf.vodsystem.entities.User;
-import com.ddf.vodsystem.exceptions.StorageException;
-import com.ddf.vodsystem.services.media.CompressionService;
-import com.ddf.vodsystem.services.media.RemuxService;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.stereotype.Service;
-
-/**
- * Orchestrates the processing pipeline for a {@link Job}: converting (remuxing)
- * the input file and, separately, compressing/persisting the output clip.
- *
- * Job storage and lookup are handled by {@link JobRegistryService}; this class
- * is only responsible for acting on a {@link Job} instance once it's been retrieved.
- * Long-running steps (remux, compress) are kicked off asynchronously via
- * {@link java.util.concurrent.CompletableFuture}-returning service calls rather
- * than blocked on, so callers should not assume the job is finished when these
- * methods return.
- */
-@Service
-public class JobOrchestrationService {
- private static final Logger logger = LoggerFactory.getLogger(JobOrchestrationService.class);
- private final ClipService clipService;
- private final RemuxService remuxService;
- private final DirectoryService directoryService;
- private final CompressionService compressionService;
- private final UserService userService;
-
- /**
- * Constructs the orchestration service with its collaborating services.
- *
- * @param clipService used to persist a completed clip's metadata once processing succeeds
- * @param remuxService performs the remux step during conversion
- * @param compressionService performs the compression step during processing
- * @param directoryService used for filesystem operations (e.g. copying the input file to a temp location)
- * @param userService used to resolve the currently logged-in user when persisting a clip
- */
- public JobOrchestrationService(ClipService clipService,
- RemuxService remuxService,
- CompressionService compressionService,
- DirectoryService directoryService,
- UserService userService) {
- this.clipService = clipService;
- this.remuxService = remuxService;
- this.directoryService = directoryService;
- this.compressionService = compressionService;
- this.userService = userService;
- }
-
- /**
- * Converts a job's input file in place by remuxing it.
- *
- * The original input file is first copied to a temporary {@code .temp} file so the
- * remux can read from a stable source; on successful completion the temp file is
- * deleted. The remux itself runs asynchronously — this method returns once the
- * remux has been kicked off, not once it has finished. Progress/completion is
- * tracked via {@code job.getStatus().getConversion()}, which is reset at the
- * start of this call and marked complete in the remux's completion callback.
- *
- * @param job the job whose input file should be converted
- * @throws StorageException if the temporary file cannot be created, or if it
- * cannot be deleted after a successful remux
- */
- public void convertJob(Job job) {
- logger.info("Converting job: {}", job.getUuid());
- File tempFile = new File(job.getInputFile().getAbsolutePath() + ".temp");
-
- try {
- directoryService.copyFile(job.getInputFile(), tempFile);
- } catch (IOException e) {
- throw new StorageException("Could not create temporary file: " + tempFile, e);
- }
-
- job.getStatus().getConversion().reset();
-
- try {
- remuxService.remux(
- tempFile,
- job.getInputFile(),
- job.getStatus().getConversion(),
- job.getInputClipOptions().getDuration())
- .thenRun(() -> {
- job.getStatus().getConversion().markComplete();
-
- try {
- Files.deleteIfExists(Paths.get(tempFile.getAbsolutePath()));
- } catch (IOException e) {
- throw new StorageException("Could not delete path at: " + tempFile.getAbsolutePath(), e);
- }
- }).whenComplete((ignored, throwable) -> {
- if (throwable != null) {
- logger.error("Remux failed for jobId={}", job.getUuid(), throwable);
- } else {
- logger.info("Remux completed for jobId={}", job.getUuid());
- }
- });
-
- } catch (IOException | InterruptedException e) {
- logger.error("Error converting job {}: {}", job.getUuid(), e.getMessage());
- Thread.currentThread().interrupt();
- }
- }
-
- /**
- * Compresses a job's output file according to its output clip options and, on
- * success, persists the resulting clip against the currently logged-in user (if any).
- *
- * Compression runs asynchronously — this method returns once compression has been
- * kicked off, not once it has finished. Progress is tracked via
- * {@code job.getStatus().getProcess()}, which is reset at the start of this call.
- * If compression or persistence fails, the job's status is marked failed with the
- * triggering exception's message.
- *
- * @param job the job to process
- */
- public void processJob(Job job) {
- logger.info("Job ready: {}", job.getUuid());
- job.getStatus().getProcess().reset();
-
- try {
- Optional user = userService.getLoggedInUser();
- compressionService.compress(job.getInputFile(), job.getOutputFile(), job.getOutputClipOptions(), job.getStatus().getProcess())
- .thenRun(() -> user.ifPresent(userVal ->
- clipService.persistClip(
- job.getOutputClipOptions().getTitle(),
- job.getOutputClipOptions().getDescription(),
- userVal,
- job.getOutputFile(),
- job.getInputFile().getName()
- )
- )).exceptionally(
- ex -> {
- job.getStatus().setFailed(true);
- job.getStatus().setFailedReason(ex.getMessage());
- logger.error("Error processing job {}: {}", job.getUuid(), ex.getMessage());
- return null;
- }
- );
- } catch (IOException | InterruptedException e) {
- job.getStatus().setFailed(true);
- job.getStatus().setFailedReason(e.getMessage());
- logger.error("Error processing job {}: {}", job.getUuid(), e.getMessage());
- Thread.currentThread().interrupt();
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java b/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java
index 266ea7e..c4b7d4b 100644
--- a/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java
+++ b/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java
@@ -6,12 +6,12 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
+import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory store for {@link Job} instances, keyed by job UUID.
- * Provides add/lookup only — actual job processing is handled by
- * {@link JobOrchestrationService}.
+ * Provides add/lookup only
*/
@Service
public class JobRegistryService {
@@ -19,13 +19,16 @@ public class JobRegistryService {
private final ConcurrentHashMap jobs = new ConcurrentHashMap<>();
/**
- * Adds a new job to the job map.
- *
- * @param job the job to add
+ * Creates and returns a new job
+ * @return Job
*/
- public void add(Job job) {
- logger.info("Added job: {}", job.getUuid());
- jobs.put(job.getUuid(), job);
+ public Job generateJob() {
+ String uuid = UUID.randomUUID().toString();
+ Job job = new Job(uuid);
+
+ jobs.put(uuid, job);
+ logger.info("New job with UUID {} created", uuid);
+ return job;
}
/**
diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java
new file mode 100644
index 0000000..8a791a9
--- /dev/null
+++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java
@@ -0,0 +1,228 @@
+package com.ddf.vodsystem.services;
+
+import com.ddf.vodsystem.dto.ClipOptions;
+import com.ddf.vodsystem.dto.Job;
+import com.ddf.vodsystem.dto.JobState;
+import com.ddf.vodsystem.entities.User;
+import com.ddf.vodsystem.exceptions.NotAuthenticated;
+import com.ddf.vodsystem.services.media.CompressionService;
+import com.ddf.vodsystem.services.media.StreamActionsService;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.nio.file.Files;
+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
+public class MediaService {
+ private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(MediaService.class);
+ private final JobRegistryService jobRegistryService;
+ private final DirectoryService directoryService;
+ private final CompressionService compressionService;
+ private final UserService userService;
+ private final ClipService clipService;
+
+ private static final float CLIP_MAX_LENGTH = 180;
+ private static final int HLS_SEGMENT_LENGTH = 3;
+ private final StreamActionsService streamActionsService;
+
+ public MediaService(JobRegistryService jobRegistryService,
+ DirectoryService directoryService,
+ CompressionService compressionService,
+ UserService userService,
+ ClipService clipService, StreamActionsService streamActionsService) {
+ this.jobRegistryService = jobRegistryService;
+ this.directoryService = directoryService;
+ this.compressionService = compressionService;
+ this.userService = userService;
+ this.clipService = clipService;
+ this.streamActionsService = streamActionsService;
+ }
+
+ public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException {
+ Job job = jobRegistryService.generateJob();
+
+ Optional user = userService.getLoggedInUser();
+ String filename = job.getUuid() + ".mp4";
+
+ Path tempInputFile = directoryService.getTempInputDir().resolve(filename);
+ Path tempOutputFile = directoryService.getTempOutputDir().resolve(filename);
+ directoryService.saveMultipartFile(tempInputFile, file);
+
+ job.setState(JobState.PROCESSING);
+ compressionService.compress(
+ tempInputFile,
+ tempOutputFile,
+ clipOptions,
+ job.getProgressTracker()
+ ).thenRun(() -> {
+ job.setDownload(tempOutputFile);
+ job.setState(JobState.SUCCEEDED);
+ logger.info("Compression job {} succeeded", job.getUuid());
+
+ // save clip if user context
+ user.ifPresent(userVal ->
+ clipService.persistClip(
+ clipOptions.getTitle(),
+ clipOptions.getDescription(),
+ userVal,
+ tempOutputFile,
+ filename
+ )
+ );
+ }).exceptionally(ex -> {
+ job.setState(JobState.FAILED);
+ job.setErrorOutput(ex.getMessage());
+ logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
+ return null;
+ });
+
+ return job;
+ }
+
+ /**
+ * Saves a clip of the currently authenticated user's stream between two points in time.
+ *
+ * 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}.
+ *
+ * @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
+ * @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 {
+ if (startTime.isAfter(endTime)) {
+ throw new IllegalArgumentException("Start time must be before end time");
+ }
+
+ 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);
+
+ 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);
+ 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;
+ }
+
+ /**
+ * Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
+ *
+ * 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)}.
+ *
+ * @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]},
+ * 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 {
+ 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();
+ 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);
+
+ 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);
+
+ Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
+ streamActionsService.saveSection(fileSegments, 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 -> {
+ job.setState(JobState.FAILED);
+ job.setErrorOutput(ex.getMessage());
+ logger.error("Clip save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
+ return null;
+ });
+
+ return job;
+ }
+
+ private List getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
+ long startMs = startTime.toEpochMilli();
+ long endMs = endTime.toEpochMilli();
+
+ try (Stream files = Files.list(streamDirectory)) {
+ return files
+ .filter(p -> p.getFileName().toString().endsWith(".ts"))
+ .filter(p -> {
+ long segMs = parseTimestampMs(p);
+ return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
+ })
+ .sorted(Comparator.comparingLong(this::parseTimestampMs))
+ .collect(Collectors.toList());
+ }
+ }
+
+ private long parseTimestampMs(Path path) {
+ String name = path.getFileName().toString().replace(".ts", "");
+ long value = Long.parseLong(name);
+ // nginx hls_fragment_naming system uses seconds; convert to ms
+ return value < 1_000_000_000_000L ? value * 1000L : value;
+ }
+}
diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java
index 309ffa9..4603511 100644
--- a/src/main/java/com/ddf/vodsystem/services/StreamService.java
+++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java
@@ -6,45 +6,28 @@ import com.ddf.vodsystem.exceptions.AlreadyStreaming;
import com.ddf.vodsystem.exceptions.KeyNotFound;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.StreamRepository;
-import com.ddf.vodsystem.services.media.StreamActionsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.io.File;
-import java.io.IOException;
-import java.time.Duration;
import java.time.Instant;
-import java.time.LocalDateTime;
import java.util.*;
-import java.util.stream.Collectors;
@Service
public class StreamService {
private static final Logger logger = LoggerFactory.getLogger(StreamService.class);
private static final int HEARTBEAT_TIMEOUT_SECONDS = 60;
- private static final float CLIP_MAX_LENGTH = 180;
- private static final int HLS_SEGMENT_LENGTH = 3;
private final StreamRepository streamRepository;
private final UserService userService;
- private final StreamActionsService streamActionsService;
- private final DirectoryService directoryService;
- private final ClipService clipService;
public StreamService(StreamRepository streamRepository,
- UserService userService,
- StreamActionsService streamActionsService,
- DirectoryService directoryService,
- ClipService clipService) {
+ UserService userService) {
this.streamRepository = streamRepository;
this.userService = userService;
- this.streamActionsService = streamActionsService;
- this.directoryService = directoryService;
- this.clipService = clipService;
}
public Stream startStream(String streamKey) {
@@ -54,7 +37,7 @@ public class StreamService {
throw new AlreadyStreaming("User " + user.getUsername() + " is already streaming.");
});
- LocalDateTime now = LocalDateTime.now();
+ Instant now = Instant.now();
Stream stream = new Stream();
stream.setUser(user);
stream.setStartDate(now);
@@ -69,7 +52,7 @@ public class StreamService {
.orElseThrow(() -> new IllegalStateException(
"No active stream found for user " + user.getUsername()));
- stream.setEndDate(LocalDateTime.now());
+ stream.setEndDate(Instant.now());
streamRepository.saveAndFlush(stream);
}
@@ -80,7 +63,7 @@ public class StreamService {
.orElseThrow(() -> new IllegalStateException(
"No active stream found for user " + user.getUsername()));
- stream.setLastSeen(LocalDateTime.now());
+ stream.setLastSeen(Instant.now());
streamRepository.saveAndFlush(stream);
}
@@ -111,100 +94,14 @@ public class StreamService {
return streamRepository.findByUser(streamUser);
}
- /**
- * Saves a clip of the currently authenticated user's stream between two points in time.
- *
- * 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}.
- *
- * @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
- * @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 void saveSection(Instant startTime, Instant endTime) throws IOException {
- if (startTime.isAfter(endTime)) {
- throw new IllegalArgumentException("Start time must be before end time");
- }
-
- User user = userService.getLoggedInUser()
- .orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
-
- File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
- List fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), 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);
- float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
-
- File outputFile = directoryService.getVodFile(user.getId(), UUID.randomUUID() + ".mp4");
- streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile);
- }
-
- /**
- * Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
- *
- * 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)}.
- *
- * @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]},
- * 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 void clip(float duration) 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();
- Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
-
- User user = userService.getLoggedInUser()
- .orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
-
- File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
- List fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), 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);
-
- File outputFile = directoryService.getTempOutputFile(UUID.randomUUID() + ".mp4");
- streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile)
- .thenRun(() ->
- clipService.persistClip(
- Instant.now().toString(),
- "",
- user,
- outputFile,
- outputFile.getName()
- )
- );
- }
-
@Scheduled(fixedDelay = 30_000)
@Transactional
public void endStaleStreams() {
- LocalDateTime cutoff = LocalDateTime.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS);
+ Instant cutoff = Instant.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS);
List stale = streamRepository.findByEndDateIsNullAndLastSeenBefore(cutoff);
if (stale.isEmpty()) return;
- LocalDateTime now = LocalDateTime.now();
+ Instant now = Instant.now();
for (Stream stream : stale) {
stream.setEndDate(now);
logger.warn("Stream {} for user {} ended due to heartbeat timeout", stream.getId(), stream.getUser().getUsername());
@@ -216,28 +113,4 @@ public class StreamService {
return userService.getUserByStreamKey(streamKey)
.orElseThrow(() -> new KeyNotFound("Stream key not found: " + streamKey));
}
-
- private List getSegmentsInRange(String streamDirectory, Instant startTime, Instant endTime) {
- File dir = new File(streamDirectory);
- File[] tsFiles = dir.listFiles((d, name) -> name.endsWith(".ts"));
- if (tsFiles == null) return List.of();
-
- long startMs = startTime.toEpochMilli();
- long endMs = endTime.toEpochMilli();
-
- return Arrays.stream(tsFiles)
- .filter(f -> {
- long segMs = parseTimestampMs(f);
- return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
- })
- .sorted(Comparator.comparingLong(this::parseTimestampMs))
- .collect(Collectors.toList());
- }
-
- private long parseTimestampMs(File file) {
- String name = file.getName().replace(".ts", "");
- long value = Long.parseLong(name);
- // nginx hls_fragment_naming system uses seconds; convert to ms
- return value < 1_000_000_000_000L ? value * 1000L : value;
- }
}
diff --git a/src/main/java/com/ddf/vodsystem/services/UploadService.java b/src/main/java/com/ddf/vodsystem/services/UploadService.java
deleted file mode 100644
index c25bc7a..0000000
--- a/src/main/java/com/ddf/vodsystem/services/UploadService.java
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.ddf.vodsystem.services;
-
-import com.ddf.vodsystem.dto.Job;
-import com.ddf.vodsystem.dto.ClipOptions;
-import com.ddf.vodsystem.exceptions.FFMPEGException;
-import com.ddf.vodsystem.exceptions.StorageException;
-import com.ddf.vodsystem.services.media.MetadataService;
-import org.springframework.stereotype.Service;
-import org.springframework.web.multipart.MultipartFile;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.ByteBuffer;
-import java.util.Base64;
-import java.util.UUID;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Service
-public class UploadService {
- private static final Logger logger = LoggerFactory.getLogger(UploadService.class);
-
- private final JobRegistryService jobRegistryService;
- private final MetadataService metadataService;
- private final DirectoryService directoryService;
-
- public UploadService(JobRegistryService jobRegistryService,
- MetadataService metadataService,
- DirectoryService directoryService) {
- this.jobRegistryService = jobRegistryService;
- this.metadataService = metadataService;
- this.directoryService = directoryService;
- }
-
- public String upload(MultipartFile file) {
- // generate uuid, filename
- String uuid = generateShortUUID();
- String extension = directoryService.getFileExtension(file.getOriginalFilename());
-
- File inputFile = directoryService.getTempInputFile(uuid + "." + extension);
- File outputFile = directoryService.getTempOutputFile(uuid + ".mp4");
-
- try {
- directoryService.saveMultipartFile(inputFile, file);
- } catch (IOException e) {
- throw new StorageException("Could not save Multipart file at: " + inputFile, e);
- }
-
- logger.info("Multi-part file uploaded at {}", inputFile);
-
- ClipOptions clipOptions = getMetadataWithTimeout(inputFile);
- Job job = new Job(uuid, inputFile, outputFile, clipOptions);
- jobRegistryService.add(job);
-
- return uuid;
- }
-
- private static String generateShortUUID() {
- UUID uuid = UUID.randomUUID();
- ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
- bb.putLong(uuid.getMostSignificantBits());
- bb.putLong(uuid.getLeastSignificantBits());
- return Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array());
- }
-
- private ClipOptions getMetadataWithTimeout(File file) {
- try {
- return metadataService.getVideoMetadata(file).get(5, TimeUnit.SECONDS);
- } catch (ExecutionException | TimeoutException | InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new FFMPEGException(e.getMessage());
- }
- }
-
-}
diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java
index 51beeaf..ae5d28c 100644
--- a/src/main/java/com/ddf/vodsystem/services/UserService.java
+++ b/src/main/java/com/ddf/vodsystem/services/UserService.java
@@ -17,7 +17,7 @@ import org.springframework.stereotype.Service;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
-import java.time.LocalDateTime;
+import java.time.Instant;
import java.util.Collections;
import java.util.HexFormat;
import java.util.Optional;
@@ -77,7 +77,7 @@ public class UserService {
if (existingUser.isEmpty()) {
user.setRole(0);
- user.setCreatedAt(LocalDateTime.now());
+ user.setCreatedAt(Instant.now());
user.setStreamKey(generateStreamKey());
return userRepository.saveAndFlush(user);
}
diff --git a/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java b/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java
index 25b3a3e..e3478d5 100644
--- a/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java
+++ b/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java
@@ -1,14 +1,19 @@
package com.ddf.vodsystem.services.media;
import com.ddf.vodsystem.dto.CommandOutput;
+import com.ddf.vodsystem.dto.ProgressTracker;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.List;
import java.util.function.Consumer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
public class CommandRunner {
+ private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
+
public static CommandOutput run(List command, Consumer onOutput) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
@@ -40,6 +45,14 @@ public class CommandRunner {
return run(command, null);
}
+ public static void setProgress(String line, ProgressTracker progress, float length) {
+ Matcher matcher = timePattern.matcher(line);
+ if (matcher.find()) {
+ float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
+ progress.setProgress(timeInMs / length);
+ }
+ }
+
private CommandRunner() {
// Private constructor to prevent instantiation
}
diff --git a/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java
index 425c165..883bf3c 100644
--- a/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java
+++ b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java
@@ -8,8 +8,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
-import java.io.File;
import java.io.IOException;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -26,15 +26,19 @@ public class CompressionService {
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
@Async("ffmpegTaskExecutor")
- public CompletableFuture compress(File inputFile,
- File outputFile,
+ public CompletableFuture compress(Path inputFile,
+ Path outputFile,
ClipOptions clipOptions,
ProgressTracker progress
) throws IOException, InterruptedException {
- logger.info("Compressing video from {} to {}", inputFile.getAbsolutePath(), outputFile.getAbsolutePath());
+ logger.info("Compressing video from {} to {}", inputFile.toAbsolutePath(), outputFile.toAbsolutePath());
- List command = buildCommand(inputFile, outputFile, clipOptions);
- CommandOutput result = CommandRunner.run(command, line -> setProgress(line, progress, clipOptions.getDuration()));
+ List command = buildCommand(
+ inputFile,
+ outputFile,
+ clipOptions
+ );
+ CommandOutput result = CommandRunner.run(command, line -> CommandRunner.setProgress(line, progress, clipOptions.getDuration()));
progress.markComplete();
return CompletableFuture.completedFuture(result);
@@ -93,14 +97,14 @@ public class CompressionService {
return command;
}
- private List buildInputs(File inputFile, Float startPoint, Float length) {
+ private List buildInputs(Path inputFile, Float startPoint, Float length) {
List command = new ArrayList<>();
command.add("-ss");
command.add(startPoint.toString());
command.add("-i");
- command.add(inputFile.getAbsolutePath());
+ command.add(inputFile.toAbsolutePath().toString());
command.add("-t");
command.add(Float.toString(length));
@@ -108,7 +112,7 @@ public class CompressionService {
return command;
}
- private List buildCommand(File inputFile, File outputFile, ClipOptions clipOptions) {
+ private List buildCommand(Path inputFile, Path outputFile, ClipOptions clipOptions) {
List command = new ArrayList<>();
command.add("ffmpeg");
command.add("-progress");
@@ -126,7 +130,7 @@ public class CompressionService {
}
// Output file
- command.add(outputFile.getAbsolutePath());
+ command.add(outputFile.toAbsolutePath().toString());
return command;
}
}
diff --git a/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java b/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java
index ea9290b..85f3330 100644
--- a/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java
+++ b/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java
@@ -10,8 +10,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
-import java.io.File;
import java.io.IOException;
+import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
@@ -21,8 +21,8 @@ public class MetadataService {
private static final Logger logger = LoggerFactory.getLogger(MetadataService.class);
@Async("ffmpegTaskExecutor")
- public Future getVideoMetadata(File file) {
- logger.info("Getting metadata for file {}", file.getAbsolutePath());
+ public Future getVideoMetadata(Path file) {
+ logger.info("Getting metadata for file {}", file);
List command = List.of(
"ffprobe",
@@ -30,7 +30,7 @@ public class MetadataService {
"-print_format", "json",
"-show_format", "-select_streams",
"v:0", "-show_entries", "stream=duration,width,height,r_frame_rate:format=size,duration",
- "-i", file.getAbsolutePath()
+ "-i", file.toAbsolutePath().toString()
);
ObjectMapper mapper = new ObjectMapper();
@@ -51,48 +51,6 @@ public class MetadataService {
}
}
- public void validateMetadata(ClipOptions inputFileMetadata, ClipOptions outputFileMetadata) {
- Float start = outputFileMetadata.getStartPoint();
- Float duration = outputFileMetadata.getDuration();
- Float fileSize = outputFileMetadata.getFileSize();
- Integer width = outputFileMetadata.getWidth();
- Integer height = outputFileMetadata.getHeight();
- Float fps = outputFileMetadata.getFps();
-
- if (start == null) {
- outputFileMetadata.setStartPoint(0f);
- }
-
- if (duration == null) {
- outputFileMetadata.setDuration(inputFileMetadata.getDuration());
- }
-
- if (start != null && start < 0) {
- throw new IllegalArgumentException("Start point cannot be negative");
- }
-
- if (duration != null && duration < 0) {
- throw new IllegalArgumentException("Duration cannot be negative");
- }
-
- if (fileSize != null && fileSize < 100) {
- throw new IllegalArgumentException("File size cannot be less than 100kb");
- }
-
- if (width != null && width < 1) {
- throw new IllegalArgumentException("Width cannot be less than 1");
- }
-
- if (height != null && height < 1) {
- throw new IllegalArgumentException("Height cannot be less than 1");
- }
-
- if (fps != null && fps < 1) {
- throw new IllegalArgumentException("FPS cannot be less than 1");
- }
- }
-
-
private ClipOptions parseVideoMetadata(JsonNode node) {
ClipOptions metadata = new ClipOptions();
metadata.setStartPoint(0f);
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 091c1bb..201cc22 100644
--- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java
+++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java
@@ -1,11 +1,12 @@
package com.ddf.vodsystem.services.media;
+import com.ddf.vodsystem.dto.ProgressTracker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
-import java.io.File;
+import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
@@ -46,15 +47,16 @@ public class StreamActionsService {
* failure
*/
@Async("ffmpegTaskExecutor")
- public CompletableFuture saveSection(
- List segments,
+ public CompletableFuture saveSection(
+ List segments,
float trimOffset,
float duration,
- File outputFile
+ Path outputFile,
+ ProgressTracker progressTracker
) {
try {
String concatInput = segments.stream()
- .map(File::getAbsolutePath)
+ .map(p -> p.toAbsolutePath().toString())
.collect(Collectors.joining("|"));
List command = List.of(
@@ -64,16 +66,17 @@ public class StreamActionsService {
"-ss", String.valueOf(trimOffset),
"-t", String.valueOf(duration),
"-c", "copy",
- outputFile.getAbsolutePath()
+ outputFile.toAbsolutePath().toString()
);
logger.info("Saving section with ({} segments) to '{}'",
- segments.size(), outputFile.getAbsolutePath());
- CommandRunner.run(command, line -> logger.debug("{}", line));
+ segments.size(), outputFile);
+ CommandRunner.run(command, line -> CommandRunner.setProgress(line, progressTracker, duration));
+ progressTracker.markComplete();
return CompletableFuture.completedFuture(outputFile);
} catch (Exception e) {
- logger.error("Failed to save section to '{}'", outputFile.getAbsolutePath(), e);
+ logger.error("Failed to save section to '{}'", outputFile, e);
return CompletableFuture.failedFuture(e);
}
}
diff --git a/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java b/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java
index 1ee0da7..7e11508 100644
--- a/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java
+++ b/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java
@@ -5,8 +5,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
-import java.io.File;
import java.io.IOException;
+import java.nio.file.Path;
import java.util.List;
@Service
@@ -14,15 +14,15 @@ public class ThumbnailService {
private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class);
@Async("ffmpegTaskExecutor")
- public void createThumbnail(File inputFile, File outputFile, Float timeInVideo) throws IOException, InterruptedException {
+ public void createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) throws IOException, InterruptedException {
logger.info("Creating thumbnail at {} seconds", timeInVideo);
List command = List.of(
"ffmpeg",
"-ss", timeInVideo.toString(),
- "-i", inputFile.getAbsolutePath(),
+ "-i", inputFile.toAbsolutePath().toString(),
"-frames:v", "1",
- outputFile.getAbsolutePath()
+ outputFile.toAbsolutePath().toString()
);
CommandRunner.run(command);
diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties
index 7b02d27..ec2e735 100644
--- a/src/main/resources/application-local.properties
+++ b/src/main/resources/application-local.properties
@@ -1 +1,6 @@
-spring.docker.compose.file=docker-compose.dev.yml
\ No newline at end of file
+spring.docker.compose.file=docker-compose.dev.yml
+
+media.temp.inputs=./videos/inputs
+media.temp.outputs=./videos/outputs
+media.users=${MEDIA_DATA}
+media.streams=${STREAM_DATA}
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 38aa1bd..9701c07 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -3,9 +3,6 @@ spring.application.name=vodSystem
# VODs
spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB
-storage.temp.inputs=videos/inputs/
-storage.temp.outputs=videos/outputs/
-storage.outputs=${MEDIA_DATA}
# Database
spring.datasource.url=${POSTGRES_URL}
@@ -21,15 +18,20 @@ spring.sql.init.schema-locations=classpath:db/schema.sql
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=profile,email
-
jwt.secret.key=${JWT_SECRET_KEY}
jwt.expiration=3600000
# Frontend
frontend.url=${FRONTEND_URL}
-# Stream HLS
-storage.stream=${STREAM_DATA}
+# Paths
+media.temp.inputs=/tmp/inputs/
+media.temp.outputs=/tmp/outputs/
+media.users=/app/media/
+media.streams=/app/stream/
## Server Configuration
-server.servlet.session.timeout=30m
\ No newline at end of file
+server.servlet.session.timeout=30m
+
+# Serialize Instant as ISO-8601 UTC string (not epoch seconds)
+spring.jackson.serialization.write-dates-as-timestamps=false
\ No newline at end of file
diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql
index d77fd70..2cc5bab 100644
--- a/src/main/resources/db/schema.sql
+++ b/src/main/resources/db/schema.sql
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS users (
name VARCHAR(100) NOT NULL,
profile_picture_url VARCHAR(255),
role INTEGER DEFAULT 0,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
stream_key VARCHAR(64) NOT NULL UNIQUE
);
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS clips (
user_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
width INTEGER NOT NULL,
height INTEGER NOT NULL,
fps FLOAT NOT NULL,