From 34182678298cdf3a12ab66b63e02028a371daa7c Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 1 May 2026 23:34:43 +0100 Subject: [PATCH 01/31] feat: add nginx rtmp server & api and frontend passthroughs --- nginx/Dockerfile | 6 ++++ nginx/nginx.conf | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 nginx/Dockerfile create mode 100644 nginx/nginx.conf diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..92121a3 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,6 @@ +FROM tiangolo/nginx-rtmp + +COPY nginx.conf /etc/nginx/nginx.conf + +EXPOSE 1935 +EXPOSE 8080 diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..2b7e71a --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,83 @@ +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_path /tmp/hls; + hls_fragment 3; + hls_playlist_length 60; + } + } +} + +http { + sendfile off; + tcp_nopush on; + default_type application/octet-stream; + + # Allow large uploads (matches Spring Boot multipart limit) + client_max_body_size 2g; + + server { + listen 80; + + # Spring Boot API + location /api/v1/ { + proxy_pass http://vod_backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Large upload timeouts + proxy_read_timeout 600s; + proxy_send_timeout 600s; + proxy_request_buffering off; + } + + # HLS stream playback + location /hls { + types { + application/vnd.apple.mpegurl m3u8; + video/mp2t ts; + } + root /tmp; + add_header Cache-Control no-cache; + add_header Access-Control-Allow-Origin *; + } + + # RTMP stats + location /stat { + rtmp_stat all; + rtmp_stat_stylesheet stat.xsl; + } + + location /stat.xsl { + root /usr/local/nginx/html; + } + + # Frontend (Vite dev server) — catch-all + location / { + proxy_pass http://vod_frontend:5173; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + } +} -- 2.49.1 From a9e8b7fe78298d806d5974278cd2f3e6cce9259e Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 1 May 2026 23:35:04 +0100 Subject: [PATCH 02/31] feat: route Spring Boot & Vite through nginx --- docker-compose.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 896c343..4c857aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,8 +21,8 @@ services: - postgres env_file: - .env - ports: - - "8080:8080" + environment: + FRONTEND_URL: http://localhost restart: unless-stopped frontend: @@ -32,11 +32,24 @@ services: container_name: vod_frontend env_file: - .env + environment: + VITE_API_URL: "" depends_on: - backend + restart: unless-stopped + + nginx: + build: + context: ./nginx + dockerfile: Dockerfile + container_name: vod_nginx + depends_on: + - backend + - frontend ports: - - "5173:5173" + - "80:80" + - "1935:1935" restart: unless-stopped volumes: - postgres_data: \ No newline at end of file + postgres_data: -- 2.49.1 From a20ea862b6ef3d798c7d23e8545f90400bd04df6 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 1 May 2026 23:36:02 +0100 Subject: [PATCH 03/31] fix: missing dir in gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 907f3e0..054ece5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,6 @@ build/ ### Generated Files ### videos/ +outputs/ generated -- 2.49.1 From 942f0d1a776c139f31ac6b20e7198cf26b6016ab Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 2 May 2026 19:19:25 +0100 Subject: [PATCH 04/31] fix: broken frontend in docker compose --- .env.example | 7 ++++++- docker-compose.yml | 12 +++++------- frontend/.dockerignore | 1 + frontend/Dockerfile | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 frontend/.dockerignore diff --git a/.env.example b/.env.example index 315c280..5ddd2a0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ +# Secrets GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -JWT_SECRET_KEY= \ No newline at end of file +JWT_SECRET_KEY= + +# Volumes +POSTGRES_DATA= +STREAM_DATA= \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4c857aa..0aa7715 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,10 +7,8 @@ services: POSTGRES_USER: myuser POSTGRES_PASSWORD: mypassword POSTGRES_DB: vodSystem - ports: - - "5432:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - ${POSTGRES_DATA}:/var/lib/postgresql/data backend: build: @@ -34,6 +32,7 @@ services: - .env environment: VITE_API_URL: "" + VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: - backend restart: unless-stopped @@ -49,7 +48,6 @@ services: ports: - "80:80" - "1935:1935" - restart: unless-stopped - -volumes: - postgres_data: + volumes: + - ${STREAM_DATA}:/stream + restart: unless-stopped \ 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 -- 2.49.1 From 265a96acc12becd8cacaa3fd7934a775777296c9 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 3 May 2026 12:51:32 +0100 Subject: [PATCH 05/31] feat: add Stream table & boilerplate --- .../com/ddf/vodsystem/entities/Stream.java | 31 +++++++++++++++++++ .../java/com/ddf/vodsystem/entities/User.java | 3 ++ .../repositories/StreamRepository.java | 9 ++++++ .../repositories/UserRepository.java | 3 ++ .../ddf/vodsystem/services/StreamService.java | 24 ++++++++++++++ .../ddf/vodsystem/services/UserService.java | 4 +++ src/main/resources/db/schema.sql | 16 ++++++++-- 7 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ddf/vodsystem/entities/Stream.java create mode 100644 src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java create mode 100644 src/main/java/com/ddf/vodsystem/services/StreamService.java 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..204fdb9 --- /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_date") + private LocalDateTime startDate; + + @Column(name = "end_date") + 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/repositories/StreamRepository.java b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java new file mode 100644 index 0000000..ecfb88e --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java @@ -0,0 +1,9 @@ +package com.ddf.vodsystem.repositories; + +import com.ddf.vodsystem.entities.Stream; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface StreamRepository extends JpaRepository { +} 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/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java new file mode 100644 index 0000000..dc6e471 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -0,0 +1,24 @@ +package com.ddf.vodsystem.services; + +import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.repositories.StreamRepository; + +public class StreamService { + private StreamRepository streamRepository; + + public StreamService(StreamRepository streamRepository) { + this.streamRepository = streamRepository; + } + + public void startStream(User user) { + + } + + public void endStream(User user) { + + } + + public void heartbeatStream(User user) { + + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index 5c63d80..ce9f586 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -67,6 +67,10 @@ 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()); diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index d8b1362..076c547 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -9,8 +9,20 @@ 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 +); + +CREATE TABLE IF NOT EXISTS streams ( + id BIGSERIAL PRIMARY KEY, + user_id BIGSERIAL, + 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, -- 2.49.1 From 2e6679cb89a412e02460b5648bca4e50c6b953da Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 14 May 2026 20:20:37 +0100 Subject: [PATCH 06/31] refactor(nginx): nginx only handles rtmp connections Not longer handles frontend & backend. Partially for ease of development and also for separation of concenrs. --- docker-compose.yml | 9 +++-- nginx/Dockerfile | 1 - nginx/nginx.conf | 65 ++++++++------------------------ src/main/resources/db/schema.sql | 8 ++-- 4 files changed, 26 insertions(+), 57 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0aa7715..906f05a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: - .env environment: FRONTEND_URL: http://localhost + ports: + - "8080:8080" restart: unless-stopped frontend: @@ -35,18 +37,19 @@ services: VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: - backend + ports: + - "1935:1935" restart: unless-stopped - nginx: + rtmp: build: context: ./nginx dockerfile: Dockerfile - container_name: vod_nginx + container_name: vod_rtmp depends_on: - backend - frontend ports: - - "80:80" - "1935:1935" volumes: - ${STREAM_DATA}:/stream diff --git a/nginx/Dockerfile b/nginx/Dockerfile index 92121a3..0ed9f1e 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -3,4 +3,3 @@ FROM tiangolo/nginx-rtmp COPY nginx.conf /etc/nginx/nginx.conf EXPOSE 1935 -EXPOSE 8080 diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 2b7e71a..98518a5 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -3,6 +3,15 @@ events { worker_connections 1024; } +upstream backend { + server 127.0.0.1:8080; +} + +upstream frontend { + server 127.0.0.1:5173; +} + + rtmp { server { listen 1935; @@ -23,61 +32,19 @@ rtmp { } } +# Serve the HLS segments over HTTP http { - sendfile off; - tcp_nopush on; - default_type application/octet-stream; - - # Allow large uploads (matches Spring Boot multipart limit) - client_max_body_size 2g; - server { - listen 80; + listen 8088; - # Spring Boot API - location /api/v1/ { - proxy_pass http://vod_backend:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # Large upload timeouts - proxy_read_timeout 600s; - proxy_send_timeout 600s; - proxy_request_buffering off; - } - - # HLS stream playback location /hls { types { - application/vnd.apple.mpegurl m3u8; - video/mp2t ts; + application/vnd.apple.mpegurl m3u8; # proper MIME for HLS playlists + video/mp2t ts; # MPEG-TS segment MIME } root /tmp; - add_header Cache-Control no-cache; - add_header Access-Control-Allow-Origin *; - } - - # RTMP stats - location /stat { - rtmp_stat all; - rtmp_stat_stylesheet stat.xsl; - } - - location /stat.xsl { - root /usr/local/nginx/html; - } - - # Frontend (Vite dev server) — catch-all - location / { - proxy_pass http://vod_frontend:5173; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; + 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/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 076c547..c94b828 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -1,3 +1,4 @@ +DROP TABLE IF EXISTS streams; DROP TABLE IF EXISTS clips; DROP TABLE IF EXISTS users; @@ -10,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) NOT NULL + stream_key VARCHAR(64) ); CREATE TABLE IF NOT EXISTS streams ( @@ -18,11 +19,10 @@ CREATE TABLE IF NOT EXISTS streams ( user_id BIGSERIAL, start_time TIMESTAMPTZ DEFAULT NOW(), end_time TIMESTAMPTZ DEFAULT NULL, - last_seen 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, -- 2.49.1 From 09ec5cf09c269017b12b2cb42dd4bbe227fff88a Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 14 May 2026 20:27:27 +0100 Subject: [PATCH 07/31] fix: incorrect docker deployment --- docker-compose.dev.yml | 10 ++++++++++ docker-compose.yml | 6 ++---- nginx/nginx.conf | 9 --------- src/main/resources/application-local.properties | 5 ++++- 4 files changed, 16 insertions(+), 14 deletions(-) create mode 100644 docker-compose.dev.yml diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..9b3f47c --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,10 @@ +services: + rtmp: + extends: + file: docker-compose.yml + service: rtmp + + postgres: + extends: + file: docker-compose.yml + service: postgres \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 906f05a..65a6555 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,4 @@ services: - postgres: image: postgres:15 container_name: vod_postgres @@ -9,6 +8,8 @@ services: POSTGRES_DB: vodSystem volumes: - ${POSTGRES_DATA}:/var/lib/postgresql/data + ports: + - "5432:5432" backend: build: @@ -46,9 +47,6 @@ services: context: ./nginx dockerfile: Dockerfile container_name: vod_rtmp - depends_on: - - backend - - frontend ports: - "1935:1935" volumes: diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 98518a5..bf0c004 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -3,15 +3,6 @@ events { worker_connections 1024; } -upstream backend { - server 127.0.0.1:8080; -} - -upstream frontend { - server 127.0.0.1:5173; -} - - rtmp { server { listen 1935; diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 0325cb2..de8e995 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -1,5 +1,5 @@ # Database -spring.datasource.url=jdbc:postgresql://postgres:5432/vodSystem +spring.datasource.url=jdbc:postgresql://localhost:5432/vodSystem spring.datasource.username=myuser spring.datasource.password=mypassword spring.datasource.driver-class-name=org.postgresql.Driver @@ -11,3 +11,6 @@ spring.sql.init.schema-locations=classpath:db/schema.sql # Frontend frontend.url=http://localhost:5173 + +# Docker +spring.docker.compose.file=docker-compose.dev.yml -- 2.49.1 From 7eb4f1d34e3af8ac5b8cd7b9b0a7b6cb15a1321f Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 17 May 2026 20:49:04 +0100 Subject: [PATCH 08/31] build: move postgres credentials to env --- docker-compose.yml | 15 +++++++-------- src/main/resources/application-dev.properties | 4 ++++ src/main/resources/application-local.properties | 5 ++--- 3 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 src/main/resources/application-dev.properties diff --git a/docker-compose.yml b/docker-compose.yml index 65a6555..94682e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,9 @@ services: 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: @@ -21,7 +21,8 @@ services: env_file: - .env environment: - FRONTEND_URL: http://localhost + FRONTEND_URL: http://localhost:5173 + SPRING_PROFILES_ACTIVE: dev ports: - "8080:8080" restart: unless-stopped @@ -31,15 +32,13 @@ services: context: ./frontend dockerfile: Dockerfile container_name: vod_frontend - env_file: - - .env environment: - VITE_API_URL: "" + VITE_API_URL: "http://localhost:8080" VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: - backend ports: - - "1935:1935" + - "5173:5173" restart: unless-stopped rtmp: diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..aa67d31 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,4 @@ +spring.datasource.url=jdbc:postgresql://postgres:5432/vodSystem +spring.datasource.username=${POSTGRES_USER} +spring.datasource.password=${POSTGRES_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver \ No newline at end of file diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index de8e995..fd79b55 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -1,13 +1,12 @@ # Database spring.datasource.url=jdbc:postgresql://localhost:5432/vodSystem -spring.datasource.username=myuser -spring.datasource.password=mypassword +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 -#spring.sql.init.data-locations=classpath:db/data.sql # Frontend frontend.url=http://localhost:5173 -- 2.49.1 From a74ab9ee0a15943b6f189c40cf9bc2e0d718f29c Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 17 May 2026 21:06:13 +0100 Subject: [PATCH 09/31] fix(frontend): missing URL prefixes --- frontend/src/components/video/VideoCard.tsx | 3 ++- frontend/src/pages/ClipEdit.tsx | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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'); -- 2.49.1 From a74e5d1453de05242d974ffc62bc4e1696964f12 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 17 May 2026 21:16:19 +0100 Subject: [PATCH 10/31] build(properties): missing properties for db --- src/main/resources/application-dev.properties | 6 +++++- src/main/resources/application.properties | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index aa67d31..e76e4fd 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,4 +1,8 @@ spring.datasource.url=jdbc:postgresql://postgres:5432/vodSystem spring.datasource.username=${POSTGRES_USER} spring.datasource.password=${POSTGRES_PASSWORD} -spring.datasource.driver-class-name=org.postgresql.Driver \ No newline at end of file +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 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b3ac3a0..228cf73 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -6,7 +6,7 @@ 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} ## Security spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} -- 2.49.1 From 32a016e212f932572d377ce567d456660a788588 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 17 May 2026 21:17:03 +0100 Subject: [PATCH 11/31] build: update env example --- .env.example | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 5ddd2a0..58218bf 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,10 @@ JWT_SECRET_KEY= # Volumes POSTGRES_DATA= -STREAM_DATA= \ No newline at end of file +STREAM_DATA= +MEDIA_DATA= + +# DB Credentials +POSTGRES_USER=myuser +POSTGRES_PASSWORD=mypassword +POSTGRES_DB=vodSystem \ No newline at end of file -- 2.49.1 From 781aecbc09efc6c2406b90a2bfc6f85fa517ed41 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 17 May 2026 21:28:21 +0100 Subject: [PATCH 12/31] build: media stored in volume --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 94682e6..6ce165e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,9 @@ services: environment: FRONTEND_URL: http://localhost:5173 SPRING_PROFILES_ACTIVE: dev + MEDIA_DATA: /app/data/media + volumes: + - ${MEDIA_DATA}:/app/data/media ports: - "8080:8080" restart: unless-stopped -- 2.49.1 From 6230259582521538ebf5aa8f857df0bbc9fba5c3 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 17 May 2026 22:05:29 +0100 Subject: [PATCH 13/31] refactor(nginx): streams are separated by key --- .env.example | 4 +++- docker-compose.dev.yml | 38 ++++++++++++++++++++++++++++++-------- docker-compose.yml | 4 +++- nginx/nginx.conf | 8 +++++--- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 58218bf..b1f5962 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,6 @@ MEDIA_DATA= # DB Credentials POSTGRES_USER=myuser POSTGRES_PASSWORD=mypassword -POSTGRES_DB=vodSystem \ No newline at end of file +POSTGRES_DB=vodSystem + +FRONTEND_URL= \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9b3f47c..6cc3fbf 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,10 +1,32 @@ -services: - rtmp: - extends: - file: docker-compose.yml - service: rtmp +# Local dev: only infrastructure services +# Backend and frontend run directly on the host +# Usage: docker compose -f docker-compose.dev.yml up +services: postgres: - extends: - file: docker-compose.yml - service: postgres \ No newline at end of file + 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 6ce165e..43a2032 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,6 @@ services: env_file: - .env environment: - FRONTEND_URL: http://localhost:5173 SPRING_PROFILES_ACTIVE: dev MEDIA_DATA: /app/data/media volumes: @@ -49,8 +48,11 @@ services: 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 \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf index bf0c004..0f3144a 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -16,9 +16,11 @@ rtmp { allow play all; hls on; - hls_path /tmp/hls; + hls_nested on; + hls_path /stream; + hls_fragment_naming system; hls_fragment 3; - hls_playlist_length 60; + hls_playlist_length 28800; } } } @@ -33,7 +35,7 @@ http { application/vnd.apple.mpegurl m3u8; # proper MIME for HLS playlists video/mp2t ts; # MPEG-TS segment MIME } - root /tmp; + root /stream; add_header Cache-Control no-cache; # prevent stale segments add_header Access-Control-Allow-Origin *; # CORS for browser playback } -- 2.49.1 From c70cd3f9393aac9f0c353a46ccaff26c753ec162 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 17 May 2026 22:30:35 +0100 Subject: [PATCH 14/31] fix(db): remove auto-drop --- src/main/resources/db/schema.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index c94b828..a3a73da 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -1,6 +1,6 @@ -DROP TABLE IF EXISTS streams; -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, -- 2.49.1 From 3b188a349797f27b817185572e13fe48d13c0c49 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 18 May 2026 21:14:56 +0100 Subject: [PATCH 15/31] fix(nginx): envs not passed into nginx --- nginx/Dockerfile | 8 +++++++- nginx/nginx.conf | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/nginx/Dockerfile b/nginx/Dockerfile index 0ed9f1e..b35c59a 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -1,5 +1,11 @@ FROM tiangolo/nginx-rtmp -COPY nginx.conf /etc/nginx/nginx.conf +RUN apt-get update && apt-get install -y gettext-base && rm -rf /var/lib/apt/lists/* + +COPY nginx.conf /etc/nginx/nginx.conf.template +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh EXPOSE 1935 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 0f3144a..1cc95aa 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -21,6 +21,11 @@ rtmp { 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; } } } -- 2.49.1 From 1dbc40d8e67ba3c9233b64575900b396512e4201 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Mon, 18 May 2026 22:27:49 +0100 Subject: [PATCH 16/31] feat(stream): connect nginx with backend with controllers --- nginx/Dockerfile | 10 ++- nginx/{nginx.conf => default.conf.template} | 0 nginx/docker-entrypoint.sh | 9 +++ .../configuration/SecurityConfig.java | 1 + .../controllers/GlobalExceptionHandler.java | 16 +++++ .../controllers/StreamController.java | 52 +++++++++++++++ .../exceptions/AlreadyStreaming.java | 7 +++ .../ddf/vodsystem/exceptions/KeyNotFound.java | 7 +++ .../repositories/StreamRepository.java | 6 ++ .../ddf/vodsystem/services/StreamService.java | 63 +++++++++++++++++-- .../ddf/vodsystem/services/UserService.java | 9 +++ src/main/resources/db/schema.sql | 2 +- 12 files changed, 170 insertions(+), 12 deletions(-) rename nginx/{nginx.conf => default.conf.template} (100%) create mode 100644 nginx/docker-entrypoint.sh create mode 100644 src/main/java/com/ddf/vodsystem/controllers/StreamController.java create mode 100644 src/main/java/com/ddf/vodsystem/exceptions/AlreadyStreaming.java create mode 100644 src/main/java/com/ddf/vodsystem/exceptions/KeyNotFound.java 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 ( -- 2.49.1 From a928bb390fabd74b46d9a919819d30b76b4b4585 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 4 Jun 2026 22:19:45 +0100 Subject: [PATCH 17/31] fix: broken compose configuration --- docker-compose.yml | 5 +++-- src/main/resources/application-dev.properties | 2 ++ src/main/resources/application.properties | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 43a2032..eed407a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,6 @@ services: - .env environment: SPRING_PROFILES_ACTIVE: dev - MEDIA_DATA: /app/data/media volumes: - ${MEDIA_DATA}:/app/data/media ports: @@ -55,4 +54,6 @@ services: volumes: - ${STREAM_DATA}:/stream pull_policy: build - restart: unless-stopped \ No newline at end of file + restart: unless-stopped + depends_on: + - backend \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index e76e4fd..f9ba019 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,3 +1,5 @@ +spring.docker.compose.enabled=false + spring.datasource.url=jdbc:postgresql://postgres:5432/vodSystem spring.datasource.username=${POSTGRES_USER} spring.datasource.password=${POSTGRES_PASSWORD} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 228cf73..c5811af 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,5 @@ spring.application.name=vodSystem -spring.profiles.active=local +spring.profiles.default=local # VODs spring.servlet.multipart.max-file-size=2GB -- 2.49.1 From 8bc9044fab626d0e8f2419552884fe40ea1d29fd Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 6 Jun 2026 13:25:35 +0100 Subject: [PATCH 18/31] feat(frontend): add profile page to view stream key --- docker-compose.yml | 2 -- frontend/src/App.tsx | 2 ++ frontend/src/components/Sidebar.tsx | 19 +++++++++++++------ frontend/src/components/Topbar.tsx | 2 ++ frontend/src/utils/types.ts | 5 ++++- src/main/Dockerfile | 6 ++++++ src/main/resources/application-dev.properties | 10 ---------- .../resources/application-local.properties | 16 +--------------- src/main/resources/application.properties | 14 +++++++++++++- 9 files changed, 41 insertions(+), 35 deletions(-) delete mode 100644 src/main/resources/application-dev.properties diff --git a/docker-compose.yml b/docker-compose.yml index eed407a..4fa72be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,8 +20,6 @@ services: - postgres env_file: - .env - environment: - SPRING_PROFILES_ACTIVE: dev volumes: - ${MEDIA_DATA}:/app/data/media ports: 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/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/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/resources/application-dev.properties b/src/main/resources/application-dev.properties deleted file mode 100644 index f9ba019..0000000 --- a/src/main/resources/application-dev.properties +++ /dev/null @@ -1,10 +0,0 @@ -spring.docker.compose.enabled=false - -spring.datasource.url=jdbc:postgresql://postgres:5432/vodSystem -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 \ No newline at end of file diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index fd79b55..7b02d27 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -1,15 +1 @@ -# Database -spring.datasource.url=jdbc:postgresql://localhost:5432/vodSystem -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 - -# Frontend -frontend.url=http://localhost:5173 - -# Docker -spring.docker.compose.file=docker-compose.dev.yml +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 c5811af..2f9acc6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,4 @@ spring.application.name=vodSystem -spring.profiles.default=local # VODs spring.servlet.multipart.max-file-size=2GB @@ -8,6 +7,16 @@ storage.temp.inputs=videos/inputs/ storage.temp.outputs=videos/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} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} @@ -16,5 +25,8 @@ spring.security.oauth2.client.registration.google.scope=profile,email jwt.secret.key=${JWT_SECRET_KEY} jwt.expiration=3600000 +# Frontend +frontend.url=${FRONTEND_URL} + ## Server Configuration server.servlet.session.timeout=30m \ No newline at end of file -- 2.49.1 From 480c21bc8c0ffe0a4ff5586e4fd2c26efaf966e6 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 6 Jun 2026 13:56:47 +0100 Subject: [PATCH 19/31] fix(profiles): missing profile page --- frontend/src/pages/Profile.tsx | 85 ++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 frontend/src/pages/Profile.tsx 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; -- 2.49.1 From bf59cb087f6debcb14a82a8d4358f924383db80b Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 6 Jun 2026 15:22:36 +0100 Subject: [PATCH 20/31] feat(stream): implement section saving --- bruno/VoD-System/Streams/History Streams.bru | 20 +++++ bruno/VoD-System/Streams/Save Section.bru | 27 ++++++ bruno/VoD-System/Streams/folder.bru | 14 +++ docker-compose.dev.yml | 1 + docker-compose.yml | 1 + .../vodsystem/configuration/AsyncConfig.java | 13 ++- .../controllers/StreamController.java | 9 ++ .../ddf/vodsystem/dto/SaveSectionRequest.java | 5 ++ .../repositories/StreamRepository.java | 2 + .../vodsystem/services/DirectoryService.java | 13 +++ .../ddf/vodsystem/services/StreamService.java | 51 ++++++++++- .../services/media/StreamActionsService.java | 90 +++++++++++++++++++ src/main/resources/application.properties | 3 + 13 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 bruno/VoD-System/Streams/History Streams.bru create mode 100644 bruno/VoD-System/Streams/Save Section.bru create mode 100644 bruno/VoD-System/Streams/folder.bru create mode 100644 src/main/java/com/ddf/vodsystem/dto/SaveSectionRequest.java create mode 100644 src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java 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 Section.bru b/bruno/VoD-System/Streams/Save Section.bru new file mode 100644 index 0000000..fdbee68 --- /dev/null +++ b/bruno/VoD-System/Streams/Save Section.bru @@ -0,0 +1,27 @@ +meta { + name: Save Section + type: http + seq: 2 +} + +post { + url: {{base_url}}/stream/save + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +body:json { + { + "startTime": "2026-06-06T13:56:00Z", + "endTime": "2026-06-06T13:58: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..6b8f9c2 --- /dev/null +++ b/bruno/VoD-System/Streams/folder.bru @@ -0,0 +1,14 @@ +meta { + name: Streams + seq: 7 +} + +auth { + mode: apikey +} + +auth:apikey { + key: + value: + placement: header +} \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6cc3fbf..da17515 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,6 +26,7 @@ services: - WEB_SERVER_URL=http://host.docker.internal:8080 ports: - "1935:1935" + - "8088:8088" volumes: - ${STREAM_DATA}:/stream pull_policy: build diff --git a/docker-compose.yml b/docker-compose.yml index 4fa72be..3ce1e7f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - .env volumes: - ${MEDIA_DATA}:/app/data/media + - ${STREAM_DATA}:/stream ports: - "8080:8080" restart: unless-stopped 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/controllers/StreamController.java b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java index 819ee60..74cb465 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java @@ -1,6 +1,7 @@ package com.ddf.vodsystem.controllers; import com.ddf.vodsystem.dto.APIResponse; +import com.ddf.vodsystem.dto.SaveSectionRequest; import com.ddf.vodsystem.entities.Stream; import com.ddf.vodsystem.services.StreamService; import org.slf4j.Logger; @@ -8,6 +9,7 @@ import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.io.IOException; import java.util.List; @RestController @@ -44,6 +46,13 @@ public class StreamController { 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.ok(new APIResponse<>("success", "Section saved", null)); + } + @GetMapping("/history/{userId}") public ResponseEntity>> getStreamHistory(@PathVariable Long userId) { List history = streamService.getStreamHistory(userId); diff --git a/src/main/java/com/ddf/vodsystem/dto/SaveSectionRequest.java b/src/main/java/com/ddf/vodsystem/dto/SaveSectionRequest.java new file mode 100644 index 0000000..0b1eb5e --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/SaveSectionRequest.java @@ -0,0 +1,5 @@ +package com.ddf.vodsystem.dto; + +import java.time.Instant; + +public record SaveSectionRequest(Instant startTime, Instant endTime) {} diff --git a/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java index a494f5f..e1ce754 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java @@ -5,6 +5,7 @@ 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; @@ -12,4 +13,5 @@ import java.util.Optional; 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/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java index ce632e5..924a5f7 100644 --- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -59,6 +59,19 @@ public class DirectoryService { return file; } + public File getVodFile(String filename) { + String dir = outputDir + File.separator + "vods" + File.separator + filename; + File file = new File(dir); + + try { + createDirectory(file.getParent()); + } catch (IOException e) { + logger.error("Error creating vods directory: {}", e.getMessage()); + } + + 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 index 23e2d9e..6e67c6b 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -4,21 +4,42 @@ 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.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; @Service public class StreamService { + private static final Logger logger = LoggerFactory.getLogger(StreamService.class); + private static final int HEARTBEAT_TIMEOUT_SECONDS = 60; + private final StreamRepository streamRepository; private final UserService userService; + private final StreamActionsService streamActionsService; - public StreamService(StreamRepository streamRepository, UserService userService) { + @Value("${storage.stream}") + private String streamDataPath; + + public StreamService(StreamRepository streamRepository, + UserService userService, + StreamActionsService streamActionsService) { this.streamRepository = streamRepository; this.userService = userService; + this.streamActionsService = streamActionsService; } public Stream startStream(String streamKey) { @@ -43,7 +64,6 @@ public class StreamService { .orElseThrow(() -> new IllegalStateException( "No active stream found for user " + user.getUsername())); - stream.setEndDate(LocalDateTime.now()); streamRepository.saveAndFlush(stream); } @@ -70,6 +90,33 @@ public class StreamService { return streamRepository.findByUser(user); } + public void saveSection(Instant startTime, Instant endTime) throws IOException, InterruptedException { + User user = userService.getLoggedInUser() + .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); + + String streamDirectory = streamDataPath + File.separator + user.getStreamKey(); + streamActionsService.saveSection(streamDirectory, startTime, endTime) + .exceptionally(ex -> { + logger.error("saveSection failed for user {}: {}", user.getUsername(), ex.getMessage(), ex); + return null; + }); + } + + @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)); 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..831d992 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -0,0 +1,90 @@ +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.time.Instant; +import java.util.Arrays; +import java.util.Comparator; +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( + String streamDirectory, + Instant startTime, + Instant endTime + ) throws IOException, InterruptedException { + List segments = getSegmentsInRange(streamDirectory, startTime, endTime); + if (segments.isEmpty()) { + throw new IllegalArgumentException("No segments found in range [" + startTime + ", " + endTime + "]"); + } + + long firstSegmentMs = parseTimestampMs(segments.get(0)); + float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); + float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f; + + String concatInput = segments.stream() + .map(File::getAbsolutePath) + .collect(Collectors.joining("|")); + + File outputFile = directoryService.getVodFile(UUID.randomUUID() + ".mp4"); + + 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 [{} - {}] from '{}' ({} segments) to '{}'", + startTime, endTime, streamDirectory, segments.size(), outputFile.getAbsolutePath()); + CommandRunner.run(command, line -> logger.debug("{}", line)); + + return CompletableFuture.completedFuture(outputFile); + } + + 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 + 3_000 > 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/resources/application.properties b/src/main/resources/application.properties index 2f9acc6..1d4fc35 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -28,5 +28,8 @@ jwt.expiration=3600000 # Frontend frontend.url=${FRONTEND_URL} +# Stream HLS +storage.stream=${STREAM_DATA:/stream} + ## Server Configuration server.servlet.session.timeout=30m \ No newline at end of file -- 2.49.1 From 5b997cb94582da537b21922136b5d73f7d7065ee Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Tue, 16 Jun 2026 21:08:44 +0100 Subject: [PATCH 21/31] chore(bruno): add save stream endpoint --- bruno/VoD-System/Streams/Save Section.bru | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bruno/VoD-System/Streams/Save Section.bru b/bruno/VoD-System/Streams/Save Section.bru index fdbee68..e280b3b 100644 --- a/bruno/VoD-System/Streams/Save Section.bru +++ b/bruno/VoD-System/Streams/Save Section.bru @@ -16,8 +16,8 @@ auth:bearer { body:json { { - "startTime": "2026-06-06T13:56:00Z", - "endTime": "2026-06-06T13:58:00Z" + "startTime": "2026-06-06T14:20:00Z", + "endTime": "2026-06-06T14:21:00Z" } } -- 2.49.1 From b55ba3ce51c85ddaefac6c309b4270f84ec8a85e Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 19 Jun 2026 22:04:42 +0100 Subject: [PATCH 22/31] feat(api): add error handling for save endpoint --- .../controllers/StreamController.java | 2 +- .../ddf/vodsystem/services/StreamService.java | 48 +++++++++++++++++-- .../services/media/StreamActionsService.java | 45 ++--------------- 3 files changed, 50 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java index 74cb465..c0b33c9 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java @@ -50,7 +50,7 @@ public class StreamController { public ResponseEntity> saveSection( @RequestBody SaveSectionRequest request) throws IOException, InterruptedException { streamService.saveSection(request.startTime(), request.endTime()); - return ResponseEntity.ok(new APIResponse<>("success", "Section saved", null)); + return ResponseEntity.accepted().body(new APIResponse<>("success", "Section saved", null)); } @GetMapping("/history/{userId}") diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index 6e67c6b..f56a65f 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -3,6 +3,7 @@ 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.FFMPEGException; import com.ddf.vodsystem.exceptions.KeyNotFound; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.repositories.StreamRepository; @@ -18,9 +19,11 @@ import java.io.File; import java.io.IOException; import java.time.Instant; import java.time.LocalDateTime; -import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Comparator; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; @Service public class StreamService { @@ -91,14 +94,27 @@ public class StreamService { } 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(); - streamActionsService.saveSection(streamDirectory, startTime, endTime) + 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; + + streamActionsService.saveSection(fileSegments, trimOffset, duration) .exceptionally(ex -> { - logger.error("saveSection failed for user {}: {}", user.getUsername(), ex.getMessage(), ex); - return null; + throw new FFMPEGException("Saving stream section failed due to FFMPEG Error"); }); } @@ -121,4 +137,28 @@ 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 + 3_000 > 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/media/StreamActionsService.java b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java index 831d992..a4d4bf4 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -8,9 +8,6 @@ import org.springframework.stereotype.Service; import java.io.File; import java.io.IOException; -import java.time.Instant; -import java.util.Arrays; -import java.util.Comparator; import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -28,19 +25,10 @@ public class StreamActionsService { @Async("ffmpegTaskExecutor") public CompletableFuture saveSection( - String streamDirectory, - Instant startTime, - Instant endTime + List segments, + float trimOffset, + float duration ) throws IOException, InterruptedException { - List segments = getSegmentsInRange(streamDirectory, startTime, endTime); - if (segments.isEmpty()) { - throw new IllegalArgumentException("No segments found in range [" + startTime + ", " + endTime + "]"); - } - - long firstSegmentMs = parseTimestampMs(segments.get(0)); - float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); - float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f; - String concatInput = segments.stream() .map(File::getAbsolutePath) .collect(Collectors.joining("|")); @@ -57,34 +45,11 @@ public class StreamActionsService { outputFile.getAbsolutePath() ); - logger.info("Saving section [{} - {}] from '{}' ({} segments) to '{}'", - startTime, endTime, streamDirectory, segments.size(), outputFile.getAbsolutePath()); + logger.info("Saving section with ({} segments) to '{}'", + segments.size(), outputFile.getAbsolutePath()); CommandRunner.run(command, line -> logger.debug("{}", line)); return CompletableFuture.completedFuture(outputFile); } - 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 + 3_000 > 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; - } } -- 2.49.1 From 88d83be50a8fbf0b0db5c480818e10e6348db32e Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 19 Jun 2026 22:30:30 +0100 Subject: [PATCH 23/31] feat(stream): implement clipping functionality --- pom.xml | 4 +++ .../controllers/StreamController.java | 10 +++++- .../controllers/input/ClipSectionRequest.java | 7 +++++ .../controllers/input/SaveSectionRequest.java | 8 +++++ .../ddf/vodsystem/dto/SaveSectionRequest.java | 5 --- .../ddf/vodsystem/services/StreamService.java | 31 +++++++++++++++++++ 6 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/ddf/vodsystem/controllers/input/ClipSectionRequest.java create mode 100644 src/main/java/com/ddf/vodsystem/controllers/input/SaveSectionRequest.java delete mode 100644 src/main/java/com/ddf/vodsystem/dto/SaveSectionRequest.java 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/java/com/ddf/vodsystem/controllers/StreamController.java b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java index c0b33c9..c53b8f0 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java @@ -1,7 +1,8 @@ package com.ddf.vodsystem.controllers; +import com.ddf.vodsystem.controllers.input.ClipSectionRequest; import com.ddf.vodsystem.dto.APIResponse; -import com.ddf.vodsystem.dto.SaveSectionRequest; +import com.ddf.vodsystem.controllers.input.SaveSectionRequest; import com.ddf.vodsystem.entities.Stream; import com.ddf.vodsystem.services.StreamService; import org.slf4j.Logger; @@ -53,6 +54,13 @@ public class StreamController { 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); 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/dto/SaveSectionRequest.java b/src/main/java/com/ddf/vodsystem/dto/SaveSectionRequest.java deleted file mode 100644 index 0b1eb5e..0000000 --- a/src/main/java/com/ddf/vodsystem/dto/SaveSectionRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.ddf.vodsystem.dto; - -import java.time.Instant; - -public record SaveSectionRequest(Instant startTime, Instant endTime) {} diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index f56a65f..d001e8f 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -17,6 +17,7 @@ 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.Arrays; @@ -28,7 +29,9 @@ 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 final StreamRepository streamRepository; private final UserService userService; @@ -118,6 +121,34 @@ public class StreamService { }); } + 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"); + } + + User user = userService.getLoggedInUser() + .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); + + Instant endTime = Instant.now(); + // minusSeconds() is possible, but only does integer seconds, not float + Instant startTime = Instant.now().minus(Duration.ofMillis((long) (duration * 1000))); + + 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); + + streamActionsService.saveSection(fileSegments, trimOffset, duration) + .exceptionally(ex -> { + throw new FFMPEGException("Saving stream section failed due to FFMPEG Error"); + }); + } + @Scheduled(fixedDelay = 30_000) @Transactional public void endStaleStreams() { -- 2.49.1 From f6061c25ca0e4dcbe87660b4e79e11f73f132409 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 19 Jun 2026 22:33:50 +0100 Subject: [PATCH 24/31] docs(bruno): add clip endpoint --- bruno/VoD-System/Streams/Save Clip.bru | 22 ++++++++++++++++++++++ bruno/VoD-System/Streams/Save Section.bru | 10 +++------- bruno/VoD-System/Streams/folder.bru | 10 ++++------ 3 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 bruno/VoD-System/Streams/Save Clip.bru 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 index e280b3b..99babfd 100644 --- a/bruno/VoD-System/Streams/Save Section.bru +++ b/bruno/VoD-System/Streams/Save Section.bru @@ -7,17 +7,13 @@ meta { post { url: {{base_url}}/stream/save body: json - auth: bearer -} - -auth:bearer { - token: {{token}} + auth: inherit } body:json { { - "startTime": "2026-06-06T14:20:00Z", - "endTime": "2026-06-06T14:21:00Z" + "startTime": "2026-06-19T21:00:00Z", + "endTime": "2026-06-19T21:03:00Z" } } diff --git a/bruno/VoD-System/Streams/folder.bru b/bruno/VoD-System/Streams/folder.bru index 6b8f9c2..2aa144d 100644 --- a/bruno/VoD-System/Streams/folder.bru +++ b/bruno/VoD-System/Streams/folder.bru @@ -4,11 +4,9 @@ meta { } auth { - mode: apikey + mode: bearer } -auth:apikey { - key: - value: - placement: header -} \ No newline at end of file +auth:bearer { + token: {{token}} +} -- 2.49.1 From 736142e8b3d74e6529aa7ac2efc68069da929e17 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 19 Jun 2026 22:48:53 +0100 Subject: [PATCH 25/31] refactor(stream): clip section extends save section Add javadocs. --- .../ddf/vodsystem/services/StreamService.java | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index d001e8f..3baee53 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -96,6 +96,22 @@ public class StreamService { return streamRepository.findByUser(user); } + /** + * 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 FFMPEGException if the underlying FFMPEG save operation fails + * @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"); @@ -121,32 +137,32 @@ public class StreamService { }); } + /** + * 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 FFMPEGException if the underlying FFMPEG save operation fails + * @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"); } - User user = userService.getLoggedInUser() - .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); - - Instant endTime = Instant.now(); // minusSeconds() is possible, but only does integer seconds, not float - Instant startTime = Instant.now().minus(Duration.ofMillis((long) (duration * 1000))); + Instant endTime = Instant.now(); + Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000))); - 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); - - streamActionsService.saveSection(fileSegments, trimOffset, duration) - .exceptionally(ex -> { - throw new FFMPEGException("Saving stream section failed due to FFMPEG Error"); - }); + saveSection(startTime, endTime); } @Scheduled(fixedDelay = 30_000) @@ -180,7 +196,7 @@ public class StreamService { return Arrays.stream(tsFiles) .filter(f -> { long segMs = parseTimestampMs(f); - return segMs < endMs && segMs + 3_000 > startMs; + return segMs < endMs && segMs > startMs; }) .sorted(Comparator.comparingLong(this::parseTimestampMs)) .collect(Collectors.toList()); -- 2.49.1 From e60b6e36b22993ec8ca295d85e13c7c7581bb641 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 19 Jun 2026 23:00:09 +0100 Subject: [PATCH 26/31] fix(stream clip): blank section at start of clip --- src/main/java/com/ddf/vodsystem/services/StreamService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index 3baee53..07d7b45 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -196,7 +196,7 @@ public class StreamService { return Arrays.stream(tsFiles) .filter(f -> { long segMs = parseTimestampMs(f); - return segMs < endMs && segMs > startMs; + return segMs < endMs && (segMs + 3_000) > startMs; }) .sorted(Comparator.comparingLong(this::parseTimestampMs)) .collect(Collectors.toList()); -- 2.49.1 From 85615ebc544a2e2107024442e5ad49dc36787778 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 19 Jun 2026 23:09:05 +0100 Subject: [PATCH 27/31] fix(schema): mismatch column names for Stream --- docker-compose.dev.yml | 1 - src/main/java/com/ddf/vodsystem/entities/Stream.java | 4 ++-- src/main/resources/db/schema.sql | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index da17515..6cc3fbf 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,7 +26,6 @@ services: - WEB_SERVER_URL=http://host.docker.internal:8080 ports: - "1935:1935" - - "8088:8088" volumes: - ${STREAM_DATA}:/stream pull_policy: build diff --git a/src/main/java/com/ddf/vodsystem/entities/Stream.java b/src/main/java/com/ddf/vodsystem/entities/Stream.java index 204fdb9..325b9c3 100644 --- a/src/main/java/com/ddf/vodsystem/entities/Stream.java +++ b/src/main/java/com/ddf/vodsystem/entities/Stream.java @@ -20,10 +20,10 @@ public class Stream { @JoinColumn(name = "user_id", nullable = false) private User user; - @Column(name = "start_date") + @Column(name = "start_time") private LocalDateTime startDate; - @Column(name = "end_date") + @Column(name = "end_time") private LocalDateTime endDate; @Column(name = "last_seen") diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index f400dbf..d77fd70 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -11,12 +11,12 @@ 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) NOT NULL + stream_key VARCHAR(64) NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS streams ( id BIGSERIAL PRIMARY KEY, - user_id BIGSERIAL, + user_id BIGINT, start_time TIMESTAMPTZ DEFAULT NOW(), end_time TIMESTAMPTZ DEFAULT NULL, last_seen TIMESTAMPTZ DEFAULT NULL, -- 2.49.1 From 8602f65db7ef05385a63796f9c3c9f72c075c42b Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 19 Jun 2026 23:26:11 +0100 Subject: [PATCH 28/31] fix(stream history): add auth missing checks --- .env.example | 3 ++- docker-compose.yml | 2 +- .../ddf/vodsystem/services/StreamService.java | 26 ++++++++++++------- src/main/resources/application.properties | 2 +- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index b1f5962..8864b13 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,5 @@ POSTGRES_USER=myuser POSTGRES_PASSWORD=mypassword POSTGRES_DB=vodSystem -FRONTEND_URL= \ No newline at end of file +FRONTEND_URL= +BACKEND_URL \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3ce1e7f..796f1cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: dockerfile: Dockerfile container_name: vod_frontend environment: - VITE_API_URL: "http://localhost:8080" + VITE_API_URL: ${BACKEND_URL} VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} depends_on: - backend diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index 07d7b45..667ad55 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -3,7 +3,6 @@ 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.FFMPEGException; import com.ddf.vodsystem.exceptions.KeyNotFound; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.repositories.StreamRepository; @@ -32,6 +31,7 @@ public class StreamService { 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; @@ -91,9 +91,20 @@ public class StreamService { } public List getStreamHistory(Long userId) { - User user = userService.getUserById(userId) + User streamUser = userService.getUserById(userId) .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); - return streamRepository.findByUser(user); + + 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); } /** @@ -108,7 +119,6 @@ public class StreamService { * @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 FFMPEGException if the underlying FFMPEG save operation fails * @throws IOException if reading the stream directory or its segments fails * @throws InterruptedException if the thread is interrupted while performing the save */ @@ -131,10 +141,7 @@ public class StreamService { float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f; - streamActionsService.saveSection(fileSegments, trimOffset, duration) - .exceptionally(ex -> { - throw new FFMPEGException("Saving stream section failed due to FFMPEG Error"); - }); + streamActionsService.saveSection(fileSegments, trimOffset, duration); } /** @@ -149,7 +156,6 @@ public class StreamService { * @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 FFMPEGException if the underlying FFMPEG save operation fails * @throws IOException if reading the stream directory or its segments fails * @throws InterruptedException if the thread is interrupted while performing the save */ @@ -196,7 +202,7 @@ public class StreamService { return Arrays.stream(tsFiles) .filter(f -> { long segMs = parseTimestampMs(f); - return segMs < endMs && (segMs + 3_000) > startMs; + return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs; }) .sorted(Comparator.comparingLong(this::parseTimestampMs)) .collect(Collectors.toList()); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1d4fc35..38aa1bd 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -29,7 +29,7 @@ jwt.expiration=3600000 frontend.url=${FRONTEND_URL} # Stream HLS -storage.stream=${STREAM_DATA:/stream} +storage.stream=${STREAM_DATA} ## Server Configuration server.servlet.session.timeout=30m \ No newline at end of file -- 2.49.1 From 37e9b6c1e7f0695974791b67df0c2111b0c2e387 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 20 Jun 2026 01:27:51 +0100 Subject: [PATCH 29/31] refactor(directory): propagate dir creation error to caller --- .../ddf/vodsystem/services/DirectoryService.java | 9 ++------- .../com/ddf/vodsystem/services/StreamService.java | 13 +++++++------ .../services/media/StreamActionsService.java | 5 ++--- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java index 924a5f7..74d8351 100644 --- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -59,15 +59,10 @@ public class DirectoryService { return file; } - public File getVodFile(String filename) { + public File getVodFile(String filename) throws IOException { String dir = outputDir + File.separator + "vods" + File.separator + filename; File file = new File(dir); - - try { - createDirectory(file.getParent()); - } catch (IOException e) { - logger.error("Error creating vods directory: {}", e.getMessage()); - } + createDirectory(file.getParent()); return file; } diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index 667ad55..97e62f1 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -19,10 +19,7 @@ import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; @Service @@ -36,16 +33,19 @@ public class StreamService { 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) { + StreamActionsService streamActionsService, + DirectoryService directoryService) { this.streamRepository = streamRepository; this.userService = userService; this.streamActionsService = streamActionsService; + this.directoryService = directoryService; } public Stream startStream(String streamKey) { @@ -141,7 +141,8 @@ public class StreamService { float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f; - streamActionsService.saveSection(fileSegments, trimOffset, duration); + File outputFile = directoryService.getVodFile(UUID.randomUUID() + ".mp4"); + streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile); } /** 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 a4d4bf4..00a6f33 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -27,14 +27,13 @@ public class StreamActionsService { public CompletableFuture saveSection( List segments, float trimOffset, - float duration + float duration, + File outputFile ) throws IOException, InterruptedException { String concatInput = segments.stream() .map(File::getAbsolutePath) .collect(Collectors.joining("|")); - File outputFile = directoryService.getVodFile(UUID.randomUUID() + ".mp4"); - List command = List.of( "ffmpeg", "-y", -- 2.49.1 From 26a906c7557837d3831711a64a2f168200b032a7 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 20 Jun 2026 01:28:15 +0100 Subject: [PATCH 30/31] docs: update env & bruno --- .env.example | 3 ++- bruno/VoD-System/Streams/Save Section.bru | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 8864b13..6083238 100644 --- a/.env.example +++ b/.env.example @@ -9,9 +9,10 @@ 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 +BACKEND_URL= \ No newline at end of file diff --git a/bruno/VoD-System/Streams/Save Section.bru b/bruno/VoD-System/Streams/Save Section.bru index 99babfd..c6421dd 100644 --- a/bruno/VoD-System/Streams/Save Section.bru +++ b/bruno/VoD-System/Streams/Save Section.bru @@ -12,8 +12,8 @@ post { body:json { { - "startTime": "2026-06-19T21:00:00Z", - "endTime": "2026-06-19T21:03:00Z" + "startTime": "2026-06-20T00:15:00Z", + "endTime": "2026-06-20T00:16:00Z" } } -- 2.49.1 From 7eb757d41ae7d9a5ee1bbbe4c3c45b8d9f45f9db Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sat, 20 Jun 2026 01:29:48 +0100 Subject: [PATCH 31/31] chore: remove .mvn --- .gitignore | 1 + .mvn/wrapper/maven-wrapper.properties | 19 ------------------- 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 .mvn/wrapper/maven-wrapper.properties diff --git a/.gitignore b/.gitignore index 054ece5..e37d9bb 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ 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 -- 2.49.1