user = userService.getLoggedInUser();
+
+ if (user.isEmpty()) {
+ throw new NotAuthenticated("Log in to see user streams");
+ }
+
+ if (!user.get().equals(streamUser)) {
+ throw new NotAuthenticated("You are not authenticated to see these user streams");
+ }
+
+ 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
+ * @throws InterruptedException if the thread is interrupted while performing the save
+ */
+ public void saveSection(Instant startTime, Instant endTime) throws IOException, InterruptedException {
+ 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"));
+
+ String streamDirectory = streamDataPath + File.separator + 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;
+
+ File outputFile = directoryService.getVodFile(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
+ * @throws InterruptedException if the thread is interrupted while performing the save
+ */
+ public void clip(float duration) throws IOException, InterruptedException {
+ 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)));
+
+ saveSection(startTime, endTime);
+ }
+
+ @Scheduled(fixedDelay = 30_000)
+ @Transactional
+ public void endStaleStreams() {
+ LocalDateTime cutoff = LocalDateTime.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS);
+ List stale = streamRepository.findByEndDateIsNullAndLastSeenBefore(cutoff);
+ if (stale.isEmpty()) return;
+
+ LocalDateTime now = LocalDateTime.now();
+ for (Stream stream : stale) {
+ stream.setEndDate(now);
+ logger.warn("Stream {} for user {} ended due to heartbeat timeout", stream.getId(), stream.getUser().getUsername());
+ }
+ streamRepository.saveAllAndFlush(stale);
+ }
+
+ private User resolveUser(String streamKey) {
+ 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/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java
index 5c63d80..7a17e06 100644
--- a/src/main/java/com/ddf/vodsystem/services/UserService.java
+++ b/src/main/java/com/ddf/vodsystem/services/UserService.java
@@ -16,8 +16,10 @@ import org.springframework.stereotype.Service;
import java.io.IOException;
import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Collections;
+import java.util.HexFormat;
import java.util.Optional;
@Service
@@ -67,12 +69,17 @@ public class UserService {
return jwtService.generateToken(user.getId());
}
+ public Optional getUserByStreamKey(String streamKey) {
+ return userRepository.findByStreamKey(streamKey);
+ }
+
private User createOrUpdateUser(User user) {
Optional existingUser = userRepository.findByGoogleId(user.getGoogleId());
if (existingUser.isEmpty()) {
user.setRole(0);
user.setCreatedAt(LocalDateTime.now());
+ user.setStreamKey(generateStreamKey());
return userRepository.saveAndFlush(user);
}
@@ -107,4 +114,10 @@ public class UserService {
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
}
}
+
+ private String generateStreamKey() {
+ byte[] bytes = new byte[24];
+ new SecureRandom().nextBytes(bytes);
+ return HexFormat.of().formatHex(bytes);
+ }
}
diff --git a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java
new file mode 100644
index 0000000..00a6f33
--- /dev/null
+++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java
@@ -0,0 +1,54 @@
+package com.ddf.vodsystem.services.media;
+
+import com.ddf.vodsystem.services.DirectoryService;
+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.io.IOException;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+
+@Service
+public class StreamActionsService {
+ private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class);
+
+ private final DirectoryService directoryService;
+
+ public StreamActionsService(DirectoryService directoryService) {
+ this.directoryService = directoryService;
+ }
+
+ @Async("ffmpegTaskExecutor")
+ public CompletableFuture saveSection(
+ List segments,
+ float trimOffset,
+ float duration,
+ File outputFile
+ ) throws IOException, InterruptedException {
+ String concatInput = segments.stream()
+ .map(File::getAbsolutePath)
+ .collect(Collectors.joining("|"));
+
+ List command = List.of(
+ "ffmpeg",
+ "-y",
+ "-i", "concat:" + concatInput,
+ "-ss", String.valueOf(trimOffset),
+ "-t", String.valueOf(duration),
+ "-c", "copy",
+ outputFile.getAbsolutePath()
+ );
+
+ logger.info("Saving section with ({} segments) to '{}'",
+ segments.size(), outputFile.getAbsolutePath());
+ CommandRunner.run(command, line -> logger.debug("{}", line));
+
+ return CompletableFuture.completedFuture(outputFile);
+ }
+
+}
diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties
index 0325cb2..7b02d27 100644
--- a/src/main/resources/application-local.properties
+++ b/src/main/resources/application-local.properties
@@ -1,13 +1 @@
-# Database
-spring.datasource.url=jdbc:postgresql://postgres:5432/vodSystem
-spring.datasource.username=myuser
-spring.datasource.password=mypassword
-spring.datasource.driver-class-name=org.postgresql.Driver
-spring.jpa.hibernate.ddl-auto=update
-spring.jpa.show-sql=true
-spring.sql.init.mode=always
-spring.sql.init.schema-locations=classpath:db/schema.sql
-#spring.sql.init.data-locations=classpath:db/data.sql
-
-# Frontend
-frontend.url=http://localhost:5173
+spring.docker.compose.file=docker-compose.dev.yml
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index b3ac3a0..38aa1bd 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,12 +1,21 @@
spring.application.name=vodSystem
-spring.profiles.active=local
# 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=outputs/
+storage.outputs=${MEDIA_DATA}
+
+# Database
+spring.datasource.url=${POSTGRES_URL}
+spring.datasource.username=${POSTGRES_USER}
+spring.datasource.password=${POSTGRES_PASSWORD}
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.jpa.hibernate.ddl-auto=update
+spring.jpa.show-sql=true
+spring.sql.init.mode=always
+spring.sql.init.schema-locations=classpath:db/schema.sql
## Security
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
@@ -16,5 +25,11 @@ 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}
+
## Server Configuration
server.servlet.session.timeout=30m
\ No newline at end of file
diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql
index d8b1362..d77fd70 100644
--- a/src/main/resources/db/schema.sql
+++ b/src/main/resources/db/schema.sql
@@ -1,5 +1,6 @@
-DROP TABLE IF EXISTS clips;
-DROP TABLE IF EXISTS users;
+-- DROP TABLE IF EXISTS streams;
+-- DROP TABLE IF EXISTS clips;
+-- DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
@@ -9,8 +10,19 @@ 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 TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ stream_key VARCHAR(64) NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS streams (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT,
+ start_time TIMESTAMPTZ DEFAULT NOW(),
+ end_time TIMESTAMPTZ DEFAULT NULL,
+ last_seen TIMESTAMPTZ DEFAULT NULL,
+
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
CREATE TABLE IF NOT EXISTS clips (
id BIGSERIAL PRIMARY KEY,