diff --git a/.env.example b/.env.example index 315c280..6083238 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,18 @@ +# Secrets GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -JWT_SECRET_KEY= \ No newline at end of file +JWT_SECRET_KEY= + +# Volumes +POSTGRES_DATA= +STREAM_DATA= +MEDIA_DATA= + +# DB Credentials +POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem +POSTGRES_USER=myuser +POSTGRES_PASSWORD=mypassword +POSTGRES_DB=vodSystem + +FRONTEND_URL= +BACKEND_URL= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 907f3e0..e37d9bb 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,7 @@ build/ ### Generated Files ### videos/ +outputs/ generated +.mvn diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index d58dfb7..0000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1,19 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -wrapperVersion=3.3.2 -distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/bruno/VoD-System/Streams/History Streams.bru b/bruno/VoD-System/Streams/History Streams.bru new file mode 100644 index 0000000..3a00096 --- /dev/null +++ b/bruno/VoD-System/Streams/History Streams.bru @@ -0,0 +1,20 @@ +meta { + name: History Streams + type: http + seq: 1 +} + +get { + url: {{base_url}}/stream/history/1 + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Streams/Save Clip.bru b/bruno/VoD-System/Streams/Save Clip.bru new file mode 100644 index 0000000..e3d2338 --- /dev/null +++ b/bruno/VoD-System/Streams/Save Clip.bru @@ -0,0 +1,22 @@ +meta { + name: Save Clip + type: http + seq: 3 +} + +post { + url: {{base_url}}/stream/clip + body: json + auth: inherit +} + +body:json { + { + "duration": 60 + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Streams/Save Section.bru b/bruno/VoD-System/Streams/Save Section.bru new file mode 100644 index 0000000..c6421dd --- /dev/null +++ b/bruno/VoD-System/Streams/Save Section.bru @@ -0,0 +1,23 @@ +meta { + name: Save Section + type: http + seq: 2 +} + +post { + url: {{base_url}}/stream/save + body: json + auth: inherit +} + +body:json { + { + "startTime": "2026-06-20T00:15:00Z", + "endTime": "2026-06-20T00:16:00Z" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Streams/folder.bru b/bruno/VoD-System/Streams/folder.bru new file mode 100644 index 0000000..2aa144d --- /dev/null +++ b/bruno/VoD-System/Streams/folder.bru @@ -0,0 +1,12 @@ +meta { + name: Streams + seq: 7 +} + +auth { + mode: bearer +} + +auth:bearer { + token: {{token}} +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..6cc3fbf --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,32 @@ +# Local dev: only infrastructure services +# Backend and frontend run directly on the host +# Usage: docker compose -f docker-compose.dev.yml up + +services: + postgres: + image: postgres:15 + container_name: vod_postgres + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - ${POSTGRES_DATA}:/var/lib/postgresql/data + ports: + - "5432:5432" + + rtmp: + build: + context: ./nginx + dockerfile: Dockerfile + container_name: vod_rtmp + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - WEB_SERVER_URL=http://host.docker.internal:8080 + ports: + - "1935:1935" + volumes: + - ${STREAM_DATA}:/stream + pull_policy: build + restart: unless-stopped \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 896c343..796f1cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,15 @@ services: - postgres: image: postgres:15 container_name: vod_postgres environment: - POSTGRES_USER: myuser - POSTGRES_PASSWORD: mypassword - POSTGRES_DB: vodSystem + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - ${POSTGRES_DATA}:/var/lib/postgresql/data ports: - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data backend: build: @@ -21,6 +20,9 @@ services: - postgres env_file: - .env + volumes: + - ${MEDIA_DATA}:/app/data/media + - ${STREAM_DATA}:/stream ports: - "8080:8080" restart: unless-stopped @@ -30,13 +32,27 @@ services: context: ./frontend dockerfile: Dockerfile container_name: vod_frontend - env_file: - - .env + environment: + VITE_API_URL: ${BACKEND_URL} + VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: - backend ports: - "5173:5173" restart: unless-stopped -volumes: - postgres_data: \ No newline at end of file + rtmp: + build: + context: ./nginx + dockerfile: Dockerfile + container_name: vod_rtmp + environment: + - WEB_SERVER_URL=http://backend:8080 + ports: + - "1935:1935" + volumes: + - ${STREAM_DATA}:/stream + pull_policy: build + restart: unless-stopped + depends_on: + - backend \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 0561dd7..f7b922a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,7 +2,7 @@ FROM node:20-alpine WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci COPY . . EXPOSE 5173 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c84db0..d648ade 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Home from './pages/Home'; import {useEffect} from "react"; import MyClips from './pages/MyClips'; import VideoPlayer from "./pages/VideoPlayer.tsx"; +import Profile from "./pages/Profile.tsx"; import { GoogleOAuthProvider } from '@react-oauth/google'; @@ -25,6 +26,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4e6e272..0c9c121 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import SidebarButton from "./buttons/SidebarButton.tsx"; -import { Plus, Film, Home } from 'lucide-react'; +import { Plus, Film, Home, User as UserIcon } from 'lucide-react'; import Box from "./Box.tsx"; import type {User} from "../utils/types.ts"; @@ -30,11 +30,18 @@ const Sidebar = ({user, className}: props) => { /> { user && ( - } - label={"My Clips"} - /> + <> + } + label={"My Clips"} + /> + } + label={"Profile"} + /> + )} ); diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 9026186..8fdef00 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -70,6 +70,8 @@ const Topbar = ({ /> + navigate("/profile")} /> handleLogout()} className={"text-red-500 font-medium"} /> diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index ffc2ab1..5b7a06e 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -13,6 +13,7 @@ type VideoCardProps = { } const fallbackThumbnail = "../../../public/default_thumbnail.png"; +const API_URL = import.meta.env.VITE_API_URL; const VideoCard = ({ id, @@ -45,7 +46,7 @@ const VideoCard = ({
Video Thumbnail diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index f9b3fad..ffc9814 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -10,10 +10,12 @@ import type { VideoMetadata } from "../utils/types.ts"; import Box from "../components/Box.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx"; +const API_URL = import.meta.env.VITE_API_URL; + const ClipEdit = () => { const { id } = useParams(); const videoRef = useRef(null); - const videoUrl = `/api/v1/download/input/${id}` + const videoUrl = API_URL + `/api/v1/download/input/${id}` const [metadata, setMetadata] = useState(null); const [playbackValue, setPlaybackValue] = useState(0); const [outputMetadata, setOutputMetadata] = useState({ @@ -65,7 +67,7 @@ const ClipEdit = () => { } const handleDownload = async () => { - const response = await fetch(`/api/v1/download/output/${id}`); + const response = await fetch(API_URL + `/api/v1/download/output/${id}`); if (!response.ok) { console.error('Download failed'); diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..1df29f8 --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -0,0 +1,85 @@ +import { useState, useEffect } from "react"; +import { Eye, EyeOff, Copy, Check } from "lucide-react"; +import Box from "../components/Box.tsx"; +import { getUser } from "../utils/endpoints.ts"; +import type { User } from "../utils/types.ts"; + +const Profile = () => { + const [user, setUser] = useState(null); + const [keyVisible, setKeyVisible] = useState(false); + const [copied, setCopied] = useState(false); + + useEffect(() => { + getUser().then(setUser).catch(console.error); + }, []); + + const handleCopy = () => { + if (!user?.streamKey) return; + navigator.clipboard.writeText(user.streamKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (!user) { + return ( +
+ Please log in to view your profile. +
+ ); + } + + return ( +
+

Profile

+ + + Profile picture +
+

{user.name}

+

@{user.username}

+

{user.email}

+

+ Member since {new Date(user.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })} +

+
+
+ + +

Stream Key

+

+ Use this key in OBS or any RTMP-compatible software to start streaming. + Keep it private — anyone with this key can stream to your account. +

+ +
+
+ {keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)} +
+ + + + +
+
+
+ ); +}; + +export default Profile; diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 287a561..04cc9f6 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -17,8 +17,11 @@ type APIResponse = { type User = { name: string, + username: string, email: string, - profilePictureUrl: string + profilePictureUrl: string, + streamKey: string, + createdAt: string, } type Clip = { diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..e88822e --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +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 + +RUN chmod +x /docker-entrypoint.sh + +ENTRYPOINT ["/docker-entrypoint.sh"] \ No newline at end of file diff --git a/nginx/default.conf.template b/nginx/default.conf.template new file mode 100644 index 0000000..1cc95aa --- /dev/null +++ b/nginx/default.conf.template @@ -0,0 +1,48 @@ +worker_processes auto; +events { + worker_connections 1024; +} + +rtmp { + server { + listen 1935; + chunk_size 4096; + + application live { + live on; + record off; + + allow publish all; + allow play all; + + hls on; + hls_nested on; + hls_path /stream; + hls_fragment_naming system; + hls_fragment 3; + hls_playlist_length 28800; + + on_publish ${WEB_SERVER_URL}/api/v1/stream/start; + on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop; + on_update ${WEB_SERVER_URL}/api/v1/stream/heartbeat; + notify_update_timeout 30s; + } + } +} + +# Serve the HLS segments over HTTP +http { + server { + listen 8088; + + location /hls { + types { + application/vnd.apple.mpegurl m3u8; # proper MIME for HLS playlists + video/mp2t ts; # MPEG-TS segment MIME + } + root /stream; + add_header Cache-Control no-cache; # prevent stale segments + add_header Access-Control-Allow-Origin *; # CORS for browser playback + } + } +} \ No newline at end of file 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/pom.xml b/pom.xml index b992532..5d188e4 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,10 @@ google-api-client 2.8.0 + + org.springframework.boot + spring-boot-starter-validation + diff --git a/src/main/Dockerfile b/src/main/Dockerfile index 3b0af77..328e231 100644 --- a/src/main/Dockerfile +++ b/src/main/Dockerfile @@ -15,6 +15,12 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +ENV POSTGRES_DATA=/app/db +ENV MEDIA_DATA=/app/data/media +ENV STREAM_DATA=/app/data/media/streams + +VOLUME ["app/db", "app/data/media/streams", "/app/data/media"] + WORKDIR /app COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 diff --git a/src/main/java/com/ddf/vodsystem/configuration/AsyncConfig.java b/src/main/java/com/ddf/vodsystem/configuration/AsyncConfig.java index ba7a39e..b6200ac 100644 --- a/src/main/java/com/ddf/vodsystem/configuration/AsyncConfig.java +++ b/src/main/java/com/ddf/vodsystem/configuration/AsyncConfig.java @@ -1,13 +1,18 @@ package com.ddf.vodsystem.configuration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @Configuration -public class AsyncConfig { +public class AsyncConfig implements AsyncConfigurer { + private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class); @Bean(name = "ffmpegTaskExecutor") public Executor taskExecutor() { @@ -19,4 +24,10 @@ public class AsyncConfig { executor.initialize(); return executor; } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return (ex, method, params) -> + logger.error("Async task '{}' failed: {}", method.getName(), ex.getMessage(), ex); + } } 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..c53b8f0 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java @@ -0,0 +1,69 @@ +package com.ddf.vodsystem.controllers; + +import com.ddf.vodsystem.controllers.input.ClipSectionRequest; +import com.ddf.vodsystem.dto.APIResponse; +import com.ddf.vodsystem.controllers.input.SaveSectionRequest; +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.io.IOException; +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)); + } + + @PostMapping("/save") + public ResponseEntity> saveSection( + @RequestBody SaveSectionRequest request) throws IOException, InterruptedException { + streamService.saveSection(request.startTime(), request.endTime()); + return ResponseEntity.accepted().body(new APIResponse<>("success", "Section saved", null)); + } + + @PostMapping("/clip") + public ResponseEntity> saveClip( + @RequestBody ClipSectionRequest request) throws IOException, InterruptedException { + streamService.clip(request.duration()); + return ResponseEntity.accepted().body(new APIResponse<>("success", "Clip saved", 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/controllers/input/ClipSectionRequest.java b/src/main/java/com/ddf/vodsystem/controllers/input/ClipSectionRequest.java new file mode 100644 index 0000000..d683b43 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/input/ClipSectionRequest.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.controllers.input; + +import jakarta.validation.constraints.NotNull; + +public record ClipSectionRequest( + @NotNull float duration +) {} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/input/SaveSectionRequest.java b/src/main/java/com/ddf/vodsystem/controllers/input/SaveSectionRequest.java new file mode 100644 index 0000000..51988a5 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/input/SaveSectionRequest.java @@ -0,0 +1,8 @@ +package com.ddf.vodsystem.controllers.input; + +import java.time.Instant; +import jakarta.validation.constraints.NotNull; + +public record SaveSectionRequest( + @NotNull Instant startTime, + @NotNull Instant endTime) {} diff --git a/src/main/java/com/ddf/vodsystem/entities/Stream.java b/src/main/java/com/ddf/vodsystem/entities/Stream.java new file mode 100644 index 0000000..325b9c3 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/entities/Stream.java @@ -0,0 +1,31 @@ +package com.ddf.vodsystem.entities; + +import jakarta.persistence.*; +import lombok.Data; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "streams") +@Data +public class Stream { + @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; + + @Column(name = "start_time") + private LocalDateTime startDate; + + @Column(name = "end_time") + private LocalDateTime endDate; + + @Column(name = "last_seen") + private LocalDateTime lastSeen; +} diff --git a/src/main/java/com/ddf/vodsystem/entities/User.java b/src/main/java/com/ddf/vodsystem/entities/User.java index f8a5455..1d7bf69 100644 --- a/src/main/java/com/ddf/vodsystem/entities/User.java +++ b/src/main/java/com/ddf/vodsystem/entities/User.java @@ -34,5 +34,8 @@ public class User { @Column(name = "created_at") private LocalDateTime createdAt; + + @Column(name = "stream_key") + private String streamKey; } 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 new file mode 100644 index 0000000..e1ce754 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java @@ -0,0 +1,17 @@ +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.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface StreamRepository extends JpaRepository { + Optional findByUserAndEndDateIsNull(User user); + List findByUser(User user); + List findByEndDateIsNullAndLastSeenBefore(LocalDateTime cutoff); +} diff --git a/src/main/java/com/ddf/vodsystem/repositories/UserRepository.java b/src/main/java/com/ddf/vodsystem/repositories/UserRepository.java index 50262d6..3a978b7 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/UserRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/UserRepository.java @@ -11,4 +11,7 @@ import java.util.Optional; public interface UserRepository extends JpaRepository { @Query("SELECT u FROM User u WHERE u.googleId = ?1") Optional findByGoogleId(String googleId); + + @Query("SELECT u FROM User u WHERE u.streamKey = ?1") + Optional findByStreamKey(String streamKey); } diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java index ce632e5..74d8351 100644 --- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -59,6 +59,14 @@ public class DirectoryService { return file; } + public File getVodFile(String filename) throws IOException { + String dir = outputDir + File.separator + "vods" + File.separator + filename; + File file = new File(dir); + createDirectory(file.getParent()); + + return file; + } + public File getUserThumbnailsFile(Long userId, String fileName) { if (userId == null || fileName == null || fileName.isEmpty()) { throw new IllegalArgumentException("User ID and file name cannot be null or empty"); diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java new file mode 100644 index 0000000..97e62f1 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -0,0 +1,218 @@ +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.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.beans.factory.annotation.Value; +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; + + @Value("${storage.stream}") + private String streamDataPath; + + public StreamService(StreamRepository streamRepository, + UserService userService, + StreamActionsService streamActionsService, + DirectoryService directoryService) { + this.streamRepository = streamRepository; + this.userService = userService; + this.streamActionsService = streamActionsService; + this.directoryService = directoryService; + } + + 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(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(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 streamUser = userService.getUserById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + + Optional 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,