diff --git a/.env.example b/.env.example index 6083238..a4e6dd5 100644 --- a/.env.example +++ b/.env.example @@ -15,4 +15,6 @@ POSTGRES_PASSWORD=mypassword POSTGRES_DB=vodSystem FRONTEND_URL= -BACKEND_URL= \ No newline at end of file +BACKEND_URL= + +STREAM_RING_LENGTH_SECOND=28800 \ No newline at end of file diff --git a/bruno/VoD-System/Markers/Create Marker.bru b/bruno/VoD-System/Markers/Create Marker.bru new file mode 100644 index 0000000..426eee7 --- /dev/null +++ b/bruno/VoD-System/Markers/Create Marker.bru @@ -0,0 +1,22 @@ +meta { + name: Create Marker + type: http + seq: 3 +} + +post { + url: {{base_url}}/markers + body: json + auth: inherit +} + +body:json { + { + "message": "Nice play" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Markers/Get Marker.bru b/bruno/VoD-System/Markers/Get Marker.bru new file mode 100644 index 0000000..865af8c --- /dev/null +++ b/bruno/VoD-System/Markers/Get Marker.bru @@ -0,0 +1,16 @@ +meta { + name: Get Marker + type: http + seq: 1 +} + +get { + url: {{base_url}}/markers/5 + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Markers/Get all markers.bru b/bruno/VoD-System/Markers/Get all markers.bru new file mode 100644 index 0000000..d26e200 --- /dev/null +++ b/bruno/VoD-System/Markers/Get all markers.bru @@ -0,0 +1,11 @@ +meta { + name: Get all markers + type: http + seq: 2 +} + +get { + url: {{base_url}}/markers + body: none + auth: inherit +} diff --git a/bruno/VoD-System/Markers/folder.bru b/bruno/VoD-System/Markers/folder.bru new file mode 100644 index 0000000..01e9479 --- /dev/null +++ b/bruno/VoD-System/Markers/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Markers + seq: 6 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Media/Save Stream Section By Markers.bru b/bruno/VoD-System/Media/Save Stream Section By Markers.bru new file mode 100644 index 0000000..9b5f6b8 --- /dev/null +++ b/bruno/VoD-System/Media/Save Stream Section By Markers.bru @@ -0,0 +1,24 @@ +meta { + name: Save Stream Section By Markers + type: http + seq: 4 +} + +post { + url: {{base_url}}/media/save/markers + body: json + auth: inherit +} + +body:json { + { + "startMarkerId": 1, + "endMarkerId": 2, + "title": "Hello", + "description": "my clip" + } +} + +script:post-response { + bru.setEnvVar("uuid", res.getBody().data.uuid); +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6cc3fbf..61149a9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,6 +22,8 @@ services: container_name: vod_rtmp extra_hosts: - "host.docker.internal:host-gateway" + env_file: + - .env environment: - WEB_SERVER_URL=http://host.docker.internal:8080 ports: diff --git a/docker-compose.yml b/docker-compose.yml index 201b8dd..cc38d19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,8 @@ services: context: ./nginx dockerfile: Dockerfile container_name: vod_rtmp + env_file: + - .env environment: - WEB_SERVER_URL=http://backend:8080 ports: diff --git a/nginx/default.conf.template b/nginx/default.conf.template index 1cc95aa..05fa14b 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -20,7 +20,7 @@ rtmp { hls_path /stream; hls_fragment_naming system; hls_fragment 3; - hls_playlist_length 28800; + hls_playlist_length ${STREAM_RING_LENGTH_SECOND}; on_publish ${WEB_SERVER_URL}/api/v1/stream/start; on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop; diff --git a/nginx/docker-entrypoint.sh b/nginx/docker-entrypoint.sh index 3d1c9c0..0859fb6 100644 --- a/nginx/docker-entrypoint.sh +++ b/nginx/docker-entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/sh # must only run on certain env files, otherwise it replaces too much -envsubst '${WEB_SERVER_URL}' \ +envsubst '${WEB_SERVER_URL} ${STREAM_RING_LENGTH_SECOND}' \ < /etc/nginx/templates/default.conf.template \ > /etc/nginx/nginx.conf diff --git a/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java b/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java index d5ecca9..a42e2e2 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java +++ b/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java @@ -108,4 +108,18 @@ public class GlobalExceptionHandler { APIResponse response = new APIResponse<>(ERROR, ex.getMessage(), null); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); } + + @ExceptionHandler(NotStreaming.class) + public ResponseEntity> handleNotStreaming(NotStreaming ex) { + logger.error("NotStreaming: {}", ex.getMessage()); + APIResponse response = new APIResponse<>(ERROR, ex.getMessage(), null); + return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + } + + @ExceptionHandler(MarkerNotFound.class) + public ResponseEntity> handleMarkerNotFound(MarkerNotFound ex) { + logger.error("MarkerNotFound: {}", ex.getMessage()); + APIResponse response = new APIResponse<>(ERROR, ex.getMessage(), null); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } } \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/MarkerController.java b/src/main/java/com/ddf/vodsystem/controllers/MarkerController.java new file mode 100644 index 0000000..b10d276 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/MarkerController.java @@ -0,0 +1,63 @@ +package com.ddf.vodsystem.controllers; + +import com.ddf.vodsystem.controllers.dto.MarkerCreateRequest; +import com.ddf.vodsystem.controllers.dto.MarkerResponse; +import com.ddf.vodsystem.dto.APIResponse; +import com.ddf.vodsystem.entities.Marker; +import com.ddf.vodsystem.services.MarkerService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/markers") +public class MarkerController { + private static final String SUCCESS = "success"; + + private final MarkerService markerService; + + public MarkerController(MarkerService markerService) { + this.markerService = markerService; + } + + @GetMapping("") + public ResponseEntity>> getMarkers() { + List markers = markerService.getUserMarkers().stream() + .map(this::convertToDTO) + .toList(); + + return ResponseEntity.ok( + new APIResponse<>(SUCCESS, "Markers retrieved successfully", markers) + ); + } + + @GetMapping("/{id}") + public ResponseEntity> getMarkerById(@PathVariable Long id) { + Marker marker = markerService.getMarkerById(id); + + return ResponseEntity.ok( + new APIResponse<>(SUCCESS, "Marker retrieved successfully", convertToDTO(marker)) + ); + } + + @PostMapping("") + public ResponseEntity> createMarker(@Valid @RequestBody MarkerCreateRequest request) { + Marker marker = markerService.create(request.message()); + + return ResponseEntity.ok( + new APIResponse<>(SUCCESS, "Marker created successfully", convertToDTO(marker)) + ); + } + + private MarkerResponse convertToDTO(Marker marker) { + return new MarkerResponse( + marker.getId(), + marker.getUser().getId(), + marker.getStream().getId(), + marker.getMessage(), + marker.getTimestamp() + ); + } +} diff --git a/src/main/java/com/ddf/vodsystem/controllers/MediaController.java b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java index 0d5185f..77ff18e 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/MediaController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java @@ -1,6 +1,7 @@ package com.ddf.vodsystem.controllers; import com.ddf.vodsystem.controllers.dto.ClipSectionRequest; +import com.ddf.vodsystem.controllers.dto.SaveSectionByMarkerRequest; import com.ddf.vodsystem.controllers.dto.SaveSectionRequest; import com.ddf.vodsystem.controllers.dto.UUIDResponse; import com.ddf.vodsystem.dto.Job; @@ -54,6 +55,21 @@ public class MediaController { )); } + @PostMapping("/save/markers") + public ResponseEntity> saveByMarkers( + @Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException { + Job job = mediaService.saveSection( + saveSectionByMarkerRequest.startMarkerId(), + saveSectionByMarkerRequest.endMarkerId(), + saveSectionByMarkerRequest.title(), + saveSectionByMarkerRequest.description()); + return ResponseEntity.ok(new APIResponse<>( + SUCCESS, + "Section saving successfully started", + new UUIDResponse(job.getUuid()) + )); + } + @PostMapping("/clip") public ResponseEntity> clip( @RequestBody ClipSectionRequest clipSectionRequest) throws IOException { diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/MarkerCreateRequest.java b/src/main/java/com/ddf/vodsystem/controllers/dto/MarkerCreateRequest.java new file mode 100644 index 0000000..bcefad8 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/MarkerCreateRequest.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.controllers.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MarkerCreateRequest( + @NotBlank String message +) {} diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/MarkerResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/MarkerResponse.java new file mode 100644 index 0000000..54d2510 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/MarkerResponse.java @@ -0,0 +1,11 @@ +package com.ddf.vodsystem.controllers.dto; + +import java.time.Instant; + +public record MarkerResponse( + Long id, + Long userId, + Long streamId, + String message, + Instant timestamp +) {} diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/SaveSectionByMarkerRequest.java b/src/main/java/com/ddf/vodsystem/controllers/dto/SaveSectionByMarkerRequest.java new file mode 100644 index 0000000..d9fb862 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/SaveSectionByMarkerRequest.java @@ -0,0 +1,9 @@ +package com.ddf.vodsystem.controllers.dto; + +import jakarta.validation.constraints.NotNull; + +public record SaveSectionByMarkerRequest( + @NotNull Long startMarkerId, + @NotNull Long endMarkerId, + String title, + String description) {} diff --git a/src/main/java/com/ddf/vodsystem/entities/Marker.java b/src/main/java/com/ddf/vodsystem/entities/Marker.java new file mode 100644 index 0000000..5ff9ef7 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/entities/Marker.java @@ -0,0 +1,33 @@ +package com.ddf.vodsystem.entities; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.Data; + +import java.time.Instant; + +@Entity +@Table(name = "markers") +@Data +public class Marker { + @Id + @Column(name = "id", nullable = false, unique = true) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JsonIgnore + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JsonIgnore + @JoinColumn(name = "stream_id", nullable = false) + private Stream stream; + + @Column(name = "message", length = 255) + private String message; + + @Column(name = "timestamp") + private Instant timestamp; +} diff --git a/src/main/java/com/ddf/vodsystem/exceptions/MarkerNotFound.java b/src/main/java/com/ddf/vodsystem/exceptions/MarkerNotFound.java new file mode 100644 index 0000000..197c7d0 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/exceptions/MarkerNotFound.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.exceptions; + +public class MarkerNotFound extends RuntimeException { + public MarkerNotFound(String message) { + super(message); + } +} diff --git a/src/main/java/com/ddf/vodsystem/exceptions/NotStreaming.java b/src/main/java/com/ddf/vodsystem/exceptions/NotStreaming.java new file mode 100644 index 0000000..15cc395 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/exceptions/NotStreaming.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.exceptions; + +public class NotStreaming extends RuntimeException { + public NotStreaming(String message) { + super(message); + } +} diff --git a/src/main/java/com/ddf/vodsystem/repositories/MarkerRepository.java b/src/main/java/com/ddf/vodsystem/repositories/MarkerRepository.java new file mode 100644 index 0000000..d5b7d82 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/repositories/MarkerRepository.java @@ -0,0 +1,19 @@ +package com.ddf.vodsystem.repositories; + +import com.ddf.vodsystem.entities.Marker; +import com.ddf.vodsystem.entities.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +@Repository +public interface MarkerRepository extends JpaRepository { + @Query("SELECT m FROM Marker m WHERE m.user = ?1") + List findByUser(User user); + + @Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1") + List findAllBefore(Instant time); +} diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index 6000245..e0dfb43 100644 --- a/src/main/java/com/ddf/vodsystem/services/ClipService.java +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -174,7 +174,7 @@ public class ClipService { Path file = directoryService.resolvePath(path); if (!Files.exists(file)) { - throw new JobNotFound("Thumbnail file not found"); + throw new ClipNotFound("Thumbnail file not found"); } return new FileSystemResource(file); diff --git a/src/main/java/com/ddf/vodsystem/services/MarkerService.java b/src/main/java/com/ddf/vodsystem/services/MarkerService.java new file mode 100644 index 0000000..b747f6a --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/MarkerService.java @@ -0,0 +1,82 @@ +package com.ddf.vodsystem.services; + +import com.ddf.vodsystem.entities.Marker; +import com.ddf.vodsystem.entities.Stream; +import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.exceptions.MarkerNotFound; +import com.ddf.vodsystem.exceptions.NotAuthenticated; +import com.ddf.vodsystem.exceptions.NotStreaming; +import com.ddf.vodsystem.repositories.MarkerRepository; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Service +public class MarkerService { + + private final UserService userService; + private final StreamService streamService; + private final MarkerRepository markerRepository; + + @Value("${stream.max-length}") + private Long maxStreamLength; + + public MarkerService(UserService userService, StreamService streamService, MarkerRepository markerRepository) { + this.userService = userService; + this.streamService = streamService; + this.markerRepository = markerRepository; + } + + public Marker getMarkerById(Long id) { + Optional marker = markerRepository.findById(id); + + if (marker.isEmpty()) { + throw new MarkerNotFound("Marker does not exist"); + } + + Optional user = userService.getLoggedInUser(); + if (user.isEmpty() || !user.get().equals(marker.get().getUser())) { + throw new NotAuthenticated("User not authenticated for this marker"); + } + + return marker.get(); + } + + public List getUserMarkers() { + Optional user = userService.getLoggedInUser(); + + if (user.isEmpty()) { + throw new NotAuthenticated("Must be logged in to get markers"); + } + + return markerRepository.findByUser(user.get()); + } + + public Marker create(String message) { + Optional stream = streamService.getActiveStream(); + + if (stream.isEmpty()) { + throw new NotStreaming("User must be streaming to mark"); + } + + Marker marker = new Marker(); + marker.setStream(stream.get()); + marker.setUser(stream.get().getUser()); + marker.setMessage(message); + marker.setTimestamp(Instant.now()); + return markerRepository.saveAndFlush(marker); + } + + @Scheduled(fixedDelay = 360_000) + @Transactional + public void deleteOldMarkers() { + Instant cutoff = Instant.now().minusSeconds(maxStreamLength); + List stale = markerRepository.findAllBefore(cutoff); + markerRepository.deleteAllInBatch(stale); + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java index e466230..50c88cc 100644 --- a/src/main/java/com/ddf/vodsystem/services/MediaService.java +++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java @@ -3,7 +3,9 @@ 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.Marker; import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.exceptions.MarkerNotFound; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.services.media.CompressionService; import com.ddf.vodsystem.services.media.StreamActionsService; @@ -32,12 +34,13 @@ public class MediaService { private static final int HLS_SEGMENT_LENGTH = 3; private final StreamActionsService streamActionsService; private final VodService vodService; + private final MarkerService markerService; public MediaService(JobRegistryService jobRegistryService, DirectoryService directoryService, CompressionService compressionService, UserService userService, - ClipService clipService, StreamActionsService streamActionsService, VodService vodService) { + ClipService clipService, StreamActionsService streamActionsService, VodService vodService, MarkerService markerService) { this.jobRegistryService = jobRegistryService; this.directoryService = directoryService; this.compressionService = compressionService; @@ -45,6 +48,7 @@ public class MediaService { this.clipService = clipService; this.streamActionsService = streamActionsService; this.vodService = vodService; + this.markerService = markerService; } /** @@ -149,6 +153,37 @@ public class MediaService { ); } + /** + * Saves a section of the currently authenticated user's live stream between two markers. + *

+ * Resolves {@code startMarkerId} and {@code endMarkerId} to their marker timestamps and + * delegates to {@link #saveSection(Instant, Instant, String, String)}. + * + * @param startMarkerId id of the marker whose timestamp begins the section to save + * @param endMarkerId id of the marker whose timestamp ends 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 MarkerNotFound if either marker id does not exist + * @throws NotAuthenticated if no user is currently authenticated, or the caller does not + * own one of the referenced markers + * @throws IllegalArgumentException if the start marker's timestamp is not before the end + * marker's, or if no stream segments exist in the given range + * @throws IOException if reading the stream directory or its segments fails + */ + public Job saveSection( + Long startMarkerId, + Long endMarkerId, + String title, + String description + ) throws IOException { + Marker start = markerService.getMarkerById(startMarkerId); + Marker end = markerService.getMarkerById(endMarkerId); + + return saveSection(start.getTimestamp(), end.getTimestamp(), title, description); + } + /** * Saves the last {@code duration} seconds of the currently authenticated user's live stream as a clip. *

diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9701c07..3fd439f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,6 +3,7 @@ spring.application.name=vodSystem # VODs spring.servlet.multipart.max-file-size=2GB spring.servlet.multipart.max-request-size=2GB +stream.max-length=${STREAM_RING_LENGTH_SECOND} # Database spring.datasource.url=${POSTGRES_URL} diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index efbb646..4f33216 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -54,4 +54,15 @@ CREATE TABLE IF NOT EXISTS vods ( 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 markers ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + stream_id BIGINT NOT NULL, + message VARCHAR(255), + timestamp TIMESTAMPTZ DEFAULT NOW(), + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE ); \ No newline at end of file