diff --git a/nginx/Dockerfile b/nginx/Dockerfile index b35c59a..e88822e 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -1,11 +1,9 @@ FROM tiangolo/nginx-rtmp RUN apt-get update && apt-get install -y gettext-base && rm -rf /var/lib/apt/lists/* +COPY default.conf.template /etc/nginx/templates/default.conf.template +COPY docker-entrypoint.sh /docker-entrypoint.sh -COPY nginx.conf /etc/nginx/nginx.conf.template -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +RUN chmod +x /docker-entrypoint.sh -EXPOSE 1935 - -ENTRYPOINT ["/entrypoint.sh"] +ENTRYPOINT ["/docker-entrypoint.sh"] \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/default.conf.template similarity index 100% rename from nginx/nginx.conf rename to nginx/default.conf.template diff --git a/nginx/docker-entrypoint.sh b/nginx/docker-entrypoint.sh new file mode 100644 index 0000000..3d1c9c0 --- /dev/null +++ b/nginx/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +# must only run on certain env files, otherwise it replaces too much +envsubst '${WEB_SERVER_URL}' \ + < /etc/nginx/templates/default.conf.template \ + > /etc/nginx/nginx.conf + +# daemon off required otherwise the container would exit immediately after nginx forks to background. +exec nginx -g "daemon off;" \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java index d2f2ffb..81bf0e6 100644 --- a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java +++ b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java @@ -35,6 +35,7 @@ public class SecurityConfig { .requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll() .requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**", "/api/v1/convert/**").permitAll() .requestMatchers("/api/v1/metadata/**").permitAll() + .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .anyRequest().authenticated() ) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) diff --git a/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java b/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java index 90f520f..659f944 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java +++ b/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java @@ -1,9 +1,11 @@ package com.ddf.vodsystem.controllers; import com.ddf.vodsystem.dto.APIResponse; +import com.ddf.vodsystem.exceptions.AlreadyStreaming; import com.ddf.vodsystem.exceptions.FFMPEGException; import com.ddf.vodsystem.exceptions.JobNotFinished; import com.ddf.vodsystem.exceptions.JobNotFound; +import com.ddf.vodsystem.exceptions.KeyNotFound; import com.ddf.vodsystem.exceptions.NotAuthenticated; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -76,4 +78,18 @@ public class GlobalExceptionHandler { APIResponse response = new APIResponse<>(ERROR, ex.getMessage(), null); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); } + + @ExceptionHandler(KeyNotFound.class) + public ResponseEntity> handleKeyNotFound(KeyNotFound ex) { + logger.error("KeyNotFound: {}", ex.getMessage()); + APIResponse response = new APIResponse<>(ERROR, ex.getMessage(), null); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + @ExceptionHandler(AlreadyStreaming.class) + public ResponseEntity> handleAlreadyStreaming(AlreadyStreaming ex) { + logger.error("AlreadyStreaming: {}", ex.getMessage()); + APIResponse response = new APIResponse<>(ERROR, ex.getMessage(), null); + return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + } } \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java new file mode 100644 index 0000000..819ee60 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java @@ -0,0 +1,52 @@ +package com.ddf.vodsystem.controllers; + +import com.ddf.vodsystem.dto.APIResponse; +import com.ddf.vodsystem.entities.Stream; +import com.ddf.vodsystem.services.StreamService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/stream") +public class StreamController { + private static final Logger logger = LoggerFactory.getLogger(StreamController.class); + + private final StreamService streamService; + + public StreamController(StreamService streamService) { + this.streamService = streamService; + } + + // Called by nginx on_publish + @PostMapping("/start") + public ResponseEntity> startStream(@RequestParam("name") String streamKey) { + logger.info("Stream start callback for key: {}", streamKey); + Stream stream = streamService.startStream(streamKey); + return ResponseEntity.ok(new APIResponse<>("success", "Stream started", stream)); + } + + // Called by nginx on_publish_done + @PostMapping("/stop") + public ResponseEntity> stopStream(@RequestParam("name") String streamKey) { + logger.info("Stream stop callback for key: {}", streamKey); + streamService.endStream(streamKey); + return ResponseEntity.ok(new APIResponse<>("success", "Stream ended", null)); + } + + // Called by nginx on_update + @PostMapping("/heartbeat") + public ResponseEntity> heartbeat(@RequestParam("name") String streamKey) { + streamService.heartbeatStream(streamKey); + return ResponseEntity.ok(new APIResponse<>("success", "Heartbeat recorded", null)); + } + + @GetMapping("/history/{userId}") + public ResponseEntity>> getStreamHistory(@PathVariable Long userId) { + List history = streamService.getStreamHistory(userId); + return ResponseEntity.ok(new APIResponse<>("success", "Stream history retrieved", history)); + } +} diff --git a/src/main/java/com/ddf/vodsystem/exceptions/AlreadyStreaming.java b/src/main/java/com/ddf/vodsystem/exceptions/AlreadyStreaming.java new file mode 100644 index 0000000..6464a06 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/exceptions/AlreadyStreaming.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.exceptions; + +public class AlreadyStreaming extends RuntimeException { + public AlreadyStreaming(String message) { + super(message); + } +} diff --git a/src/main/java/com/ddf/vodsystem/exceptions/KeyNotFound.java b/src/main/java/com/ddf/vodsystem/exceptions/KeyNotFound.java new file mode 100644 index 0000000..402e370 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/exceptions/KeyNotFound.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.exceptions; + +public class KeyNotFound extends RuntimeException { + public KeyNotFound(String message) { + super(message); + } +} diff --git a/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java index ecfb88e..a494f5f 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java @@ -1,9 +1,15 @@ package com.ddf.vodsystem.repositories; import com.ddf.vodsystem.entities.Stream; +import com.ddf.vodsystem.entities.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository public interface StreamRepository extends JpaRepository { + Optional findByUserAndEndDateIsNull(User user); + List findByUser(User user); } diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index dc6e471..23e2d9e 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -1,24 +1,77 @@ package com.ddf.vodsystem.services; +import com.ddf.vodsystem.entities.Stream; import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.exceptions.AlreadyStreaming; +import com.ddf.vodsystem.exceptions.KeyNotFound; import com.ddf.vodsystem.repositories.StreamRepository; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service public class StreamService { - private StreamRepository streamRepository; + private final StreamRepository streamRepository; + private final UserService userService; - public StreamService(StreamRepository streamRepository) { + public StreamService(StreamRepository streamRepository, UserService userService) { this.streamRepository = streamRepository; + this.userService = userService; } - public void startStream(User user) { + public Stream startStream(String streamKey) { + User user = resolveUser(streamKey); + streamRepository.findByUserAndEndDateIsNull(user).ifPresent(existing -> { + throw new AlreadyStreaming("User " + user.getUsername() + " is already streaming."); + }); + + LocalDateTime now = LocalDateTime.now(); + Stream stream = new Stream(); + stream.setUser(user); + stream.setStartDate(now); + stream.setLastSeen(now); + return streamRepository.saveAndFlush(stream); } - public void endStream(User user) { + public void endStream(String streamKey) { + User user = resolveUser(streamKey); + Stream stream = streamRepository.findByUserAndEndDateIsNull(user) + .orElseThrow(() -> new IllegalStateException( + "No active stream found for user " + user.getUsername())); + + + stream.setEndDate(LocalDateTime.now()); + streamRepository.saveAndFlush(stream); } - public void heartbeatStream(User user) { + public void heartbeatStream(String streamKey) { + User user = resolveUser(streamKey); + Stream stream = streamRepository.findByUserAndEndDateIsNull(user) + .orElseThrow(() -> new IllegalStateException( + "No active stream found for user " + user.getUsername())); + + stream.setLastSeen(LocalDateTime.now()); + streamRepository.saveAndFlush(stream); + } + + public Optional getActiveStream(String streamKey) { + User user = resolveUser(streamKey); + return streamRepository.findByUserAndEndDateIsNull(user); + } + + public List getStreamHistory(Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + return streamRepository.findByUser(user); + } + + private User resolveUser(String streamKey) { + return userService.getUserByStreamKey(streamKey) + .orElseThrow(() -> new KeyNotFound("Stream key not found: " + streamKey)); } } diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index ce9f586..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 @@ -77,6 +79,7 @@ public class UserService { if (existingUser.isEmpty()) { user.setRole(0); user.setCreatedAt(LocalDateTime.now()); + user.setStreamKey(generateStreamKey()); return userRepository.saveAndFlush(user); } @@ -111,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/resources/db/schema.sql b/src/main/resources/db/schema.sql index a3a73da..f400dbf 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS users ( profile_picture_url VARCHAR(255), role INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - stream_key VARCHAR(64) + stream_key VARCHAR(64) NOT NULL ); CREATE TABLE IF NOT EXISTS streams (